QA Interview Hub · База знань · Тести
API
Contract testing на практиці
Consumer-driven contracts: де вони в піраміді тестів
У системі з кількома сервісами найдорожчі баги живуть на стику: провайдер перейменував поле, а споживач про це дізнався на staging або в проді. Класичні відповіді на це мають слабкі місця: e2e повільні, нестабільні й вимагають піднятого оточення з усіма залежностями; інтеграційні тести з реальним провайдером ламаються через чужі дані та недоступність сервісу; моки в unit-тестах споживача з часом розходяться з реальним API, і всі тести зелені, поки не впаде прод.
Contract testing закриває цю діру. Ідея consumer-driven contracts (CDC): споживач у власному тесті описує мінімум, який йому потрібен від провайдера (запит і форму відповіді). Цей опис зберігається як контракт, і провайдер у власному CI перевіряє, що реальний код його виконує. Жоден тест не потребує обох сервісів одночасно, тож усе швидко й детерміновано.
Місце в піраміді. Контрактні тести лежать між unit та інтеграційними/e2e: вони перевіряють форму спілкування двох сервісів, а не бізнес-логіку. Вони не замінюють:
- функціональні тести провайдера: контракт каже, що поле
status є рядком, а не те, що замовлення оплачується правильно;
- невелику кількість наскрізних e2e на критичних шляхах: вони перевіряють конфігурацію, мережу, авторизацію й справжні дані;
- тести, у яких споживачів багато й вони невідомі (публічне API): тут краще працюють схема OpenAPI і перевірка змін, що ламають сумісність.
Коли CDC доречний. Команди знають одна одну й живуть в одній компанії (мікросервіси, BFF + фронтенд, мобільний клієнт + бекенд), API змінюється часто, а e2e стали вузьким місцем релізу. Коли ні: один моноліт, одна команда на обидва боки, або споживачі невідомі.
Результат для бізнесу виміряти легко: менше інцидентів через зміни API, незалежні релізи сервісів без «зустрічі для узгодження», і короткий пайплайн, де e2e лишаються як невеликий smoke.
Pact від кінця до кінця: тест споживача, Broker, can-i-deploy
Повний цикл Pact складається з чотирьох кроків.
- Тест споживача. Код клієнта (не UI!) викликає mock-провайдер, який Pact піднімає локально. Тест описує взаємодію (interaction): стан (
given), запит і очікувану відповідь. Після проходу тесту генерується pact-файл (JSON);
- Публікація. CI споживача публікує pact у Pact Broker (або PactFlow) з версією (зазвичай git SHA) і гілкою:
pact-broker publish ./pacts --consumer-app-version $GIT_SHA --branch $BRANCH;
- Верифікація провайдера. Провайдер запускає
Verifier, який бере pact-и з Broker, відтворює кожен запит проти реально запущеного провайдера (зі стабами залежностей) і порівнює відповідь. Результат публікується назад із версією провайдера;
- Рішення про деплой. Перед релізом будь-який бік питає:
pact-broker can-i-deploy --pacticipant web-app --version $GIT_SHA --to-environment production. Broker відповідає за матрицею «версія споживача × версія провайдера, що зараз у цьому оточенні»; після деплою фіксуємо факт: pact-broker record-deployment --environment production.
Provider states. Interaction каже «коли замовлення 42 існує, GET /orders/42 повертає…». Провайдер у верифікації мусить привести систему в цей стан: реалізує stateHandlers, які створюють тестові дані або налаштовують стаби. Стани мають бути незалежними й повторюваними.
Версіонування й гілки. Сучасна модель: pacticipant version = git SHA, гілка передається окремо (--branch), а «що зараз у проді» відстежують оточення (record-deployment, для бібліотек і мобільних застосунків record-release). Старі теги (--tag) застарілі для цієї мети. Верифікація обирає pact-и через consumerVersionSelectors, зазвичай mainBranch, matchingBranch і deployedOrReleased.
Pending pacts і WIP. Новий pact від гілки споживача, який провайдер ще не виконує, не повинен ламати збірку провайдера: з enablePending: true його верифікація не блокує, доки він не пройшов хоча б раз. includeWipPactsSince підтягує такі «робочі» pact-и автоматично.
Webhooks. Щоб не чекати наступної збірки провайдера, Broker має webhook на подію contract_requiring_verification_published: нова або змінена угода запускає верифікацію у CI провайдера, а результат повертається в Broker і в статус PR споживача.
Ключова деталь: без can-i-deploy Pact лише «додаткові тести». Саме перевірка матриці перед деплоєм дає гарантію, що ця комбінація версій перевірена.
Pact JS/TS: приклад, matchers і що не класти в контракт
Приклад тесту споживача на Pact JS (пакет @pact-foundation/pact, API PactV3; для spec v4 є PactV4 з тією самою ідеєю):
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, regex } = MatchersV3;
const provider = new PactV3({ consumer: 'web-app', provider: 'orders-api' });
it('returns an order by id', async () => {
provider
.given('order 42 exists')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: like(42),
status: regex('NEW|PAID|SHIPPED', 'NEW'),
items: eachLike({ sku: like('A-1'), qty: like(1) }),
},
});
await provider.executeTest(async (mockServer) => {
const order = await new OrdersClient(mockServer.url).getOrder(42);
expect(order.items.length).toBeGreaterThan(0);
});
});
Matchers. Приклад значення в контракті не означає «має бути рівно так». like(x) перевіряє тип, eachLike(template) — масив із щонайменше одним елементом такої форми, regex(pattern, example) — рядок за шаблоном, є також integer, datetime, uuid. Без matchers Pact порівнює значення буквально, і провайдер падає на кожній зміні даних.
Тест споживача має виконувати справжній клієнтський код (HTTP-клієнт, парсинг), а не перевіряти сам мок. Ціль: якщо клієнт чекає поле, воно є в контракті; якщо клієнт його не читає, у контракті його немає.
Що НЕ класти в контракт:
- Бізнес-логіку й точні значення. Контракт: «
total — число», а не «total = 150 для цього кошика». Логіку перевіряють функціональні тести провайдера;
- Поля, які споживач не використовує. Зайві поля зв'язують провайдера без користі й заважають його рефакторингу;
- Усі коди помилок «про всяк випадок». Лише ті, які клієнт справді обробляє (404, 409, 422 з тілом помилки);
- UI-деталі. Тест через браузер замість клієнтського шару робить контракт повільним і крихким.
Корисне правило: «пиши контракт, як приймальний тест на потребу споживача», а не як копію відповіді провайдера.
Bi-directional, schema-based і message-контракти; як впровадити в команді
Bi-directional contract testing (функція PactFlow) знімає потребу в provider states та верифікації кожного pact. Провайдер публікує свою OpenAPI-специфікацію разом із результатами власних тестів («специфікація справді відповідає коду»), споживач публікує pact зі своїми очікуваннями (їх можна згенерувати з мок-інструментів на кшталт Mock Service Worker чи Cypress), а PactFlow статично порівнює їх на сумісність. Плюс: низький поріг входу, провайдеру не треба підтримувати стани. Мінус: гарантія слабша. Правильність специфікації залежить від того, чим вона підтверджена, а порівняння не виконує справжній запит.
Provider-driven (schema-based) підхід. Коли споживачів багато або вони невідомі, зручніше спиратися на OpenAPI провайдера: лінтер специфікації, перевірка breaking changes між версіями (наприклад, oasdiff) і генерація клієнтів. Це доповнення, а не конкурент CDC.
Schema-based fuzzing. Інструменти на кшталт Schemathesis читають OpenAPI і генерують сотні запитів (граничні значення, пропущені поля, неправильні типи), перевіряючи, що відповіді відповідають схемі, а сервер не віддає 500: st run openapi.yaml --url http://localhost:8080. Це знаходить розбіжності між специфікацією і реальністю, але не перевіряє, чого саме потребує конкретний споживач.
Асинхронні взаємодії. Для черг і подій (Kafka, SQS, RabbitMQ) Pact має message pacts: контракт описує форму повідомлення. Споживач перевіряє, що його обробник приймає такий payload; продюсер у верифікації доводить, що його код генерує повідомлення такої форми. Транспорт у перевірці не бере участі, тому конфігурацію топіків і брокера перевіряють окремо.
Типові пастки:
- Перевизначений контракт: точні значення, зайві поля, порядок елементів. Провайдер падає без реальної поломки, і команда перестає довіряти тестам;
- Спільні фікстури й залежні provider states: стан «замовлення існує» спирається на дані з іншого тесту, і верифікація нестабільна;
- Верифікація проти не тієї версії: перевіряють
main провайдера, хоча в проді інша версія. Лікують селекторами й can-i-deploy --to-environment;
- Контракт без
can-i-deploy: pact-и є, але ніхто не блокує деплой;
- Верифікація проти реальних залежностей провайдера: це знову інтеграційний тест. Залежності потрібно стабити.
Як впровадити. Почати з одного критичного ребра (наприклад, фронтенд → orders-api), а не з усієї системи. Домовитися про Broker (власний або PactFlow), імена pacticipants і модель версій (SHA + гілки + оточення). Підключити верифікацію до CI провайдера й can-i-deploy перед деплоєм, і лише потім розширювати. Роль QA: допомагати писати «тонкі» контракти, стежити, щоб стани були незалежними, і показувати користь через метрики (зловлені breaking changes, скорочення e2e).