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 складається з чотирьох кроків.

  1. Тест споживача. Код клієнта (не UI!) викликає mock-провайдер, який Pact піднімає локально. Тест описує взаємодію (interaction): стан (given), запит і очікувану відповідь. Після проходу тесту генерується pact-файл (JSON);
  2. Публікація. CI споживача публікує pact у Pact Broker (або PactFlow) з версією (зазвичай git SHA) і гілкою: pact-broker publish ./pacts --consumer-app-version $GIT_SHA --branch $BRANCH;
  3. Верифікація провайдера. Провайдер запускає Verifier, який бере pact-и з Broker, відтворює кожен запит проти реально запущеного провайдера (зі стабами залежностей) і порівнює відповідь. Результат публікується назад із версією провайдера;
  4. Рішення про деплой. Перед релізом будь-який бік питає: 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).