vyvchy
    Теми розділу

    04 · API-тестування

    Контрактне тестування і сумісність версій

    Зміст

    Уяви типову картину: команда сервісу платежів перейменувала в 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Наскрізний бізнес-сценарій очима користувачаНайдорожче, найповільніше, найфлакіше

    Контрактний — кожна сторона окремо

    звірка

    звірка

    Сервіс A

    Контракт

    Сервіс B

    Інтеграційний — два реальні сусіди

    Сервіс A

    Сервіс B

    E2E — весь ланцюг разом

    UI

    Сервіс A

    Сервіс B

    Зовнішній API

    Контрактний — кожна сторона окремо

    звірка

    звірка

    Сервіс A

    Контракт

    Сервіс B

    Інтеграційний — два реальні сусіди

    Сервіс A

    Сервіс B

    E2E — весь ланцюг разом

    UI

    Сервіс A

    Сервіс B

    Зовнішній API

    Головна думка: контрактний тест навмисно не піднімає обидва боки. Це його сила (жодного спільного оточення, жодних гонок, запускається у власному пайплайні кожної команди) і його межа (він не доведе, що система працює наскрізно, — лише що формати збігаються). Тому контракти не замінюють e2e, а зменшують потребу в них: більшість «інтеграційних» падінь — це розбіжність формату, і її дешевше зловити контрактом. E2e лишають на кілька критичних наскрізних сценаріїв. Це той самий аргумент, що й у стратегії тестування мікросервісів (розділ «Автоматизація: стратегія»).

    Consumer-driven contracts (Pact)

    Ключове рішення — хто пише контракт. У підході consumer-driven contracts (CDC) контракт визначає споживач, виходячи з того, що він реально використовує. Не вся поверхня API, а лише ті поля й ендпоінти, на які цей клієнт покладається. Це важливо: постачальнику не треба гарантувати кожне поле кожному — лише те, чого хтось справді потребує.

    Канонічний інструмент — Pact. Механіка в двох кроках:

    1. Бік споживача. Тест клієнта звертається не до реального API, а до вбудованого mock-провайдера від Pact. Кожна описана взаємодія (запит → очікувана відповідь) записується у файл контракту — pact-файл (JSON). Тобто контракт народжується як побічний продукт тестів споживача, а не пишеться руками окремо.
    2. Бік постачальника. Постачальник бере той самий pact-файл і в режимі верифікації відтворює записані запити проти справжнього сервісу, звіряючи, що реальні відповіді відповідають контракту. Якщо постачальник щось перейменував чи прибрав — верифікація падає в його пайплайні, ще до релізу.
    Постачальник (CI)Pact BrokerСпоживач (CI)Постачальник (CI)Pact BrokerСпоживач (CI)can-i-deploy: матриця сумісності версійТест проти mock-провайдераПублікує pact (версія, гілка)Вебхук: зʼявився новий контрактЗавантажує pactВерифікація: реальні відповіді vs контрактПублікує результат перевіркиПостачальник (CI)Pact BrokerСпоживач (CI)Постачальник (CI)Pact BrokerСпоживач (CI)can-i-deploy: матриця сумісності версійТест проти mock-провайдераПублікує pact (версія, гілка)Вебхук: зʼявився новий контрактЗавантажує pactВерифікація: реальні відповіді vs контрактПублікує результат перевірки

    Дві деталі, які відрізняють робочий 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 (патерн толерантного читача, закон Постела на боці клієнта).