Моки и стабы — подмена зависимостей в тестах
Зачем нужны моки
При тестировании модуля вы хотите проверить его логику, а не работу внешних зависимостей (БД, API, файловая система, email). Моки (mocks) подменяют реальные зависимости контролируемыми объектами.
| Термин | Назначение |
|---|---|
| Mock | Имитация объекта с возможностью отслеживать вызовы |
| Stub | Возвращает заготовленные данные |
| Spy | Оборачивает реальный объект, записывая вызовы |
| Fake | Рабочая, но упрощённая реализация (например, in-memory БД) |
from unittest.mock import Mock
# Create a mock object
api_client = Mock()
# Configure return value
api_client.get_user.return_value = {"id": 1, "name": "Алексей"}
# Use mock like a real object
user = api_client.get_user(user_id=1)
print(user) # {'id': 1, 'name': 'Алексей'}
# Verify the call was made
api_client.get_user.assert_called_once_with(user_id=1)
Mock и MagicMock
Mock — базовый мок-объект. MagicMock — расширенная версия, поддерживающая magic-методы (__len__, __iter__, __getitem__ и др.).
from unittest.mock import Mock, MagicMock
# Basic Mock
m = Mock()
m.some_method(1, 2, key="value")
m.some_method.assert_called_with(1, 2, key="value")
# Mock with spec — restricts to real object's interface
class UserService:
def get_user(self, user_id: int) -> dict:
...
def create_user(self, name: str) -> int:
...
mock_service = Mock(spec=UserService)
mock_service.get_user(1) # OK
# mock_service.nonexistent() # AttributeError!
# MagicMock — supports magic methods
magic = MagicMock()
magic.__len__.return_value = 42
len(magic) # 42
magic.__getitem__.return_value = "значение"
magic["key"] # 'значение'
# MagicMock as context manager
magic.__enter__.return_value = "ресурс"
with magic as resource:
print(resource) # 'ресурс'
Настройка возвращаемых значений
from unittest.mock import Mock
api = Mock()
# Simple return value
api.get_status.return_value = "ok"
assert api.get_status() == "ok"
# return_value for chained calls
api.session.get.return_value.json.return_value = {"data": [1, 2, 3]}
result = api.session.get("url").json()
assert result == {"data": [1, 2, 3]}
# side_effect — function called instead of return_value
def dynamic_response(url: str) -> dict:
if "users" in url:
return {"users": ["Алексей"]}
return {"error": "Not found"}
api.fetch.side_effect = dynamic_response
assert api.fetch("/api/users") == {"users": ["Алексей"]}
assert api.fetch("/api/other") == {"error": "Not found"}
# side_effect — raise exception
api.delete.side_effect = PermissionError("Нет доступа")
try:
api.delete("/api/resource")
except PermissionError as e:
print(f"Ошибка: {e}")
# side_effect — list of values (returns in order)
api.next_id.side_effect = [1, 2, 3]
assert api.next_id() == 1
assert api.next_id() == 2
assert api.next_id() == 3
Проверка вызовов (assertions)
from unittest.mock import Mock, call
service = Mock()
# Make some calls
service.process("a")
service.process("b")
service.process("c")
# Verify call count
assert service.process.call_count == 3
# Verify specific call
service.process.assert_called_with("c") # Last call
# Verify call was made at least once
service.process.assert_called()
# Verify exact call sequence
service.process.assert_has_calls([
call("a"),
call("b"),
call("c"),
])
# Verify any-order calls
service.process.assert_has_calls(
[call("c"), call("a")],
any_order=True,
)
# Verify NOT called
fresh_mock = Mock()
fresh_mock.method.assert_not_called()
# Reset mock
service.process.reset_mock()
assert service.process.call_count == 0
@patch — подмена объектов в контексте
Декоратор @patch временно заменяет объект моком на время теста:
# weather_service.py
import requests
def get_temperature(city: str) -> float:
"""Get temperature from weather API."""
response = requests.get(
f"https://api.weather.com/v1/current",
params={"city": city},
)
response.raise_for_status()
data = response.json()
return data["temperature"]
def get_forecast(city: str) -> str:
"""Get weather forecast."""
temp = get_temperature(city)
if temp > 25:
return "Жарко"
elif temp > 10:
return "Тепло"
else:
return "Холодно"
# test_weather.py
from unittest.mock import patch, Mock
from weather_service import get_temperature, get_forecast
# Approach 1: @patch as decorator
@patch("weather_service.requests.get")
def test_get_temperature(mock_get: Mock) -> None:
# Configure mock response
mock_response = Mock()
mock_response.json.return_value = {"temperature": 22.5}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = get_temperature("Москва")
assert result == 22.5
mock_get.assert_called_once()
# Approach 2: patch as context manager
def test_get_forecast_hot() -> None:
with patch("weather_service.get_temperature", return_value=30.0):
result = get_forecast("Сочи")
assert result == "Жарко"
def test_get_forecast_cold() -> None:
with patch("weather_service.get_temperature", return_value=5.0):
result = get_forecast("Мурманск")
assert result == "Холодно"
Правило: patch where it's used
Важно: патчить нужно объект в том месте, где он используется, а не где он определён:
# WRONG: patching where requests is defined
# @patch("requests.get")
# RIGHT: patching where requests is imported/used
@patch("weather_service.requests.get")
def test_correct_patch(mock_get):
pass
patch.object и patch.dict
from unittest.mock import patch, MagicMock
import os
class NotificationService:
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def send_push(self, user_id: int, message: str) -> bool:
return True
def send_email(self, email: str, body: str) -> bool:
return True
# patch.object — patch a method on a specific object
def test_patch_object() -> None:
service = NotificationService(api_key="test-key")
with patch.object(service, "send_push", return_value=False) as mock_push:
result = service.send_push(123, "Привет")
assert result is False
mock_push.assert_called_once_with(123, "Привет")
# patch.dict — patch a dictionary temporarily
def test_patch_environ() -> None:
with patch.dict(os.environ, {"API_KEY": "test-123", "DEBUG": "true"}):
assert os.environ["API_KEY"] == "test-123"
assert os.environ["DEBUG"] == "true"
# Original values restored
assert "API_KEY" not in os.environ
Стратегии: Fake-объекты vs Mock
from dataclasses import dataclass, field
from typing import Protocol
# Protocol-based dependency
class Storage(Protocol):
def save(self, key: str, data: bytes) -> None: ...
def load(self, key: str) -> bytes: ...
# Fake — in-memory implementation for tests
@dataclass
class FakeStorage:
_data: dict[str, bytes] = field(default_factory=dict)
def save(self, key: str, data: bytes) -> None:
self._data[key] = data
def load(self, key: str) -> bytes:
return self._data[key]
@dataclass
class DocumentService:
storage: Storage
def save_document(self, name: str, content: str) -> None:
self.storage.save(name, content.encode())
def load_document(self, name: str) -> str:
data = self.storage.load(name)
return data.decode()
def test_with_fake() -> None:
storage = FakeStorage()
service = DocumentService(storage=storage)
service.save_document("doc.txt", "Привет")
result = service.load_document("doc.txt")
assert result == "Привет"
Практический пример: тестирование платёжного сервиса
from dataclasses import dataclass
from enum import Enum
from unittest.mock import Mock
import pytest
class PaymentStatus(Enum):
SUCCESS = "success"
FAILED = "failed"
PENDING = "pending"
@dataclass
class PaymentResult:
status: PaymentStatus
transaction_id: str | None = None
error: str | None = None
class PaymentGateway:
def charge(self, amount: float, card_token: str) -> dict:
raise NotImplementedError
class PaymentService:
def __init__(self, gateway: PaymentGateway, notifier) -> None:
self.gateway = gateway
self.notifier = notifier
def process_payment(self, amount: float, card_token: str, email: str) -> PaymentResult:
if amount <= 0:
raise ValueError("Сумма должна быть положительной")
try:
result = self.gateway.charge(amount, card_token)
payment = PaymentResult(
status=PaymentStatus.SUCCESS,
transaction_id=result["transaction_id"],
)
self.notifier.send(email, f"Оплата {amount} прошла успешно")
return payment
except ConnectionError:
return PaymentResult(
status=PaymentStatus.FAILED,
error="Платёжная система недоступна",
)
# Tests
@pytest.fixture
def gateway() -> Mock:
return Mock(spec=PaymentGateway)
@pytest.fixture
def notifier() -> Mock:
return Mock()
@pytest.fixture
def service(gateway: Mock, notifier: Mock) -> PaymentService:
return PaymentService(gateway=gateway, notifier=notifier)
def test_successful_payment(service, gateway, notifier) -> None:
gateway.charge.return_value = {"transaction_id": "txn_123"}
result = service.process_payment(500.0, "tok_abc", "[email protected]")
assert result.status == PaymentStatus.SUCCESS
assert result.transaction_id == "txn_123"
gateway.charge.assert_called_once_with(500.0, "tok_abc")
notifier.send.assert_called_once()
def test_payment_gateway_error(service, gateway, notifier) -> None:
gateway.charge.side_effect = ConnectionError("Timeout")
result = service.process_payment(500.0, "tok_abc", "[email protected]")
assert result.status == PaymentStatus.FAILED
assert "недоступна" in result.error
notifier.send.assert_not_called()
def test_negative_amount_raises(service) -> None:
with pytest.raises(ValueError, match="положительной"):
service.process_payment(-100.0, "tok_abc", "[email protected]")
@pytest.mark.parametrize("amount", [100, 500, 9999.99])
def test_various_amounts(service, gateway, amount) -> None:
gateway.charge.return_value = {"transaction_id": f"txn_{amount}"}
result = service.process_payment(amount, "tok_abc", "[email protected]")
assert result.status == PaymentStatus.SUCCESS