open atlas
↑ К треку
Архитектурные паттерны ARCH · 04 · 03

Тестирование на границе

Hexagonal architecture делает domain тривиально тестируемым: вызови через primary port с тест-драйвером, подставь in-memory fake на каждый secondary port — и весь domain работает без БД, HTTP или платёжного SDK. Это и есть пирамида тестов в hexagonal-командах.

ARCH Middle ◷ 24 min
Уровень
ОсновыJuniorMiddleSenior

До hexagonal architecture тестовый набор OrderService B2B-платформы страдал проблемой, с которой команда научилась мириться: каждый тест, касающийся подтверждения заказа, требовал работающего PostgreSQL-инстанса, активного тестового аккаунта Stripe и настроенного SendGrid-sandbox. CI-пайплайн поднимал Docker Compose-стек при каждом пуше. Тесты выполнялись четыре минуты. Нестабильность была постоянной: sandbox Stripe периодически возвращал ошибки rate-limit; контейнер с базой данных иногда не успевал инициализироваться; у sandbox SendGrid был суточный лимит писем, который тестовый набор достигал к середине дня. Инженеры запускали полный набор локально примерно раз в неделю. Покрытие номинально составляло 72%, но большая его часть была интеграционной поверхностью, а не логической. Когда изменилась валидация суммы заказа — чисто доменное правило без инфраструктурной зависимости — для его тестирования всё равно требовалась полная интеграционная среда, потому что логика валидации находилась внутри OrderService, который импортировал PostgresOrderRepository, StripeClient и SendGridClient. Нетестируемость была не проблемой тестирования. Это была архитектурная проблема: логика domain была запутана с инфраструктурными зависимостями. Hexagonal architecture решает это структурно — не делая мокирование проще, а делая инфраструктурные зависимости структурно отсутствующими в domain.

Почему старая архитектура была нетестируемой

Нетестируемость из вступительной истории была структурной. OrderService имел compile-time зависимости от PostgresOrderRepository, StripeClient и SendGridClient. Чтобы создать OrderService в тесте, нужно было либо предоставить реальную инфраструктуру, либо замокировать эти классы с помощью мок-фреймворка. Оба варианта несут издержки:

Реальная инфраструктура в тестах требует настройки окружения (Docker Compose, внешние аккаунты, доступ в сеть), медленная (секунды на тест, минуты на набор) и нестабильная (внешние сервисы падают, контейнеры таймаутят, rate limits срабатывают).

Мок-фреймворки требуют от теста знания внутренних деталей реализации OrderService — какие методы он вызывает у каких зависимостей, в каком порядке, с какими аргументами. Когда OrderService рефакторится (например, извлекается доменный метод), моки ломаются, даже если поведение не изменилось. Тесты становятся хрупким бременем сопровождения вместо надёжных страховочных сеток.

Обе проблемы разделяют одну первопричину: OrderService имеет прямые source-code зависимости от инфраструктурных классов. Тест не может изолировать доменную логику от этих зависимостей без предоставления их или замены структурно идентичными копиями (моками).

Hexagonal architecture устраняет первопричину. Domain не имеет source-code зависимостей от инфраструктурных классов — он зависит только от собственных интерфейсов портов. Случайно создать экземпляр PostgresOrderRepository внутри domain невозможно, потому что domain не импортирует его. Единственный способ удовлетворить secondary port — предоставить реализацию, которой может быть быстрый in-memory fake.

Вызов через primary port с тест-драйвером

Тест-драйвер является driving adapter (урок 02). Он вызывает primary port напрямую, как это делает HTTP-контроллер. HTTP не задействован — ни сервер, ни парсинг запросов, ни форматирование ответов.

// domain/ports/OrderConfirmationPort.ts
interface OrderConfirmationPort {
  confirmOrder(orderId: string, approverId: string): Promise<ConfirmationResult>;
}

// Тест: вызов через primary port
describe('Order confirmation use case', () => {
  let useCase: OrderConfirmationPort;
  let orderRepo: InMemoryOrderRepository;
  let paymentGateway: FakePaymentGateway;
  let emailNotifier: FakeEmailNotifier;

  beforeEach(() => {
    orderRepo = new InMemoryOrderRepository();
    paymentGateway = new FakePaymentGateway();
    emailNotifier = new FakeEmailNotifier();

    // Собираем use case с fake driven adapters
    useCase = new OrderConfirmationUseCase(orderRepo, paymentGateway, emailNotifier);
  });

  it('подтверждает pending-заказ и снимает оплату с клиента', async () => {
    // Arrange: помещаем pending-заказ в fake-репозиторий
    const order = Order.pending({ id: 'order-1', customerId: 'cust-42', amount: Money.of(500, 'USD') });
    await orderRepo.save(order);

    // Act: вызываем через primary port
    const result = await useCase.confirmOrder('order-1', 'approver-99');

    // Assert: доменный результат
    expect(result.success).toBe(true);
    expect(paymentGateway.chargesIssued).toHaveLength(1);
    expect(paymentGateway.chargesIssued[0].amount).toEqual(Money.of(500, 'USD'));
    expect(emailNotifier.sentMessages).toHaveLength(1);
  });

  it('отклоняет подтверждение при наличии просроченных счетов', async () => {
    const order = Order.pending({ id: 'order-2', customerId: 'cust-42', amount: Money.of(200, 'USD') });
    order.markInvoiceOverdue('inv-7');
    await orderRepo.save(order);

    const result = await useCase.confirmOrder('order-2', 'approver-99');

    expect(result.success).toBe(false);
    expect(result.reason).toBe('overdue-invoice');
    expect(paymentGateway.chargesIssued).toHaveLength(0);
  });
});

Никакого Docker Compose. Никакого Stripe-аккаунта. Никакого SendGrid-sandbox. Каждый тест выполняется за миллисекунды. Тестовый набор может содержать сотни тестов и завершаться менее чем за две секунды.

In-memory fakes на secondary ports

Ключевой элемент — in-memory fake: простая, быстрая реализация secondary port, хранящая состояние в памяти. Это не mock (который записывает ожидания вызовов); это реальная реализация, просто использующая память вместо сети или диска.

// Реализует secondary port: IOrderRepository
class InMemoryOrderRepository implements IOrderRepository {
  private store = new Map<string, Order>();

  async findById(id: string): Promise<Order | null> {
    return this.store.get(id) ?? null;
  }

  async save(order: Order): Promise<void> {
    this.store.set(order.id, order);
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    return [...this.store.values()].filter(o => o.customerId === customerId);
  }
}

// Реализует secondary port: IPaymentGateway
class FakePaymentGateway implements IPaymentGateway {
  public chargesIssued: Array<{ customerId: string; amount: Money }> = [];
  public shouldFail = false;

  async charge(customerId: CustomerId, amount: Money): Promise<ChargeResult> {
    if (this.shouldFail) return ChargeResult.failure('card-declined');
    this.chargesIssued.push({ customerId: customerId.value, amount });
    return ChargeResult.success(`charge-${Date.now()}`);
  }

  async refund(chargeId: ChargeId, amount: Money): Promise<RefundResult> {
    return RefundResult.success();
  }
}

Fakes реализуют интерфейс secondary port в точности — тот же интерфейс, что реализуют production-адаптеры Postgres и Stripe. Domain use case не может отличить их во время компиляции. В runtime тест подставляет fake; production подключает реальный адаптер. Один и тот же use case выполняется в обоих контекстах без модификации.

Почему это работает

Почему это структурное свойство, а не техника тестирования? Потому что тестируемость возникает из структуры зависимостей, а не из дисциплины настройки тестов. Команда, использующая слоистую архитектуру, тоже может писать in-memory fakes — но ей нужно мокировать конкретные классы, которые импортирует её сервис, что означает зависимость настройки теста от деталей реализации. При рефакторинге сервиса моки ломаются. В hexagonal architecture domain импортирует только собственные интерфейсы. In-memory fake — это легитимная реализация этого интерфейса, а не мок конкретного класса. Рефакторинг use case не ломает fakes (fakes реализуют порт, который меняется только при изменении требований domain). Тестируемость встроена по построению.

Пирамида тестов, которую используют hexagonal-команды

Структурное разделение domain от адаптеров создаёт естественную трёхуровневую пирамиду тестов:

Уровень 1 — Domain-тесты (много, миллисекунды): вызов через primary port с тест-драйвером и fake driven adapters. Тестирует каждое бизнес-правило, каждый инвариант, каждый граничный случай в domain. Эти тесты не имеют инфраструктурных зависимостей — они выполняются везде, мгновенно. Это основная часть тестового набора: каждая комбинация состояния заказа, логики согласования, правила биллинга и условия ошибки. На B2B-платформе это сотни тестов, покрывающих полную логику подтверждения, выставления счетов и согласования биллинга.

Уровень 2 — Тесты адаптеров (меньше, секунды): тестирование каждого адаптера изолированно против контракта его порта. Тест PostgreSQL-репозитория запускает реальную базу данных, сохраняет и извлекает доменные объекты, проверяет правильность маппинга. Тест Stripe-адаптера (против тестового API Stripe) проверяет маппинг charge/refund. Тест HTTP-контроллера проверяет, что поля HTTP-запроса корректно парсятся в параметры порта и результаты порта корректно сериализуются в HTTP-ответы. Эти тесты проверяют, что адаптер корректно реализует порт — они не пере-тестируют доменную логику.

Уровень 3 — End-to-end тесты (очень мало, секунды-минуты): запуск полного приложения со всеми реальными адаптерами. Отправка реальных HTTP-запросов. Проверка интеграции всего стека. Эти тесты дороги — оставляй их только для критических happy path.

Уровень 1: Domain-тесты    ████████████████████████████████████  (сотни)
Уровень 2: Тесты адаптеров ██████████                            (десятки)
Уровень 3: E2E-тесты       ██                                    (единицы)

Исходная проблема B2B-платформы — 72% номинального покрытия через медленные интеграционные тесты — была симптомом полного отсутствия тестов Уровня 1. Всё было Уровнем 3. Hexagonal architecture инвертирует это: тесты Уровня 1 дёшевы в написании, потому что domain полностью доступен через primary port без инфраструктуры.

lesson.inset.note

Существует нюансированный антипаттерн «ice cream cone»: много E2E-тестов, мало тестов адаптеров, почти никаких domain-тестов. Команды в слоистых архитектурах естественно дрейфуют к нему, потому что unit-тестирование domain требует мокирования многих конкретных инфраструктурных зависимостей — что утомительно и хрупко. Hexagonal architecture делает domain-тесты дёшевыми в написании (никакого мок-фреймворка, просто fakes, реализующие порты), поэтому пирамида инвертируется естественно. Если тестовый набор hexagonal-команды всё ещё выглядит как «ice cream cone», это обычно означает, что secondary ports не были корректно инвертированы — domain где-то всё ещё импортирует конкретные инфраструктурные классы.

Тесты адаптеров: тестирование границы снаружи

Тесты адаптеров структурно независимы от domain-тестов. Они тестируют другой вопрос: «Правильно ли этот адаптер реализует контракт порта?»

// Тест адаптера: PostgresOrderRepository реализует IOrderRepository
describe('PostgresOrderRepository', () => {
  let db: Pool; // Реальное PostgreSQL-соединение
  let repo: PostgresOrderRepository;

  beforeAll(async () => {
    db = await createTestDatabase();
    repo = new PostgresOrderRepository(db);
  });

  it('сохраняет и извлекает заказ по id', async () => {
    const order = Order.pending({ id: 'test-1', customerId: 'c-1', amount: Money.of(100, 'USD') });
    await repo.save(order);
    const retrieved = await repo.findById('test-1');
    expect(retrieved).toEqual(order);
  });

  it('возвращает null для несуществующего заказа', async () => {
    const result = await repo.findById('no-such-id');
    expect(result).toBeNull();
  });
});

Эти тесты запускают реальную базу данных. Они медленнее domain-тестов. Но покрывают одно, чего domain-тесты не могут: корректный маппинг между доменными объектами и строками базы данных (имена столбцов, типы, обработка NULL, сериализация). Domain-тест с fake-репозиторием никогда не обнаружит баг маппинга столбца. Тест адаптера поймает его точно, не тестируя при этом доменную логику.

Викторина

Команда конвертирует слоистую архитектуру в hexagonal. OrderConfirmationUseCase теперь получает IOrderRepository и IPaymentGateway через конструктор (подключаются при запуске). Разработчик говорит: «У нас уже был DI раньше — мы могли мокировать OrderRepository и StripeClient в тестах. Что нам даёт hexagonal, чего у нас не было?» Как ответить?

Викторина

Команда пишет domain-тест с InMemoryOrderRepository и FakePaymentGateway. Тест подтверждения заказа проходит. Позже Postgres-адаптер деплоится в production и заказ падает: столбец amount хранит значения как целые числа (центы), но domain использует Money value object, хранящийся как десятичная строка. Какой уровень тестов обнаружил бы этот баг, и почему domain-тест его не поймал?

Викторина

У команды 400 domain-тестов (Уровень 1), 30 тестов адаптеров (Уровень 2) и 5 E2E-тестов (Уровень 3). CI занимает 45 секунд. Новый разработчик говорит: «Нужно добавить больше E2E-тестов для уверенности — они тестируют реальную систему». Как оценить этот аргумент?

Вспомните перед уходом
  1. 01
    Какова структурная причина того, что domain-тесты в hexagonal architecture не требуют мок-фреймворка?
  2. 02
    В чём разница между in-memory fake и mock-объектом, и почему это различие важно для сопровождения тестов?
  3. 03
    У команды B2B-платформы 400 domain-тестов с fakes. Обнаружен production-баг: заказы на суммы свыше $10 000 в определённых валютах молча не проходят списание. FakePaymentGateway этого не выявил. Что это говорит о fake и как команде реагировать?
Итог

Нетестируемость исходной B2B-платформы была не проблемой написания тестов. Это была структурная проблема: сервисный слой имел compile-time зависимости от инфраструктурных классов, поэтому каждый тест бизнес-логики требовал полного инфраструктурного стека.

Hexagonal architecture устраняет compile-time зависимости. Domain импортирует только собственные интерфейсы портов. Secondary ports удовлетворяются в runtime тем адаптером, который подставлен — production или fake. Domain не может случайно импортировать конкретный инфраструктурный класс.

In-memory fakes реализуют интерфейсы secondary ports полностью и корректно, без использования какой-либо внешней инфраструктуры. Domain use case выполняется идентично против fakes и production-адаптеров, потому что видит только интерфейсы портов, которыми владеет.

Пирамида тестов, которую это создаёт, имеет три уровня. Domain-тесты (много, миллисекунды) вызывают через primary port с fake driven adapters — они тестируют каждое бизнес-правило без инфраструктуры. Тесты адаптеров (меньше, секунды) тестируют каждый адаптер с реальной инфраструктурой — они проверяют маппинг между доменными объектами и строками БД, API-ответами или форматами сообщений. E2E-тесты (очень мало) проверяют проводку интеграции.

Антипаттерн «ice cream cone» — много E2E, почти никаких domain-тестов — симптом недостаточного владения портами. Когда domain-тесты становятся дёшевыми по построению, пирамида инвертируется естественно.

Эта структурная тестируемость — практическая выгода, делающая hexagonal architecture стоящей своей стоимости. Границы, предотвращающие layer-skipping (проблема unit 03) и владение портом, инвертирующее инфраструктурную зависимость (DIP из unit 02), также делают весь domain тестируемым без базы данных, без платёжного SDK и без работающего сервера.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.