Контрактне тестування і сумісність версій
Зміст
Уяви типову картину: команда сервісу платежів перейменувала в JSON-відповіді поле amount на total, дрібний рефакторинг, тести всередині їхнього сервісу зелені, реліз поїхав. За годину падає кабінет користувача — бо він читав саме amount, а тепер там undefined. Ніхто не зламав нічого «у себе»: зламалася межа між двома сервісами. Класичний спосіб зловити таке — e2e-тест, що піднімає обидва сервіси разом. Але e2e повільний, флакі й вимагає, щоб обидва боки одночасно жили в одному оточенні. А проблему видно вже після того, як хтось задеплоївся.
Контрактне тестування (contract testing) розвʼязує саме цей клас багів: перевіряє сумісність двох сервісів не піднімаючи їх разом, а звіряючи кожен окремо зі спільною угодою про формат обміну. Для сеньйор-AQA це тема про системне мислення: чому інтеграція ламається мовчки, де контрактний тест дешевший за інтеграційний, і що робити, коли API мусить змінитися, а старі клієнти нікуди не поділися.
Контракт як межа між командами
Контракт — це узгоджений формат запиту й відповіді на межі між двома сторонами: споживачем (consumer) — тим, хто викликає API, і постачальником (provider) — тим, хто його надає. Контракт описує, які поля точно будуть у відповіді, яких типів, які статуси можливі, які параметри запиту обовʼязкові. Це інтерфейс у широкому сенсі: поки він тримається, боки можуть змінювати внутрішню реалізацію як завгодно й деплоїтися незалежно.
Проблема в тому, що контракт майже завжди неявний. Він живе в голові розробника, у прикладі відповіді зі Swagger, у пам'яті інтеграційного тесту, написаного пів року тому. Ніхто не оголошував «поле amount — частина публічного контракту», просто хтось на нього поклався. Тому єдина реальна причина зробити контракт явним і виконуваним — щоб зміна, яка його порушує, падала в CI, а не в проді.
Друга сила, що руйнує межу з часом, — schema drift (розсинхрон схеми): поступове розходження між тим, що документація/угода обіцяє, і тим, що API реально віддає. Дрифт накопичується непомітно: додали поле й не оновили специфікацію; змінили формат дати «тимчасово»; сервіс почав під навантаженням повертати null там, де раніше завжди був обʼєкт. Кожна окрема зміна виглядає безпечною, а разом вони перетворюють специфікацію на художній вимисел, якому не можна довіряти. Це та сама «розсинхронізація мока з реальністю», про яку йшлося в главі про мокання залежностей — контрактні тести і є системною страховкою від неї.
Breaking vs non-breaking зміни
Центральне поняття теми: чи ламає конкретна зміна наявних споживачів. Зворотно сумісна (backward-compatible, non-breaking) зміна — та, після якої старий клієнт продовжує працювати без правок. Ламна (breaking) — та, після якої він падає або починає поводитися неправильно.
| Зазвичай НЕ ламає (для толерантного клієнта) | Ламає |
|---|---|
| Додати нове опціональне поле у відповідь | Видалити або перейменувати поле у відповіді |
| Додати новий ендпоінт | Змінити тип поля (string → число, обʼєкт → масив) |
| Додати новий опціональний параметр запиту | Зробити раніше опціональний параметр обовʼязковим |
| Зробити раніше обовʼязкове поле запиту опціональним | Додати новий обовʼязковий параметр/поле запиту |
| Розширити діапазон приймання (стати толерантнішим) | Прибрати підтримуваний ендпоінт чи метод |
| Звузити валідацію (почати відхиляти те, що приймалося) | |
| Змінити значення за замовчуванням чи семантику статус-коду |
Ключ до правого стовпця — чи клієнт на це покладається. Тому «безпечність» зміни визначається не почуттям, а фактичною угодою. І тут вступає в дію принцип надійності (robustness principle, закон Постела): «будь консервативним у тому, що надсилаєш, і ліберальним у тому, що приймаєш». На боці клієнта це патерн толерантного читача (tolerant reader): ігноруй невідомі поля, не падай через зайве, читай лише те, що тобі потрібно. Толерантний клієнт переживає додавання полів; клієнт, який валідує відповідь строго, — ні.
Звідси дві найпідступніші пастки non-breaking змін:
additionalProperties: falseу схемі споживача. Якщо клієнт валідує відповідь JSON-схемою із забороною зайвих полів, то навіть додавання нового поля постачальником стане для нього breaking-зміною. Деталі валідації схемою — у главі про перевірки відповіді.- Нове значення enum. Додати варіант до переліку (наприклад, новий
status: "refunded") — технічно розширення. Але якщо клієнт має вичерпнийswitchпо відомих значеннях або строго валідує enum, нове значення його зламає.
Саме тому «breaking чи ні» — питання не до API окремо, а до пари API + його клієнти. Одна й та сама зміна може бути безпечною для толерантного споживача й фатальною для строгого.
Зворотна сумісність, версіонування і deprecation
Рано чи пізно ламна зміна неминуча. Стратегія тут — не ламати мовчки й не ламати одразу.
Версіонування (versioning). Найпоширеніші підходи для REST (детальніше про формати — у главі REST API та формати даних):
- у шляху:
/v1/orders→/v2/orders— найпомітніше й найпростіше для дебагу; - у заголовку чи типі медіа:
Accept: application/vnd.api.v2+json; - у параметрі запиту:
?version=2— простий, але легко загубити в логах і кеші.
Версіонування дає старим клієнтам залишитися на v1, поки вони мігрують на v2. Ціна — тепер треба підтримувати обидві версії, і кожна потребує тестового покриття.
Expand–contract (parallel change). Дисциплінована альтернатива різкому «зламали й випустили». Зміну розбивають на три кроки: expand — додати нове поле/ендпоінт поряд зі старим, не чіпаючи старе; migrate — перевести всіх споживачів на нове; contract — прибрати старе, коли ним ніхто не користується. У будь-який момент часу жоден живий клієнт не зламаний. Той самий підхід знайомий з міграцій БД.
Deprecation (застарівання). Проміжок між «ми хочемо це прибрати» і «ми це прибрали». Стандартний спосіб просигналити машиночитно — HTTP-заголовки у відповіді:
HTTP/1.1 200 OK
Deprecation: @1688169599
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"
Заголовок Sunset (RFC 8594) називає у форматі HTTP-date дату, після якої ресурс, як очікується, перестане відповідати (RFC прямо каже: це підказка, не гарантія); окремо стандартизований заголовок Deprecation (RFC 9745) позначає сам факт застарівання й несе мітку часу, коли воно настало — або настане: дата може бути й у майбутньому (structured-field-дата — @ та Unix-час). Для AQA це готовий обʼєкт перевірки: чи взагалі приходить сигнал про deprecation, чи не «завмерла» дата Sunset у минулому, і — найважливіше — чи ресурс справді ще працює до заявленої дати, а не вимкнений наперед.
Контрактні vs інтеграційні vs e2e тести
Ці три рівні часто плутають, бо всі вони «про інтеграцію». Різниця — в обсязі (scope): скільки реальних компонентів запущено одночасно.
| Що запущено разом | Що ловить | Ціна/швидкість | |
|---|---|---|---|
| Контрактний | Жоден бік не піднімається разом з іншим; кожен звіряється з контрактом окремо | Розходження формату на межі двох сервісів | Дешево, швидко, стабільно |
| Інтеграційний | Два реальні сусіди, зʼєднані по-справжньому | Що вони справді працюють разом (не лише збігаються формати) | Дорожче, потрібне оточення |
| E2E | Увесь ланцюг: UI → сервіси → БД → зовнішні API | Наскрізний бізнес-сценарій очима користувача | Найдорожче, найповільніше, найфлакіше |
Головна думка: контрактний тест навмисно не піднімає обидва боки. Це його сила (жодного спільного оточення, жодних гонок, запускається у власному пайплайні кожної команди) і його межа (він не доведе, що система працює наскрізно, — лише що формати збігаються). Тому контракти не замінюють e2e, а зменшують потребу в них: більшість «інтеграційних» падінь — це розбіжність формату, і її дешевше зловити контрактом. E2e лишають на кілька критичних наскрізних сценаріїв. Це той самий аргумент, що й у стратегії тестування мікросервісів (розділ «Автоматизація: стратегія»).
Consumer-driven contracts (Pact)
Ключове рішення — хто пише контракт. У підході consumer-driven contracts (CDC) контракт визначає споживач, виходячи з того, що він реально використовує. Не вся поверхня API, а лише ті поля й ендпоінти, на які цей клієнт покладається. Це важливо: постачальнику не треба гарантувати кожне поле кожному — лише те, чого хтось справді потребує.
Канонічний інструмент — Pact. Механіка в двох кроках:
- Бік споживача. Тест клієнта звертається не до реального API, а до вбудованого mock-провайдера від Pact. Кожна описана взаємодія (запит → очікувана відповідь) записується у файл контракту — pact-файл (JSON). Тобто контракт народжується як побічний продукт тестів споживача, а не пишеться руками окремо.
- Бік постачальника. Постачальник бере той самий pact-файл і в режимі верифікації відтворює записані запити проти справжнього сервісу, звіряючи, що реальні відповіді відповідають контракту. Якщо постачальник щось перейменував чи прибрав — верифікація падає в його пайплайні, ще до релізу.
Дві деталі, які відрізняють робочий Pact від наївного «порівняння JSON»:
- Матчери (matchers). Контракт звіряє не точні значення, а форму: тип поля, відповідність регулярному виразу, наявність ключів. Інакше контракт падав би від кожного іншого
idчи мітки часу. Перевіряємо, щоtotal— число, а не що воно дорівнює42. - Pact Broker — сховище контрактів і результатів верифікації. Він дає версіонування контрактів по гілках, вебхуки (нова публікація pact запускає верифікацію постачальника) і команду
can-i-deploy— гейт, що перед деплоєм звіряє матрицю: чи ця версія споживача вже верифікована проти тієї версії постачальника, що зараз стоїть у цільовому оточенні. Самеcan-i-deployперетворює контракти з «ще одних тестів» на реальний запобіжник несумісного релізу.
Межа CDC: він добре працює, коли ти контролюєш обидва боки (внутрішні сервіси однієї компанії) і споживачів скінченна кількість. Для публічного API з тисячами невідомих клієнтів consumer-driven підхід не масштабується — там доречніший провайдер-центричний контракт зі специфікації.
Валідація проти OpenAPI як полегшений контракт
Не завжди варто піднімати всю машинерію Pact. Часто достатньо одностороннього контракту: перевірити, що постачальник не відхиляється від власної OpenAPI-специфікації. Специфікація вже є джерелом істини для формату (див. главу OpenAPI/Swagger) — лишається зробити її виконуваною: у тестах валідувати реальні відповіді проти схем зі специфікації.
Це «полегшений контракт»: не треба координувати споживача й постачальника, не треба брокера. Ти просто ловиш schema drift — момент, коли API почав віддавати не те, що обіцяє його ж специфікація. У коді це звична валідація схемою (наприклад, ajv або zod — деталі в главі про API-автотести в коді):
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
const ajv = new Ajv({ strict: false });
// userSchema витягнуто з components.schemas.User у openapi.json
const validateUser = ajv.compile(userSchema);
test('GET /users/1 відповідає OpenAPI-контракту', async ({ request }) => {
const res = await request.get('/users/1');
expect(res.status()).toBe(200);
const body = await res.json();
const ok = validateUser(body);
expect(ok, JSON.stringify(validateUser.errors)).toBe(true);
});
Свідома компромісність такого підходу:
- Він не знає про споживача. Специфікація підтверджує, що постачальник вірний собі, але не те, що клієнт читає саме ці поля. Прибрати поле, яким хтось користується, специфікація дозволить, якщо його прибрати й зі схеми.
- Строгість — вибір:
additionalProperties: falseу контрактній схемі ловить «таємно дописані» поля (сильний сигнал drift), але робить будь-яке нове поле червоним. Для полегшеного контракту частіше лишають схему толерантною й ловлять лише зникнення/зміну типу обовʼязкових полів.
Проміжний варіант — двобічні контракти (bi-directional contracts): інструмент звіряє контракт споживача (що він використовує) зі специфікацією постачальника (що той обіцяє), не піднімаючи живий сервіс. У GraphQL роль такого машиночитного контракту грає сама схема з інтроспекцією — про це в главі про GraphQL.
Контракти в пайплайні
Контрактні тести дають цінність лише як гейт у CI, а не як разовий локальний запуск. Типова розкладка (сама механіка пайплайнів — у розділі «Git і CI/CD»):
- Пайплайн споживача: прогнати тести клієнта → згенерувати pact → опублікувати його в брокер із тегом гілки/версії.
- Пайплайн постачальника: за вебхуком підтягнути нові pact-файли → відпрацювати верифікацію → опублікувати результат назад у брокер.
- Гейт перед деплоєм:
can-i-deployдивиться в матрицю сумісності й блокує викочування версії, яка ще не верифікована проти того, що зараз у цільовому оточенні.
Для полегшеного OpenAPI-варіанта пайплайн простіший: валідація відповідей проти специфікації — це звичайний тестовий крок, який падає, щойно живий сервіс розійшовся зі своєю схемою. Головний принцип обох варіантів однаковий: несумісність має падати в пайплайні того, хто її вніс, а не в проді того, хто нічого не змінював.
Типові помилки
- Виглядає як «безпечно додав поле», а насправді breaking-зміна. Споживач валідує відповідь із
additionalProperties: falseабо має вичерпний розбір enum — і нове поле/значення його валить. «Non-breaking» визначає клієнт, не постачальник. - Виглядає як контрактний тест, а насправді інтеграційний. Якщо в «контрактному» тесті ти піднімаєш обидва сервіси й ходиш по мережі між ними — це інтеграційний тест з усіма його гонками й потребою в оточенні. Суть контракту — що боки перевіряються окремо.
- Виглядає як «специфікація — контракт», а насправді schema drift. OpenAPI, який ніхто не валідує в тестах, тихо розходиться з реальністю. Незапущена специфікація — документація, а не контракт.
- Виглядає як зелений контракт, а насправді порівняння точних значень. Контракт без матчерів падає від кожного іншого
idчи мітки часу — команда починає його ігнорувати або постійно «підганяти». Контракт має звіряти форму, не конкретику. - Виглядає як «задеплоїли з deprecation», а насправді зламали клієнтів. Ресурс вимкнули раніше за дату
Sunsetабо взагалі без сигналу застарівання. Deprecation без реального вікна міграції — це просто breaking-зміна з ввічливою назвою.
Підсумок
- Контракт — це явна межа між споживачем і постачальником; його цінність зʼявляється лише тоді, коли його порушення падає в CI, а не в проді.
- «Breaking чи ні» визначає пара API + клієнти: додавання поля чи enum-значення може бути безпечним для толерантного читача й фатальним для строгого.
- Контрактний тест не піднімає обидва боки разом — цим він дешевший і стабільніший за інтеграційний та e2e, але доводить лише збіг форматів, не наскрізну роботу системи.
- Consumer-driven (Pact) будує контракт з реального використання клієнта й верифікує його на боці постачальника; валідація проти OpenAPI — полегшений односторонній контракт, що ловить schema drift без координації сторін.
- Ламну зміну не роблять мовчки: версіонування, expand–contract і deprecation (
Sunset) дають старим клієнтам вікно міграції.
Що питають на співбесіді
- «Чим контрактне тестування відрізняється від інтеграційного?» Інтерв'юер перевіряє, чи розумієш ти обсяг: контракт звіряє кожну сторону з угодою окремо, інтеграційний піднімає реальних сусідів разом. Сильна відповідь одразу називає наслідок — контракт не потребує спільного оточення й тому стабільніший і швидший.
- «Наведи breaking і non-breaking зміну API.» Дивляться, чи не назвеш ти «додати поле» безумовно безпечним. Згадай про толерантного читача,
additionalProperties: falseі нове enum-значення — це відрізняє джуна від сеньйора. - «Що таке consumer-driven contract і навіщо він?» Очікують: контракт визначає споживач з того, що реально використовує; постачальник верифікує його у себе; порушення падає в його пайплайні до релізу. Бонус — згадати
can-i-deployяк гейт. - «Як тестувати сумісність, якщо API мусить змінитися?» Перевіряють системне мислення: версіонування, expand–contract, deprecation із
Sunset, вікно міграції. Погана відповідь — «просто оновимо клієнтів разом». - «Контракт vs OpenAPI-валідація — коли що?» Хочуть почути межу: Pact — коли контролюєш обидва боки й споживачів мало; валідація проти специфікації — легкий односторонній контроль drift для публічного чи одностороннього випадку.
Джерела
- ISTQB Foundation Level Syllabus v4.0 — рівні тестування: component integration testing і system integration testing.
- Pact — офіційна документація: docs.pact.io (consumer-driven contracts, matchers, Pact Broker,
can-i-deploy). - OpenAPI Specification — spec.openapis.org (структура схем,
$ref, responses як джерело контракту). - RFC 8594 «The Sunset HTTP Header Field» — rfc-editor.org/rfc/rfc8594 (машиночитний сигнал про дату виведення ресурсу з експлуатації).
- RFC 9745 «The Deprecation HTTP Response Header Field» — rfc-editor.org/rfc/rfc9745 (стандартизований сигнал застарівання; значення — structured-field-дата у Unix-часі).
- Ian Robinson, «Consumer-Driven Contracts» — martinfowler.com (першоджерело поняття CDC).
- Martin Fowler, «TolerantReader» — martinfowler.com/bliki/TolerantReader.html (патерн толерантного читача, закон Постела на боці клієнта).
Що таке контракт між сервісами і навіщо робити його явним?
Контракт — це домовленість про формат обміну на межі двох сторін: споживача (consumer), який викликає API, і постачальника (provider), який його надає. У ній зафіксовано, на які поля й типи у відповіді можна розраховувати, які статуси можливі, які параметри запиту обовʼязкові. Доки домовленість тримається, кожен бік вільний переписувати нутрощі й релізитися незалежно. Біда в тому, що зазвичай вона ніде не записана — розкидана по головах розробників, прикладах зі Swagger і старих інтеграційних тестах. Явним і виконуваним контракт роблять з однієї причини: щоб зміна, яка його порушує, зупинялася в CI автора зміни, а не долітала до проду того, хто нічого не міняв.
Чим контрактне тестування відрізняється від інтеграційного та e2e?
Розрізняє їх обсяг (scope) — кількість реальних компонентів, що працюють одночасно. Контрактний тест свідомо не запускає сервіси разом: кожен бік окремо звіряється зі спільною угодою, тому такий тест дешевий, швидкий і стабільний, але доводить лише збіг форматів. Інтеграційний реально зʼєднує двох сусідів і перевіряє, що вони справді працюють у парі, — за це платиш оточенням. E2e ганяє весь ланцюг (UI, сервіси, БД, зовнішні API) і дивиться на наскрізний бізнес-сценарій очима користувача — це найдорожчий, найповільніший і найбільш флакі рівень. Сильна відповідь одразу називає наслідок: контракту не потрібне спільне оточення, тому він не ловить його гонок; e2e лишають на кілька критичних сценаріїв.
Наведи приклад breaking і non-breaking зміни API.
Non-breaking (зворотно сумісна) — та, після якої старий клієнт працює без правок: додати нове опціональне поле у відповідь, додати новий ендпоінт, додати опціональний параметр запиту, зробити раніше обовʼязкове поле запиту опціональним. Breaking (ламна) — та, після якої клієнт падає або поводиться неправильно: видалити чи перейменувати поле відповіді, змінити тип поля (string на число, обʼєкт на масив), зробити раніше опціональний параметр обовʼязковим, прибрати підтримуваний ендпоінт, звузити валідацію, змінити семантику статус-коду. Критерій «ламна» один: чи покладається на це хтось із живих клієнтів. Слабка відповідь називає «додати поле» безумовно безпечним; сильна одразу згадує, що толерантність клієнта вирішує все.
Чому «додати поле у відповідь» — не завжди безпечна зміна?
Бо вирок «breaking чи ні» виносить не API сам по собі, а пара API плюс його клієнти. Дві найпідступніші пастки. Перша — additionalProperties: false у схемі споживача: клієнт, що забороняє зайві поля своєю JSON-схемою, впаде від будь-якого нового поля, тож для нього «безпечне розширення» — повноцінна breaking-зміна. Друга — нове значення enum: формально це теж розширення (скажімо, зʼявився status: "refunded"), але вичерпний switch по відомих значеннях чи строга валідація enum на невідомому варіанті ламаються. Тому та сама зміна безпечна для толерантного споживача й фатальна для строгого. Практичний висновок для AQA: перш ніж називати зміну безпечною, зʼясуй, як саме клієнт читає й валідує відповідь.
Що таке патерн толерантного читача і закон Постела?
Принцип надійності (robustness principle), він же закон Постела: «будь консервативним у тому, що надсилаєш, і ліберальним у тому, що приймаєш». Його клієнтське втілення — толерантний читач (tolerant reader): бери з відповіді лише потрібні тобі поля, незнайомі пропускай повз, не падай від зайвого. Такий клієнт спокійно переживає розширення API; строго валідуючий — ні. Практичний наслідок: більшість «безпечних» розширень API безпечні тільки для толерантних споживачів, тому, проєктуючи або тестуючи клієнт, варто свідомо не закручувати валідацію відповіді, якщо мета — стійкість до сумісних змін постачальника.
Що таке schema drift і чим він небезпечний?
Schema drift (розсинхрон схеми) — поступове розходження між тим, що обіцяє документація чи угода, і тим, що API віддає насправді. Накопичується він непомітно, дрібними кроками: нове поле поїхало в прод повз специфікацію, формат дати змінили «тимчасово», сервіс під навантаженням почав віддавати null замість обʼєкта. Окремо кожен крок здається невинним, але сумарно специфікація перестає відповідати дійсності — і їй більше не можна довіряти. Небезпека в тому, що незапущена специфікація — це документація, а не контракт: вона тихо бреше, і на неї спираються нові клієнти. Системна страховка проти дрифту — зробити специфікацію виконуваною, тобто валідувати реальні відповіді проти її схем у тестах.
Що таке consumer-driven contract і навіщо він?
Consumer-driven contract (CDC) — підхід, у якому контракт формує споживач, і рівно з того, чим реально користується: не з усієї поверхні API, а з конкретних полів і ендпоінтів, від яких залежить його код. Це знімає з постачальника обовʼязок гарантувати все всім — гарантується лише те, що комусь справді потрібно. Механіка (канонічний інструмент — Pact): тести споживача ходять у mock-провайдер, і з описаних взаємодій народжується pact-файл; постачальник у режимі верифікації програє ці ж запити проти справжнього сервісу. Перейменував чи прибрав щось потрібне споживачу — верифікація червона в його пайплайні ще до релізу. Бонус до відповіді — згадати can-i-deploy як гейт перед деплоєм.
Як механічно працює Pact — крок за кроком?
У два кроки, на двох боках. Бік споживача: тест клієнта ходить не в реальний API, а у вбудований mock-провайдер від Pact; кожна описана взаємодія (запит плюс очікувана відповідь) осідає у pact-файлі (JSON). Тобто контракт — побічний продукт тестів споживача, його не пишуть руками окремо. Бік постачальника: він у режимі верифікації програє записані з pact-файлу запити проти справжнього сервісу й звіряє, чи реальні відповіді вкладаються в контракт. Зазвичай між боками стоїть Pact Broker: споживач публікує туди pact із тегом гілки, вебхук запускає верифікацію постачальника, результат публікується назад. Головне — боки перевіряються окремо, спільне оточення не піднімається.
Що таке матчери в контракті й навіщо вони?
Матчери (matchers) — правила гнучкої звірки: контракт фіксує форму значення (тип, відповідність регулярному виразу, присутність ключа), а не саму величину. Без них контракт червонів би від кожного нового id чи мітки часу, бо живі відповіді щоразу трохи інші. Приклад: асертимо, що total — число, а не що воно дорівнює конкретним 42. Саме матчери відрізняють робочий Pact від наївного порівняння двох JSON. Практичний наслідок: контракт без матчерів — антипатерн; команда швидко починає його ігнорувати або постійно «підганяти» під фактичні дані, і він перестає ловити реальні поломки формату.
Що таке Pact Broker і команда can-i-deploy?
Pact Broker — центральне сховище pact-файлів і звітів верифікації. Він уміє версіонувати контракти по гілках, смикати вебхуки (нова публікація pact запускає верифікацію постачальника) і відповідати на команду can-i-deploy — передрелізний гейт, який дивиться в матрицю сумісності: чи верифікована ця версія споживача проти тієї версії постачальника, що реально стоїть у цільовому оточенні. Саме can-i-deploy перетворює контракти з «ще одних тестів» на справжній запобіжник несумісного релізу: без нього маєш зелені контракти, але не маєш гарантії, що конкретні дві версії сумісні між собою в конкретному оточенні.
Коли consumer-driven підхід не працює і що замість нього?
CDC добре працює, коли ти контролюєш обидва боки (внутрішні сервіси однієї компанії) і споживачів скінченна, відома кількість. Для публічного API з тисячами невідомих клієнтів consumer-driven підхід не масштабується: неможливо зібрати pact-файли від усіх, і незрозуміло, чиї очікування вважати контрактом. Там доречніший провайдер-центричний контракт зі специфікації — валідація проти власної OpenAPI. Тобто вибір інструмента залежить від того, скільки споживачів і чи ти ними керуєш, а не від моди на Pact. Це типове питання «на межу»: сильний кандидат називає саме умову застосовності, а не просто «Pact — це добре».
Що таке валідація проти OpenAPI як полегшений контракт і в чому її межа?
Це односторонній контракт: у тестах звіряти живі відповіді сервісу зі схемами його ж OpenAPI-специфікації. Координувати два боки не треба, брокер не потрібен — специфікація вже існує як джерело істини, лишається змусити її працювати як перевірку. Так ловиться schema drift: момент, коли сервіс розʼїхався з власною обіцянкою. Свідома межа підходу: він нічого не знає про споживача. Такий тест доводить лише, що постачальник вірний собі, — а не що клієнт читає саме ці поля. Якщо поле, на яке хтось спирається, приберуть одночасно з реалізації і зі схеми, валідація лишиться зеленою. Тому це дешевший, але слабший захист, ніж CDC.
Як тестувати сумісність, якщо API все одно мусить змінитися?
Стратегія — не ламати мовчки й не ламати одразу. Три інструменти. Версіонування (versioning): дати старим клієнтам залишитися на v1, поки вони мігрують на v2 (у шляху, у заголовку/типі медіа або в параметрі запиту); ціна — тепер треба підтримувати й тестувати обидві версії. Expand–contract (parallel change): додати нове поряд зі старим (expand), перевести всіх споживачів (migrate), прибрати старе, коли ним ніхто не користується (contract) — жоден живий клієнт не ламається в жодний момент. Deprecation: проміжок між «хочемо прибрати» і «прибрали», просигналений машиночитно заголовками Sunset і Deprecation, із реальним вікном міграції. Погана відповідь — «просто оновимо клієнтів разом»: це і є ламати мовчки й одразу.
Що таке expand–contract (parallel change)?
Дисциплінована альтернатива різкому «зламали й випустили»: ламну зміну розкладають на три кроки. Expand — поряд зі старим полем чи ендпоінтом зʼявляється нове, старе не чіпають; API тимчасово віддає і те, і те. Migrate — усіх споживачів переводять на нове, поки старе ще живе. Contract — старе прибирають, коли підтверджено, що ним уже ніхто не користується. Сенс: старий контракт тримається, доки не піде останній його споживач, тому зламаних клієнтів немає в жодній точці процесу. Той самий прийом знайомий з міграцій БД (додати колонку, переписати читачів, прибрати стару колонку). Для AQA це означає, що кожен із трьох кроків — окрема перевірна точка, а не один великий реліз.
Які заголовки сигналять про deprecation і що з ними перевіряє AQA?
Стандартний машиночитний спосіб — HTTP-заголовки відповіді. Sunset (RFC 8594) — це HTTP-date, після якої ресурс, як очікується, перестане відповідати. Deprecation (RFC 9745) фіксує сам факт застарівання міткою часу у форматі structured-field-дати (@ плюс Unix-час); дата може бути й майбутньою — «застаріє тоді-то». Поруч часто їде Link з rel="deprecation" на гайд міграції. Для AQA тут три перевірки: сигнал узагалі приходить; дата Sunset не «завмерла» в минулому при живому ресурсі; і головне — ресурс справді працює аж до заявленої дати, а не вимкнений наперед. Якщо реального вікна міграції немає — deprecation стає лише ввічливою назвою для ламної зміни.
Чому контрактні тести дають цінність лише як гейт у CI?
Бо контракт ловить несумісність тільки тоді, коли його порушення падає автоматично в пайплайні того, хто зміну вніс, а не при разовому локальному запуску, який легко забути. Типова розкладка: пайплайн споживача проганяє тести клієнта, генерує pact і публікує його в брокер; пайплайн постачальника за вебхуком підтягує нові pact-файли, верифікує їх і публікує результат; гейт перед деплоєм (can-i-deploy) блокує викочування версії, яка ще не верифікована проти того, що зараз у цільовому оточенні. Для полегшеного OpenAPI-варіанта все простіше — валідація відповідей проти специфікації як звичайний тестовий крок. Головний принцип обох: несумісність має падати в пайплайні автора зміни, а не в проді того, хто нічого не міняв.
Що таке двобічні контракти (bi-directional contracts)?
Проміжний варіант між повним Pact і простою валідацією проти OpenAPI. Інструмент звіряє контракт споживача (що він реально використовує) зі специфікацією постачальника (що той обіцяє), не піднімаючи живий сервіс: постачальник публікує свою OpenAPI-специфікацію, споживач — свій набір очікувань, і система перевіряє їх на сумісність статично. Це дешевше за класичну верифікацію (не треба ганяти запити проти реального постачальника), але спирається на те, що специфікація постачальника правдива — тобто сама має бути під валідацією проти реальних відповідей, інакше повертаємось до дрифту. У GraphQL роль такого машиночитного контракту грає сама схема з інтроспекцією.
Три кейси, де контракт вирішує, зелений реліз чи мовчазна поломка інтеграції: таблиця рішень «breaking чи ні» для тієї самої зміни під толерантного й строгого клієнта, полегшений контракт як валідація відповіді проти OpenAPI в Playwright, і перевірка сигналу deprecation (Sunset) як окремого обʼєкта тестування. Скрізь — що дивитися і чому.
Кейс 1. Таблиця рішень: та сама зміна, різний вирок
Команда постачальника прийшла з переліком змін до релізу й питає QA: «що з цього зламає клієнтів?». Пастка в тому, що на половину з них правильна відповідь — «залежить від клієнта». Розберемо кожну зміну двічі: для толерантного читача (ігнорує невідомі поля, читає лише потрібне) і для строгого клієнта (валідує відповідь JSON-схемою з additionalProperties: false, має вичерпний switch по enum).
| Зміна в API | Толерантний клієнт | Строгий клієнт |
|---|---|---|
Додати нове опціональне поле discount у відповідь | Не ламає | Ламає (зайве поле → валідація падає) |
Додати нове значення status: "refunded" | Не ламає | Ламає (немає гілки в switch/enum) |
Перейменувати amount на total | Ламає | Ламає |
Змінити тип id з числа на string | Ламає | Ламає |
Додати новий ендпоінт /refunds | Не ламає | Не ламає |
Зробити опціональний параметр page обовʼязковим | Ламає | Ламає |
Почати повертати null у полі, де раніше завжди був обʼєкт | Ламає (розіменування null) | Ламає |
Що дивитися і чому:
- Перші два рядки — весь сенс теми. «Додати поле» і «додати enum-значення» виглядають як безпечне розширення, але для строгого клієнта це breaking-зміна. Тому питання «non-breaking чи ні» — не до API окремо, а до пари API плюс його клієнти. Перш ніж давати вирок, QA має знати, як клієнт валідує відповідь.
additionalProperties: falseперетворює будь-яке нове поле на червоне. Якщо ви пишете контрактну схему на боці споживача — це свідомий вибір: строгість ловить «таємно дописані» поля (сигнал drift), але робить постачальника заручником вашої схеми. Для полегшеного контракту схему частіше лишають толерантною.- Рядки, що ламають обох, — безумовно breaking. Перейменування, зміна типу, звуження вхідних вимог,
nullзамість обʼєкта не рятує жодна толерантність. Їх не роблять мовчки — тільки через версіонування або expand–contract. - Практичний висновок для тесту. Той самий негативний сценарій «строгий клієнт + нове поле» варто мати в наборі окремо: він документує, що ваша сторона поводиться строго, і чому наступне «безпечне» розширення постачальника впаде саме тут.
Кейс 2. Полегшений контракт: валідація відповіді проти OpenAPI
Не завжди варто піднімати всю машинерію Pact. Часто достатньо одностороннього контракту: перевірити, що постачальник не відхиляється від власної OpenAPI-специфікації. Специфікація вже є джерелом істини — лишається зробити її виконуваною й ловити schema drift прямо в тестах.
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
const ajv = new Ajv({ strict: false });
// userSchema витягнуто з components.schemas.User у openapi.json
const validateUser = ajv.compile(userSchema);
test('GET /users/1 відповідає OpenAPI-контракту', async ({ request }) => {
const res = await request.get('/users/1');
expect(res.status()).toBe(200);
const body = await res.json();
const ok = validateUser(body);
// якщо схема не зійшлася — виводимо самі помилки ajv, а не голе false
expect(ok, JSON.stringify(validateUser.errors)).toBe(true);
});
Строгість схеми — окремий свідомий вибір. Толерантна схема ловить лише зникнення чи зміну типу обовʼязкових полів; строга — ще й «таємно дописані» поля:
// Толерантний варіант: перевіряємо тільки те, на що спираємось.
// required — обовʼязкові поля контракту; зайві поля дозволені.
const tolerantSchema = {
type: 'object',
required: ['id', 'total', 'status'],
properties: {
id: { type: 'string' },
total: { type: 'number' },
status: { type: 'string' }, // enum свідомо не фіксуємо: нове значення статусу не має валити тест
},
// additionalProperties за замовчуванням true — нове поле постачальника не валить тест
};
Що дивитися і чому:
- Ловиться саме drift, а не поломка клієнта. Тест підтверджує, що постачальник вірний власній специфікації. Він НЕ знає, чи клієнт читає саме ці поля: прибрати поле, яким хтось користується, специфікація дозволить, якщо його прибрати й зі схеми. Це дешевший, але слабший захист, ніж consumer-driven contract.
required— серце толерантного контракту. Зникло обовʼязкове поле чи змінився його тип — тест червоний. Зʼявилося нове поле — тест зелений, бо толерантний читач переживе розширення. Саме так контракт не падає від кожного невинного додавання.additionalProperties: falseвмикають свідомо. Для полегшеного контракту частіше лишають толерантність; строгість беруть, коли «таємно дописане поле» — це саме той сигнал drift, який треба ловити.- Це звичайний тестовий крок у CI. Він падає, щойно живий сервіс розійшовся зі своєю схемою, — без брокера, без координації сторін. Головне — щоб несумісність упала в пайплайні того, хто її вніс.
Кейс 3. Deprecation як обʼєкт тестування: сигнал і живий ресурс
Ламну зміну не роблять мовчки. Коли ендпоінт застаріває, постачальник має просигналити машиночитно й лишити реальне вікно міграції. Для QA це готовий обʼєкт перевірки: чи взагалі приходить сигнал, чи не «завмерла» дата Sunset у минулому, і — найважливіше — чи ресурс справді ще працює до заявленої дати, а не вимкнений наперед.
HTTP/1.1 200 OK
Deprecation: @1688169599
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v2>; rel="deprecation"
import { test, expect } from '@playwright/test';
test('застарілий /v1/orders ще живий і чесно сигналить про deprecation', async ({ request }) => {
const res = await request.get('/v1/orders');
// 1) ресурс НЕ вимкнений наперед: deprecation — це ще не видалення
expect(res.status()).toBe(200);
// 2) сигнал застарівання взагалі приходить
const sunset = res.headers()['sunset'];
expect(sunset, 'немає заголовка Sunset — застарівання не просигналене').toBeTruthy();
// 3) дата Sunset не «завмерла» в минулому — вікно міграції ще відкрите
const sunsetDate = new Date(sunset);
expect(sunsetDate.getTime(), 'Sunset у минулому, а ресурс ще віддає 200').toBeGreaterThan(Date.now());
});
Що дивитися і чому:
200тут — не формальність, а суть. Deprecation — це проміжок між «хочемо прибрати» і «прибрали». Якщо ресурс уже віддає404/410до датиSunset— його вимкнули наперед, і це breaking-зміна з ввічливою назвою. Тест саме на це й стоїть на сторожі.Sunsetу минулому — окремий баг. Дата, що вже минула, поки ресурс живий, означає розсинхрон між обіцянкою й реальністю: або забули вимкнути, або забули посунути дату. Обидва варіанти вартують розмови з командою.- Заголовок
Deprecation— про факт,Sunset— про дедлайн.Deprecation(RFC 9745) каже «це застаріло» з міткою часу;Sunset(RFC 8594) — «після цієї дати, як очікується, перестане відповідати». Перевіряти варто обидва: сигнал без дати не дає вікна міграції, дата без сигналу губиться. - Це не UI-, а контрактна перевірка. Вона живе в API-наборі й падає, тільки-но постачальник поводиться з deprecation недисципліновано, — задовго до того, як реальний клієнт отримає
undefinedзамість замовлення.
Контракт як межа між сервісами
- Можу пояснити, що контракт — це узгоджений формат запиту й відповіді на межі споживач (consumer) / постачальник (provider), і поки він тримається, боки деплояться незалежно.
- Розумію, чому контракт майже завжди неявний (живе в голові, у Swagger-прикладі, у старому тесті) і чому його цінність зʼявляється лише тоді, коли порушення падає в CI, а не в проді.
- Знаю, що таке schema drift і чому незапущена специфікація — це документація, а не контракт.
Breaking vs non-breaking
- Знаю типові non-breaking зміни (нове опціональне поле, новий ендпоінт, опціональний параметр, послаблення валідації) і breaking (видалити/перейменувати поле, змінити тип, зробити параметр обовʼязковим, звузити валідацію).
- Розумію головний принцип: «breaking чи ні» визначає пара API + клієнти, а не API окремо.
- Можу пояснити дві пастки «non-breaking»:
additionalProperties: falseу схемі споживача (нове поле валить валідацію) і нове значення enum (ламає вичерпнийswitchчи строгу валідацію). - Знаю патерн толерантного читача (tolerant reader) і закон Постела: консервативний у тому, що надсилаєш, ліберальний у тому, що приймаєш.
Версіонування і deprecation
- Знаю три способи версіонування REST (у шляху
/v1/, у заголовку/типі медіа, у параметрі?version=) і компроміс кожного. - Можу пояснити expand–contract (parallel change): expand → migrate → contract, і чому в кожен момент жоден живий клієнт не зламаний.
- Знаю сигнали deprecation —
Sunset(RFC 8594, дата виведення ресурсу) іDeprecation(RFC 9745, факт застарівання з міткою часу) — і що перевіряє AQA: чи приходить сигнал, чи не «завмерла» датаSunsetу минулому, чи ресурс живий до заявленої дати.
Рівні тестів: контракт / інтеграція / e2e
- Можу пояснити різницю за обсягом (scope): контракт не піднімає боки разом, інтеграційний зʼєднує двох реальних сусідів, e2e ганяє весь ланцюг.
- Розумію силу й межу контракту: дешевий і стабільний без спільного оточення, але доводить лише збіг форматів — тому не замінює e2e, а зменшує потребу в ньому.
Consumer-driven contracts (Pact)
- Розумію ідею CDC: контракт визначає споживач із того, що реально використовує, а не вся поверхня API.
- Знаю механіку Pact у два кроки: споживач пише pact проти mock-провайдера → постачальник верифікує той самий pact проти реального сервісу.
- Можу пояснити, навіщо матчери (звіряють форму — тип, регекс, наявність ключів — а не точні значення на кшталт конкретного
id). - Знаю роль Pact Broker і команди
can-i-deployяк гейта, що звіряє матрицю сумісності версій перед деплоєм. - Розумію межу CDC: працює, коли контролюєш обидва боки й споживачів мало; для публічного API не масштабується.
OpenAPI-валідація і пайплайн
- Можу пояснити валідацію проти OpenAPI як полегшений односторонній контракт, що ловить schema drift без брокера й координації, і її межу: не знає про споживача — підтверджує, що постачальник вірний собі, але не те, що клієнт читає саме ці поля.
- Знаю, що таке двобічні контракти (bi-directional): звірка контракту споживача зі специфікацією постачальника без живого сервісу.
- Розумію головний принцип пайплайну: несумісність має падати в пайплайні того, хто її вніс, а не в проді того, хто нічого не змінював.
Що таке контракт між двома сервісами?
Питання
Що таке контракт між сервісами?