Тестирование: пирамида и тестовые двойники
Unit, integration, contract, e2e. Mock vs Stub vs Fake. Лондонская vs классическая школа TDD. Что мокать, что нет.
TL;DR
- Пирамида (много unit, меньше integration, минимум e2e) до сих пор актуальна.
- Mock ≠ Stub ≠ Fake. Meszaros их разделил.
- Лондонская школа (Freeman & Pryce): mock everything. Классическая (Beck, Khorikov): mock только outside boundaries.
- Contract testing замещает "горизонтальный" integration в микросервисах.
Пирамида и её вариации
Классическая пирамида (Mike Cohn, 2009):
▲
/e\ e2e — единичные
/2 e\
/─────\
/ integ \ integration — десятки
/─────────\
/ unit \ unit — тысячи
/─────────────\
Идея простая: много быстрых юнитов, меньше медленных integration’ов, минимум ещё более медленных e2e.
Мотивация:
- Скорость обратной связи. Unit проходят за секунды, integration — минуты, e2e — десятки минут.
- Стоимость поддержки. E2e хрупкие: сломался UI, отвалилась сеть — тест красный без бага в коде.
- Локализация ошибки. Unit ловит баг в конкретном классе. E2e — «что-то где-то сломалось».
Альтернативы пирамиде
Testing Trophy (Kent C. Dodds) — акцент на integration:
▲
/e\
/ 2 \
/─────\
/ \
/ integr. \ ← основной вес
/───────────\
/ unit \
/ \
/ static + type\
Аргумент: тестируем то, что реально ломается — интеграции. Unit-тесты «правильных» вызовов — не всегда стоят усилий.
Testing Honeycomb (Spotify): тоже integration-heavy.
Разница риторическая. Пирамида по-прежнему полезная модель, но не догма.
Тестовые двойники (Meszaros)
unittest.mock.Mock умеет всё сразу — часто путают роли. Gerard Meszaros в xUnit Test Patterns (2007) разделил:
Объект, передаваемый только для заполнения параметра. Не используется.
Возвращает заготовленные ответы на запросы. Никаких проверок.
Записывает, как его вызывали. Проверки — после теста.
Заранее объявлены ожидания. Проверка — во время вызова (или в assert_called).
Полноценная альтернативная реализация. InMemoryUserRepository вместо PostgresUserRepository.
Практическая разница:
# Stub — просто возвращает
class StubEmailSender:
async def send(self, to, subject, body):
return 'sent-id-123' # ответ подготовлен
# Spy — записывает вызовы
class SpyEmailSender:
def __init__(self):
self.sent = []
async def send(self, to, subject, body):
self.sent.append((to, subject, body))
# Mock — с ожиданиями
mock = MagicMock()
mock.send = AsyncMock(return_value='ok')
await service.run()
mock.send.assert_called_once_with('a@b', 'welcome', 'hi')
# Fake — рабочая имплементация
class FakeEmailSender:
def __init__(self):
self.inbox: dict[str, list] = {}
async def send(self, to, subject, body):
self.inbox.setdefault(to, []).append((subject, body))
Две школы TDD
Классическая (Detroit / Chicago)
Ассоциируется с Kent Beck (TDD by Example, 2002).
- Test-first, но mock редко.
- Работаем с real объектами и fakes (in-memory implementations).
- Тесты проверяют state: «после операции состояние стало таким».
- Работает снизу вверх: пишем entity, потом service, потом controller.
Пример:
def test_confirms_order_when_pending():
order = Order(id=OrderId(1), status=OrderStatus.PENDING, items=[...])
order.confirm()
assert order.status is OrderStatus.CONFIRMED
Лондонская (mockist)
Ассоциируется с Steve Freeman & Nat Pryce (GOOS, 2009).
- Test-first, mock everything.
- Каждая зависимость — mock.
- Тесты проверяют interactions: «вызвал ли этот метод с этими аргументами».
- Работает сверху вниз: mock’ами определяем интерфейсы соседей, потом реализуем.
Пример:
async def test_place_order_notifies_customer():
repo = AsyncMock()
notifier = AsyncMock()
handler = PlaceOrderHandler(repo, notifier)
await handler.execute(cmd)
notifier.notify.assert_called_once_with(cmd.customer_id, 'ordered')
Что выбрать
Test behavior, not implementation. Mock only at the boundaries of the system.
Khorikov предлагает срединный путь:
- State-based тесты по умолчанию — проверяем результат, не как достигнут.
- Mock только outside boundaries — то, что вылетает за пределы приложения (email, HTTP, message broker).
- Fake для внутренних зависимостей (repository, cache, timer). Не mock.
Это правило масштабируется. Классическая школа — для domain и aggregate. Mockист — на границе с infrastructure.
Что мокать, что нет
Простое правило: mock только то, что владеешь и что вылетает за пределы приложения.
| Зависимость | Что использовать |
|---|---|
| Aggregate, Value Object | Реальный |
| Domain Service | Реальный |
| Repository | Fake (in-memory) |
| Unit of Work | Fake |
| Event Bus / Publisher | Fake (capturing publisher) |
| External API client (HTTP) | Mock или контрактный тест |
| Email/SMS/Push provider | Mock |
| DateTime.now() | Stub (frozen time) |
| Filesystem | Fake (in-memory FS) |
| Redis, Postgres | Реальный через testcontainers для integration |
Ключевая идея: fake быстрее, mock проще, но mock хрупче — при рефакторинге теста меняем меньше.
Виды тестов
Unit tests
- Тестируют одну единицу (класс, функция).
- Без БД, без сети, без файловой системы.
- Работают за миллисекунды. Тысячи в CI.
- Основа пирамиды.
Что тестируем: aggregate методы, доменные сервисы, чистые функции.
Integration tests
- Тестируют взаимодействие с infrastructure: БД, брокер, кэш.
- Реальные (или fake) внешние сервисы через testcontainers.
- За секунды — минуты. Сотни в CI.
Что тестируем: Repository, UoW, projections, integrations с брокером.
Contract tests
- Тестируют контракт между сервисами без запуска обоих.
- Consumer описывает ожидания (Pact contract), Provider проверяет.
Незаменимы для микросервисов — заменяют «горизонтальный» integration test через несколько сервисов.
E2e tests
- Проходят через весь стек: UI → API → БД → external.
- Медленные, хрупкие. Единицы, максимум десятки.
Тестируем: критические user journey. «Оформить заказ», «зарегистрироваться».
Как правильно: тесты aggregate
class TestOrderConfirm:
def test_confirms_pending_order(self):
order = _pending_order_with_lines()
order.confirm()
assert order.status is OrderStatus.CONFIRMED
def test_rejects_confirm_when_cancelled(self):
order = _cancelled_order()
with pytest.raises(DomainError, match='cannot confirm'):
order.confirm()
def test_rejects_confirm_when_no_lines(self):
order = _pending_order_without_lines()
with pytest.raises(DomainError, match='empty'):
order.confirm()
def test_emits_confirmed_event(self):
order = _pending_order_with_lines()
order.confirm()
events = order.pull_events()
assert len(events) == 1
assert isinstance(events[0], OrderConfirmed)
Ноль моков. Real domain-objects. Быстрые. Читаются как спецификация.
Как правильно: use case с fake’ами
class FakeOrderRepository:
def __init__(self, seed: dict[OrderId, Order] | None = None):
self._store = seed or {}
async def find_by_id(self, order_id):
return self._store.get(order_id)
async def save(self, order):
self._store[order.id] = order
class CapturingEventPublisher:
def __init__(self):
self.events: list[DomainEvent] = []
async def publish(self, event):
self.events.append(event)
async def test_place_order_creates_and_publishes():
orders = FakeOrderRepository()
publisher = CapturingEventPublisher()
handler = PlaceOrderHandler(orders, publisher, uow=NullUnitOfWork())
order_id = await handler.execute(PlaceOrderCommand(...))
assert order_id in orders._store
assert len(publisher.events) == 1
assert isinstance(publisher.events[0], OrderPlaced)
Fake-подход, а не mock. Тесты стабильны к рефакторингу — при изменении реализации PlaceOrderHandler fake остаётся.
Как не надо
1. Mocking my own code
def test_confirms_order():
mock_order = MagicMock() # !!
mock_order.status = OrderStatus.PENDING
...
mock_order.confirm.assert_called_once()
Мокаем Order, а потом проверяем, что вызван confirm. Ничего не проверено — только «мы вызвали, что вызвали».
Правильно: реальный Order, проверить status после confirm().
2. Тесты на imlementation details
def test_handler_calls_repo_find_then_save():
handler.execute(cmd)
assert mock_repo.method_calls == [
call.find_by_id(1),
call.save(mock.ANY)
]
Тест ломается при любом рефакторинге handler’а. Хрупкий.
Правильно: проверять результат (state в fake repo), а не последовательность вызовов.
3. Ignoring integration tests
Только unit. «Всё работает, тесты зелёные». В проде — 500 при любом контакте с БД, потому что репозиторий не проверен вживую.
Правильно: хотя бы контрактные integration тесты для Repository с реальной БД через testcontainers.
4. Всё через e2e
«Тесты через API покрывают всё». Тысячи e2e тестов, час прогона, половина flaky.
Правильно: пирамида. E2e — только критические user journey.
5. Мок замораживает время внутри aggregate
class Order:
def confirm(self):
self.confirmed_at = datetime.utcnow() # !!
Тестировать точное время — невозможно. Мокать datetime.utcnow — костыль.
Правильно: передавать clock: def confirm(self, at: datetime), тесты подставляют fixed time. Или Clock abstraction через DI.
Trade-offs
| Ситуация | Классическая | Лондонская | Пирамида vs Trophy |
|---|---|---|---|
| Богатый domain | ✓ | Пирамида | |
| Много интеграций | ✓ | Trophy | |
| Sync CRUD | ✓ | Не важно | |
| Микросервисы | ✓ (contract tests) | Trophy или Пирамида | |
| Aggregate с инвариантами | ✓ | Пирамида |
В твоём же коде
Анонимизированный микросервис имеет unit-тесты с моками — путь mockist. Что можно улучшить:
Плюс: есть parity-тесты (сравнение старой и новой реализации) — форма характеризационных тестов.
Что стоит добавить:
- Integration-тесты для Repository через testcontainers. Проверять
PostgresItemRepository.update_normalized_dataна реальной Postgres. - Contract test между этим сервисом и matcher-service (Pact). Сейчас формат
StandardizedItemописан в коде, а как именно matcher его читает — знает только сам matcher. - Fake для NormalizerPort (in-memory реализация) — вместо MagicMock. Тесты станут менее хрупкими.
Дальнейшее чтение
- Vladimir Khorikov. Unit Testing: Principles, Practices, and Patterns. Manning, 2020. Обязательное чтение.
- Kent Beck. Test Driven Development: By Example. Классическая школа TDD.
- Steve Freeman, Nat Pryce. Growing Object-Oriented Software, Guided by Tests. Лондонская школа.
- Gerard Meszaros. xUnit Test Patterns. Классификация двойников.
- Martin Fowler. Mocks Aren’t Stubs. Ясное различение.
- Pact Documentation. Contract testing на практике.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Пирамида тестов — это...
Q2. Разница между Stub и Mock:
Q3. Что не стоит мокать (по Khorikov)?
Q4. Contract testing нужен для...
Q5. Признак хорошего теста: