Skip to content
PПромтбук
RUEN
04Архитектура

Contract testing: Pact, OpenAPI, CI-гейты

Consumer-driven contracts, provider/consumer flow, schema check в CI, ловушки моков vs контрактов.

Спроектируй contract testing для {{services}}. Транспорт: {{transport}}.

Базовая аксиома: integration tests с реальными сервисами дороги и медленны; mock'и в unit-тестах врут (сервис изменился — твой mock нет). Contract testing — между ними: consumer проверяет «мой mock соответствует реальному provider'у», provider проверяет «я удовлетворяю контракту consumer'ов».

1. Проблема которую решаем

Сценарий: сервис A вызывает сервис B. У A есть mock B в тестах. B меняет endpoint. CI у A зелёный (mock не знает), CI у B зелёный (он сам себя тестирует). Production падает.

Варианты решения:

  • E2E через docker-compose — медленно, flaky, всё ломается при любом изменении
  • Staging environment — поймает поздно, после merge
  • Contract test — поймает в CI, до merge, за секунды

2. Виды контрактов

ТипКто пишетКогда
Schema-first (OpenAPI, gRPC proto, Avro)Provider публикует, consumer генерит клиентКогда у provider'а много неизвестных consumer'ов (public API)
Consumer-driven (Pact)Consumer описывает что ему нужно, provider проверяетКогда consumer'ы известны (internal services)
Сonsumer-driven via schema (расширения OpenAPI)Consumer пишет sample, validate против schemaКогда уже есть OpenAPI и не хочется второй tool

Для микросервисов между внутренними командами — Pact или его аналог. Для public API — OpenAPI/gRPC schema first.

3. Consumer-driven contracts (Pact-style flow)

1. Consumer пишет test: "Когда вызываю GET /users/123, ожидаю {id, name, email}"
2. Pact mock сервер записывает interaction → pact file (JSON)
3. Consumer тест проходит против Pact mock'а
4. Pact file публикуется в broker (Pactflow / open source)
5. Provider в своём CI: "тяну все pact'ы для меня, проверяю что реально удовлетворяю"
6. Provider verification CI fails → kontrakt сломан → блок мёрджа provider'а

Главное: consumer определяет ожидания. Provider может добавить поле — ок. Удалить — сломает consumer'а.

4. Consumer side

// users-service-consumer.test.ts
const provider = new PactV3({ consumer: 'OrdersService', provider: 'UsersService' });

test('get user by id', async () => {
  provider
    .given('a user with id 123 exists')
    .uponReceiving('a request for user 123')
    .withRequest({ method: 'GET', path: '/users/123' })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: { id: like(123), name: like('Alice'), email: like('a@b.c') },
    });

  await provider.executeTest(async (mockServer) => {
    const client = new UsersClient(mockServer.url);
    const user = await client.getById(123);
    expect(user.email).toContain('@');
  });
});

Заметь:

  • like(...) — matcher: «значение такого типа», не «точно такое». Иначе любая смена id ломает контракт
  • given('a user ... exists') — provider state. Provider в verification настроит БД так чтобы пользователь существовал
  • Test проходит → pact file пишется

5. Provider side

// users-service-verification.test.ts
const opts = {
  provider: 'UsersService',
  providerBaseUrl: 'http://localhost:3000',
  pactBrokerUrl: 'https://broker.company.com',
  consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
  providerVersion: process.env.GIT_SHA,
  publishVerificationResult: process.env.CI === 'true',
  stateHandlers: {
    'a user with id 123 exists': async () => {
      await db.users.insert({ id: 123, name: 'Alice', email: 'a@b.c' });
    },
  },
};

await new Verifier(opts).verifyProvider();

Provider в CI:

  • Стартует свой сервер
  • Тянет pact'ы из брокера для всех consumer'ов
  • Прогоняет каждый interaction против реального сервера, с настройкой state
  • Публикует результат обратно в брокер

6. Provider state — критичный кусок

Pact's «given» — это setup hook на provider стороне. Без него contract test не покажет ничего полезного — provider просто не знает что в БД должен быть user 123.

Реализация:

  • Reset DB перед каждым interaction (или использовать transaction rollback)
  • Map state name → seed function
  • Idempotent — может вызваться несколько раз

Если state handler сложный — это сигнал что contract слишком много знает о implementation. Упрощай.

7. Schema check для REST (OpenAPI)

Параллельно или вместо Pact:

  • Provider держит OpenAPI spec (источник истины или сгенерён из кода)
  • В CI: lint spec (spectral), check breaking changes vs main (oasdiff)
  • Consumer генерит клиент из spec (openapi-typescript, openapi-generator)
  • Response validation в e2e тестах (openapi-backend, express-openapi-validator)
# .github/workflows/api-contract.yml
- run: spectral lint openapi.yaml
- run: oasdiff breaking main:openapi.yaml HEAD:openapi.yaml
- run: prism mock openapi.yaml & npm test  # consumer tests against generated mock

Преимущества OpenAPI:

  • Один источник истины для всех consumer'ов
  • Auto-doc, code-gen, mock-server из коробки
  • Schema lint ловит UX-косяки (имена, типы)

Минусы vs Pact:

  • Не показывает «что именно consumer X использует» — спека описывает всё, breaking change может быть безопасен (никто не использует это поле)
  • Setup state не входит в формат

Часто используют вместе: OpenAPI для shape API, Pact для конкретных consumer-driven сценариев.

8. CI gates

Минимум:

Consumer pipeline:

  • ✓ Unit tests с Pact mock
  • ✓ Publish pact file в broker с tag = branch name
  • can-i-deploy check: «можем ли деплоить эту версию consumer'а — есть ли verified pact с deployed provider?»

Provider pipeline:

  • ✓ Verify против всех pact'ов (main branch consumers + deployed)
  • ✓ Publish результат в broker
  • ✓ OpenAPI breaking change check vs main
  • can-i-deploy check: «не сломаю ли deployed consumer'ов?»

can-i-deploy — главная защита от deadlock'а. Без неё легко задеплоить provider который сломает consumer'а который ещё не задеплоен.

9. События / async контракты

Для event-driven:

  • Schema events в registry (Confluent, Apicurio) — описано в схожем гайде
  • Compatibility check на publish
  • Pact-message (отдельная фича Pact) для контракта на сообщения: consumer описывает «я ожидаю такие события»

Принцип тот же: producer не должен молча менять схему.

10. Ловушки

Mock'и vs контракты

  • Если у тебя в unit-тестах consumer'а захардкоженый mock B — это просто фантазия. Mock прошёл — это ничего не говорит о реальности
  • Pact заменяет mock на mock, который верифицирован на provider стороне
  • Не пиши параллельно: «у меня и Pact, и hand-written mock в других тестах» — рассинхрон гарантирован. Mock = Pact-mock везде

Contract test ≠ functional test

  • Contract test проверяет что формат запроса/ответа совпадает
  • Не проверяет бизнес-логику provider'а — это задача provider'а
  • Consumer пишет «я отправлю это, ожидаю такой формат». Не «provider должен правильно посчитать сумму»

Если в pact'е появляется assertion «total == 100» с конкретным значением — это уже functional test, замаскированный под contract. Перенеси в provider unit tests.

Слишком жёсткие matcher'ы

  • equal(123) вместо like(123) — любой ID сломает test
  • equal('Alice') — provider не может вернуть Bob
  • Используй like, integer, uuid, iso8601DateTime — проверяй тип, не значение

Исключения: enum'ы, статус-коды, поля которые consumer интерпретирует по значению (status: 'active').

Слишком много interaction'ов

  • Pact для каждого варианта response'а → 50 interaction'ов → медленный verification, broker замусорен
  • Группируй: один happy path + ключевые error cases. Edge cases — в unit tests provider'а

Provider state как костыль

  • Если для одного interaction нужно 5 seed-операций — interaction слишком сложный. Разбей или упрости fixture
  • State name'ы должны быть domain-level («user exists»), не implementation («row in users table with x, y»)

Breaking change в формате

  • Удалить поле — breaking даже если consumer'ам «не нужно». Они могли начать использовать
  • Безопасный путь: добавить новое поле / endpoint → переключить consumer'ов → удалить старое. Минимум 2 deploy'я

11. Чек-лист внедрения

  • Выбран tool: Pact / OpenAPI / оба
  • Broker поднят (Pactflow / self-hosted / артефакт в S3 для простых случаев)
  • Consumer: pact-тесты в CI, publish с тэгом branch
  • Provider: verification в CI, publish результата
  • can-i-deploy в deploy pipeline (consumer и provider)
  • Provider state handlers покрывают все used states
  • OpenAPI lint + breaking diff в CI (если REST)
  • Команда знает что pact pinning замораживает contract — нельзя «случайно» удалить поле
  • Документация: как добавить consumer / interaction, как читать verification failure

Анти-паттерны

  • ❌ Pact с захардкоженными значениями вместо matcher'ов — каждое изменение БД ломает CI
  • ❌ Provider verification «локально проходит» но не в CI — отсутствует state setup на CI
  • ❌ Hand-written mock параллельно с Pact для тех же endpoint'ов — рассинхрон через неделю
  • ❌ Contract test проверяет бизнес-логику provider'а (значения, инварианты) — не его работа
  • ❌ Без can-i-deploy — задеплоил provider с breaking change → все consumer'ы лежат
  • ❌ Удалить поле «никто не использует» без deprecation period — кто-то использует
  • ❌ OpenAPI как документация которую никто не валидирует — расходится с реальностью за месяц
  • ❌ 200 interaction'ов в одном pact'е — verification 30 минут, никто не смотрит результат
  • ❌ Provider state «database has these 50 rows» вместо domain-level — связали contract с persistence
  • ❌ Игнорировать verification failure «потом починим» — broker становится мусором, доверия нет

В конце

  • Choice of tool (Pact, OpenAPI, both) с обоснованием
  • Broker + где хранятся pact'ы / specs
  • Consumer flow: что тестируется, какие matcher'ы
  • Provider flow: state handlers, verification CI
  • can-i-deploy встроен в deploy
  • Schema breaking change policy (deprecation period, версионирование)
  • Owner'ы: кто чинит когда verification красный
К подразделу «Архитектура»
Похожие промты