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-deploycheck: «можем ли деплоить эту версию consumer'а — есть ли verified pact с deployed provider?»
Provider pipeline:
- ✓ Verify против всех pact'ов (main branch consumers + deployed)
- ✓ Publish результат в broker
- ✓ OpenAPI breaking change check vs main
- ✓
can-i-deploycheck: «не сломаю ли 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 сломает testequal('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 красный
Тест-сюита для агента
Набор кейсов и автоматическая прогонка с проверкой ожидаемого поведения.
Eval-фреймворк для LLM
Как мерить качество промтов и агентов: test set, метрики, автоматизация.
Регрессионный тест-сет
Каждый баг — новый тест. Дискаверь регрессии до прода.