Уровень 7 · Культура Глава 23 10 мин

Тестирование: пирамида и тестовые двойники

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) разделил:

Определение Dummy

Объект, передаваемый только для заполнения параметра. Не используется.

Определение Stub

Возвращает заготовленные ответы на запросы. Никаких проверок.

Определение Spy

Записывает, как его вызывали. Проверки — после теста.

Определение Mock

Заранее объявлены ожидания. Проверка — во время вызова (или в assert_called).

Определение Fake

Полноценная альтернативная реализация. 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.

Vladimir Khorikov Unit Testing, 2020

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Реальный
RepositoryFake (in-memory)
Unit of WorkFake
Event Bus / PublisherFake (capturing publisher)
External API client (HTTP)Mock или контрактный тест
Email/SMS/Push providerMock
DateTime.now()Stub (frozen time)
FilesystemFake (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 · закрепить

Проверьте себя

  1. Q1. Пирамида тестов — это...

  2. Q2. Разница между Stub и Mock:

  3. Q3. Что не стоит мокать (по Khorikov)?

  4. Q4. Contract testing нужен для...

  5. Q5. Признак хорошего теста: