
Python Testing: Моки и изоляция тестов
Для изоляции тестов часто необходимо заменять внешние зависимости моками. Моки (mock objects) — это объекты, которые имитируют поведение реальных объектов, но в контролируемой среде. Это позволяет тестировать код независимо от внешних систем, таких как базы данных, веб-сервисы или файловая система.
1. Зачем нужны моки?
Основные причины использования моков:
- Изоляция тестов — тесты не зависят от внешних систем
- Скорость — моки работают быстрее, чем реальные системы
- Надёжность — тесты не падают из-за проблем с внешними сервисами
- Контроль — можно симулировать различные сценарии (ошибки, медленные ответы)
- Повторяемость — тесты дают одинаковые результаты при каждом запуске
Типы моков:
- Mock — простой объект, который может имитировать любой атрибут или метод
- MagicMock — расширенная версия Mock с предустановленными магическими методами
- patch — декоратор для временной замены объектов в модуле
- Mock с spec — мок, который проверяет соответствие интерфейсу реального объекта
2. unittest.mock
Модуль unittest.mock
входит в стандартную библиотеку Python и предоставляет мощные инструменты для создания моков.
2.1. Базовые моки
from unittest.mock import Mock, patch, MagicMock
import unittest
class TestWithMocks(unittest.TestCase):
def test_mock_basic(self):
"""Базовый пример мока"""
mock_obj = Mock()
mock_obj.some_method.return_value = "mocked result"
result = mock_obj.some_method()
self.assertEqual(result, "mocked result")
mock_obj.some_method.assert_called_once()
def test_mock_with_arguments(self):
"""Мок с аргументами"""
mock_func = Mock()
mock_func.return_value = 42
result = mock_func("arg1", "arg2", kwarg="value")
self.assertEqual(result, 42)
mock_func.assert_called_once_with("arg1", "arg2", kwarg="value")
def test_mock_side_effect(self):
"""Мок с побочными эффектами"""
mock_func = Mock()
mock_func.side_effect = [1, 2, 3] # возвращает разные значения
self.assertEqual(mock_func(), 1)
self.assertEqual(mock_func(), 2)
self.assertEqual(mock_func(), 3)
# После третьего вызова возвращает StopIteration
with self.assertRaises(StopIteration):
mock_func()
2.2. Патчинг функций и классов
@patch('builtins.open')
def test_file_operations(self, mock_open):
"""Тест с патчингом встроенной функции"""
mock_open.return_value.__enter__.return_value.read.return_value = "file content"
with open('test.txt') as f:
content = f.read()
self.assertEqual(content, "file content")
mock_open.assert_called_once_with('test.txt')
@patch('requests.get')
def test_api_call(self, mock_get):
"""Тест API вызова с моком"""
# Настраиваем мок для имитации успешного ответа
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "success", "data": [1, 2, 3]}
mock_get.return_value = mock_response
# Вызываем функцию, которая делает API запрос
result = fetch_data_from_api("https://api.example.com/data")
# Проверяем результат
self.assertEqual(result["status"], "success")
self.assertEqual(len(result["data"]), 3)
# Проверяем, что API был вызван с правильными параметрами
mock_get.assert_called_once_with("https://api.example.com/data")
2.3. Моки с spec
def test_mock_with_spec(self):
"""Тест с моком, который проверяет соответствие интерфейсу"""
# Создаём мок, который должен соответствовать интерфейсу User
mock_user = Mock(spec=['name', 'email', 'get_info'])
mock_user.name = "Test User"
mock_user.email = "test@example.com"
mock_user.get_info.return_value = "Test User (test@example.com)"
# Тестируем код, который работает с пользователем
result = process_user_info(mock_user)
self.assertEqual(result, "Test User (test@example.com)")
mock_user.get_info.assert_called_once()
def test_mock_with_spec_error(self):
"""Тест ошибки при обращении к несуществующему атрибуту"""
mock_user = Mock(spec=['name', 'email'])
# Это работает
mock_user.name = "Test"
# Это вызовет AttributeError
with self.assertRaises(AttributeError):
mock_user.age = 25
2.4. Дополнительные возможности моков
def test_mock_advanced_features(self):
"""Продвинутые возможности моков"""
mock = Mock()
# Настройка возвращаемых значений
mock.method.return_value = "result"
# Настройка побочных эффектов
mock.side_effect_func.side_effect = Exception("Database error")
# Настройка атрибутов
mock.attribute = "value"
# Проверка вызовов
mock.method()
mock.method.assert_called()
mock.method.assert_called_once()
mock.method.assert_called_with()
# Проверка количества вызовов
mock.method()
self.assertEqual(mock.method.call_count, 2)
# Проверка аргументов вызова
mock.method_with_args("arg1", kwarg="value")
mock.method_with_args.assert_called_with("arg1", kwarg="value")
# Проверка, что метод не был вызван
mock.uncalled_method.assert_not_called()
3. pytest-mock
pytest-mock
— это плагин для pytest, который предоставляет более удобный интерфейс для работы с моками.
3.1. Установка и базовое использование
pip install pytest-mock
import pytest
def test_with_pytest_mock(mocker):
"""Тест с использованием pytest-mock"""
# Мок для requests.get
mock_get = mocker.patch('requests.get')
mock_get.return_value.json.return_value = {"status": "success"}
# Ваш код, который использует requests.get
import requests
response = requests.get('https://api.example.com/data')
data = response.json()
assert data["status"] == "success"
mock_get.assert_called_once_with('https://api.example.com/data')
def test_api_error_handling(mocker):
"""Тест обработки ошибок API"""
# Мокаем requests.get для имитации ошибки сети
mock_get = mocker.patch('requests.get')
mock_get.side_effect = requests.RequestException("Network error")
# Тестируем функцию, которая должна обрабатывать ошибки
result = safe_api_call("https://api.example.com/data")
assert result is None # функция должна вернуть None при ошибке
3.2. Фикстуры с моками
@pytest.fixture
def mock_database(mocker):
"""Фикстура для мока базы данных"""
mock_db = mocker.patch('database.connection')
mock_db.query.return_value.filter.return_value.first.return_value = {
'id': 1, 'name': 'Test User'
}
return mock_db
def test_user_lookup(mock_database):
"""Тест с использованием фикстуры-мока"""
# Ваш код, который работает с базой данных
user = mock_database.query().filter().first()
assert user['name'] == 'Test User'
@pytest.fixture
def mock_file_system(mocker):
"""Фикстура для мока файловой системы"""
mock_os = mocker.patch('os.path.exists')
mock_os.return_value = True
mock_open = mocker.patch('builtins.open', mocker.mock_open(read_data="file content"))
return {
'exists': mock_os,
'open': mock_open
}
def test_file_processing(mock_file_system):
"""Тест обработки файла с моком файловой системы"""
result = process_file("test.txt")
assert result == "file content"
mock_file_system['exists'].assert_called_once_with("test.txt")
mock_file_system['open'].assert_called_once_with("test.txt", "r")
3.3. Контекстные менеджеры
def test_context_manager_mocking(mocker):
"""Тест с использованием контекстных менеджеров"""
with mocker.patch('requests.get') as mock_get:
mock_get.return_value.json.return_value = {"data": "test"}
response = requests.get('https://api.example.com/data')
data = response.json()
assert data["data"] == "test"
mock_get.assert_called_once()
def test_multiple_patches(mocker):
"""Тест с несколькими патчами"""
with mocker.patch('requests.get') as mock_get, \
mocker.patch('time.sleep') as mock_sleep:
mock_get.return_value.json.return_value = {"status": "ok"}
result = fetch_data_with_retry("https://api.example.com/data")
assert result["status"] == "ok"
mock_get.assert_called_once()
mock_sleep.assert_called_once()
4. Практические примеры
4.1. Моки для базы данных
import pytest
from unittest.mock import Mock
class TestDatabaseOperations:
def test_user_creation(self, mocker):
"""Тест создания пользователя с моком БД"""
# Мокаем соединение с базой данных
mock_connection = mocker.patch('database.get_connection')
mock_cursor = Mock()
mock_connection.return_value.cursor.return_value = mock_cursor
# Настраиваем мок для вставки
mock_cursor.execute.return_value = None
mock_cursor.fetchone.return_value = (1,) # ID созданного пользователя
# Вызываем функцию создания пользователя
user_id = create_user("John", "john@example.com")
# Проверяем результат
assert user_id == 1
# Проверяем, что SQL был вызван с правильными параметрами
mock_cursor.execute.assert_called_once()
call_args = mock_cursor.execute.call_args
assert "INSERT INTO users" in call_args[0][0]
assert call_args[0][1] == ("John", "john@example.com")
def test_database_error_handling(self, mocker):
"""Тест обработки ошибок базы данных"""
mock_connection = mocker.patch('database.get_connection')
mock_cursor = Mock()
mock_connection.return_value.cursor.return_value = mock_cursor
# Симулируем ошибку базы данных
mock_cursor.execute.side_effect = Exception("Database connection failed")
# Проверяем, что функция правильно обрабатывает ошибку
with pytest.raises(DatabaseError):
create_user("John", "john@example.com")
4.2. Моки для внешних API
class TestExternalAPI:
def test_api_success(self, mocker):
"""Тест успешного API вызова"""
mock_requests = mocker.patch('requests.get')
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": 123,
"name": "Test User",
"email": "test@example.com"
}
mock_requests.return_value = mock_response
result = fetch_user_from_api(123)
assert result["name"] == "Test User"
assert result["email"] == "test@example.com"
mock_requests.assert_called_once_with("https://api.example.com/users/123")
def test_api_timeout(self, mocker):
"""Тест таймаута API"""
mock_requests = mocker.patch('requests.get')
mock_requests.side_effect = requests.Timeout("Request timeout")
result = fetch_user_from_api(123)
assert result is None
mock_requests.assert_called_once()
def test_api_rate_limiting(self, mocker):
"""Тест ограничения скорости API"""
mock_requests = mocker.patch('requests.get')
mock_response = Mock()
mock_response.status_code = 429 # Too Many Requests
mock_response.json.return_value = {"error": "Rate limit exceeded"}
mock_requests.return_value = mock_response
with pytest.raises(RateLimitError):
fetch_user_from_api(123)
4.3. Моки для файловой системы
class TestFileOperations:
def test_file_reading(self, mocker):
"""Тест чтения файла"""
mock_open = mocker.patch('builtins.open', mocker.mock_open(read_data="file content"))
mock_os = mocker.patch('os.path.exists')
mock_os.return_value = True
content = read_file("test.txt")
assert content == "file content"
mock_open.assert_called_once_with("test.txt", "r")
def test_file_not_found(self, mocker):
"""Тест обработки отсутствующего файла"""
mock_os = mocker.patch('os.path.exists')
mock_os.return_value = False
with pytest.raises(FileNotFoundError):
read_file("nonexistent.txt")
def test_file_writing(self, mocker):
"""Тест записи в файл"""
mock_open = mocker.mock_open()
mocker.patch('builtins.open', mock_open)
write_file("test.txt", "new content")
mock_open.assert_called_once_with("test.txt", "w")
mock_open().write.assert_called_once_with("new content")
4.4. Моки для времени и дат
from datetime import datetime
import time
class TestTimeOperations:
def test_current_time(self, mocker):
"""Тест работы с текущим временем"""
# Мокаем datetime.now
mock_datetime = mocker.patch('datetime.datetime')
mock_datetime.now.return_value = datetime(2023, 1, 1, 12, 0, 0)
current_time = get_current_time()
assert current_time == "2023-01-01 12:00:00"
def test_sleep_operation(self, mocker):
"""Тест операции ожидания"""
mock_sleep = mocker.patch('time.sleep')
wait_for_seconds(5)
mock_sleep.assert_called_once_with(5)
def test_timed_operation(self, mocker):
"""Тест измерения времени выполнения"""
mock_time = mocker.patch('time.time')
mock_time.side_effect = [100.0, 105.0] # начало и конец
duration = measure_execution_time(some_function)
assert duration == 5.0
5. Лучшие практики работы с моками
5.1. Принципы мокирования
1. Мокайте на правильном уровне:
# Хорошо - мокаем интерфейс
mocker.patch('database.connection')
# Плохо - мокаем реализацию
mocker.patch('database.connection._internal_method')
2. Используйте spec для проверки интерфейса:
# Хорошо - проверяем соответствие интерфейсу
mock_user = Mock(spec=['name', 'email', 'get_info'])
# Плохо - неограниченный мок
mock_user = Mock()
3. Проверяйте вызовы моков:
def test_api_call(mocker):
mock_get = mocker.patch('requests.get')
mock_get.return_value.json.return_value = {"status": "ok"}
result = fetch_data("https://api.example.com/data")
# Проверяем не только результат, но и вызов
assert result["status"] == "ok"
mock_get.assert_called_once_with("https://api.example.com/data")
4. Избегайте избыточного мокирования:
# Хорошо - мокаем только внешние зависимости
def test_user_creation(mocker):
mock_db = mocker.patch('database.connection')
# НЕ мокаем внутренние функции
result = create_user("John", "john@example.com")
assert result.name == "John"
# Плохо - избыточное мокирование
def test_user_creation_bad(mocker):
mock_db = mocker.patch('database.connection')
mock_validate = mocker.patch('utils.validate_email') # избыточно
mock_hash = mocker.patch('utils.hash_password') # избыточно
5.2. Организация моков
# conftest.py - общие моки
@pytest.fixture
def mock_external_api(mocker):
"""Общий мок для внешнего API"""
mock_api = mocker.patch('external_api.client')
mock_api.get_data.return_value = {"status": "success", "data": []}
mock_api.post_data.return_value = {"status": "success", "id": 123}
return mock_api
# test_specific.py - специфичные моки
def test_specific_api_call(mock_external_api, mocker):
"""Тест с дополнительными моками"""
# Переопределяем общий мок для конкретного теста
mock_external_api.get_data.return_value = {"status": "error"}
result = handle_api_response()
assert result is None
5.3. Отладка моков
def test_debug_mocks(mocker):
"""Тест с отладочной информацией"""
mock_func = mocker.patch('some_module.function')
mock_func.return_value = "mocked"
# Включаем отладку
mock_func.side_effect = lambda *args, **kwargs: print(f"Called with {args}, {kwargs}")
result = some_module.function("test", kwarg="value")
# Проверяем историю вызовов
print(f"Call history: {mock_func.call_args_list}")
print(f"Call count: {mock_func.call_count}")
6. Антипаттерны и ошибки
6.1. Распространённые ошибки
1. Мокирование того, что тестируете:
# Плохо - мокаем саму тестируемую функцию
def test_bad_mocking(mocker):
mock_calc = mocker.patch('calculator.add') # мокаем то, что тестируем
mock_calc.return_value = 5
result = calculator.add(2, 3) # бессмысленный тест
assert result == 5
2. Непроверка вызовов моков:
# Плохо - не проверяем, что мок был вызван
def test_no_assertion(mocker):
mock_api = mocker.patch('api.call')
mock_api.return_value = {"status": "ok"}
result = process_data()
assert result["status"] == "ok"
# НЕ проверяем, что API был вызван
3. Слишком сложные моки:
# Плохо - слишком сложная настройка мока
def test_overcomplicated_mock(mocker):
mock_db = mocker.patch('database.connection')
mock_cursor = Mock()
mock_db.return_value.cursor.return_value = mock_cursor
mock_cursor.execute.return_value = None
mock_cursor.fetchall.return_value = [{"id": 1}, {"id": 2}]
mock_cursor.fetchone.return_value = {"id": 1}
# ... ещё 10 строк настройки
6.2. Правильные альтернативы
1. Тестируйте реальную логику:
# Хорошо - тестируем реальную логику
def test_good_mocking(mocker):
mock_db = mocker.patch('database.connection')
mock_db.return_value.query.return_value.filter.return_value.first.return_value = {"id": 1}
result = get_user_by_id(1)
assert result["id"] == 1
mock_db.assert_called_once()
2. Используйте фикстуры для сложных моков:
@pytest.fixture
def mock_database_complex(mocker):
"""Сложный мок базы данных в фикстуре"""
mock_db = mocker.patch('database.connection')
mock_cursor = Mock()
mock_db.return_value.cursor.return_value = mock_cursor
mock_cursor.execute.return_value = None
mock_cursor.fetchall.return_value = [{"id": 1}, {"id": 2}]
return mock_db
def test_with_complex_mock(mock_database_complex):
"""Тест с использованием сложного мока"""
result = get_all_users()
assert len(result) == 2
7. Заключение
Моки — это мощный инструмент для изоляции тестов, но их нужно использовать правильно. Основные принципы:
Ключевые принципы:
- Мокайте интерфейсы, а не реализации
- Используйте spec для проверки соответствия интерфейсу
- Проверяйте не только результаты, но и вызовы моков
- Избегайте избыточного мокирования
- Организуйте моки в фикстуры для переиспользования
Когда использовать моки:
- Для изоляции от внешних систем (БД, API, файловая система)
- Для симуляции ошибок и исключительных ситуаций
- Для ускорения тестов
- Для тестирования асинхронного кода
Когда НЕ использовать моки:
- Для тестирования простой логики
- Для мокирования того, что вы тестируете
- Когда интеграционные тесты более подходят
Следующие шаги: В следующей статье мы рассмотрим покрытие кода и метрики качества — как измерять эффективность тестов.
Дополнительные ресурсы: