MidТеория7 min

Моки и стабы

Mock, MagicMock, @patch, side_effect и стратегии подмены зависимостей

Моки и стабы — подмена зависимостей в тестах

Зачем нужны моки

При тестировании модуля вы хотите проверить его логику, а не работу внешних зависимостей (БД, 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

Проверь себя

Какова основная цель использования моков в тестах?

Чем MagicMock отличается от Mock?

Где нужно указывать путь к объекту при использовании @patch?

В чём разница между fake-объектом и mock-объектом?