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

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

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

    Зміст

    Уяви типову картину: команда сервісу платежів перейменувала в JSON-відповіді поле amount на total, дрібний рефакторинг, тести всередині їхнього сервісу зелені, реліз поїхав. За годину падає кабінет користувача — бо він читав саме amount, а тепер там undefined. Ніхто не зламав нічого «у себе»: зламалася межа між двома сервісами. Класичний спосіб зловити таке — e2e-тест, що піднімає обидва сервіси разом. Але e2e повільний, й вимагає, щоб обидва боки одночасно жили в одному оточенні. А проблему видно вже після того, як хтось задеплоївся.

    (contract testing) розвʼязує саме цей клас багів: перевіряє сумісність двох сервісів не піднімаючи їх разом, а звіряючи кожен окремо зі спільною угодою про формат обміну. Для сеньйор-AQA це тема про системне мислення: чому інтеграція ламається мовчки, де контрактний тест дешевший за інтеграційний, і що робити, коли API мусить змінитися, а старі клієнти нікуди не поділися.

    Контракт як межа між командами

    Контракт — це узгоджені зобовʼязання на межі між двома сторонами: (consumer) — тим, хто , і постачальником (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-зміною. Деталі валідації схемою — у главі про перевірки відповіді.
    • Нове значення . Додати варіант до переліку (наприклад, новий 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-заголовки у відповіді (статус документів різний: Deprecation описаний у RFC 9745 категорії Standards Track, а Sunset — у RFC 8594 категорії Informational, який сам зазначає, що не є специфікацією Standards Track):

    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 у минулому. А от «ресурс мусить працювати до заявленої дати» інваріантом не є: RFC знімає гарантію в обидва боки — не гарантовано ані що ресурс буде доступний до цього моменту, ані що після нього він зникне. Оголошену дату не можна вважати остаточним передбаченням того, що станеться; єдина остаточна перевірка доступності — спробувати звернутися до ресурсу.

    Контрактні 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) — лишається зробити її виконуваною: у тестах валідувати реальні відповіді проти схем зі специфікації.

    Це «полегшений контракт»: не треба координувати споживача й постачальника, не треба брокера. Ти просто ловиш дрейф схеми — момент, коли 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» визначає клієнт, не постачальник.
    • Виглядає як контрактний тест, а насправді інтеграційний. Якщо в «контрактному» тесті ти піднімаєш обидва сервіси й ходиш по мережі між ними — це інтеграційний тест з усіма його гонками й потребою в оточенні. Суть контракту — що боки перевіряються окремо.
    • Виглядає як «специфікація — контракт», а насправді дрейф схеми. OpenAPI, який ніхто не валідує в тестах, тихо розходиться з реальністю. Незапущена специфікація — документація, а не контракт.
    • Виглядає як зелений контракт, а насправді порівняння точних значень. Контракт без матчерів падає від кожного іншого id чи мітки часу — команда починає його ігнорувати або постійно «підганяти». Контракт має звіряти форму, не конкретику.
    • Виглядає як «задеплоїли з deprecation», а насправді зламали клієнтів. Сигналу застарівання не було зовсім, або вікна міграції не дали. Саме по собі вимкнення раніше за дату Sunset порушенням RFC не є — заголовок дає підказку, а не гарантію; але deprecation без реального вікна міграції — це просто breaking-зміна з ввічливою назвою.

    Підсумок

    • Контракт — це явна межа між споживачем і постачальником; його цінність зʼявляється лише тоді, коли його порушення падає в CI, а не в проді.
    • «Breaking чи ні» визначає пара API + клієнти: додавання поля чи enum-значення може бути безпечним для толерантного читача й фатальним для строгого.
    • Контрактний тест не піднімає обидва боки разом — цим він дешевший і стабільніший за інтеграційний та e2e, але доводить лише збіг форматів, не наскрізну роботу системи.
    • Consumer-driven contracts будують контракт з реального використання клієнта й верифікують його на боці постачальника (Pact — одна з реалізацій патерну, а не сам патерн); валідація проти OpenAPI — полегшений односторонній контракт, що ловить дрейф схеми без координації сторін.
    • Ламну зміну не роблять мовчки: версіонування, expand–contract і deprecation (Sunset) дають старим клієнтам вікно міграції.

    Можливі питання

    • «Чим контрактне тестування відрізняється від інтеграційного?» Інтерв'юер перевіряє, чи розумієш ти обсяг: контракт звіряє кожну сторону з угодою окремо, інтеграційний піднімає реальних сусідів разом. Сильна відповідь одразу називає наслідок — контракт не потребує спільного оточення й тому стабільніший і швидший.
    • «Наведи breaking і non-breaking зміну API.» Дивляться, чи не назвеш ти «додати поле» безумовно безпечним. Згадай про толерантного читача, additionalProperties: false і нове enum-значення — це відрізняє джуна від сеньйора.
    • «Що таке consumer-driven contract і навіщо він?» Очікують: контракт визначає споживач з того, що реально використовує; постачальник верифікує його у себе; порушення падає в його пайплайні до релізу. Бонус — згадати can-i-deploy як гейт.
    • «Як тестувати сумісність, якщо API мусить змінитися?» Перевіряють системне мислення: версіонування, expand–contract, deprecation із Sunset, вікно міграції. Погана відповідь — «просто оновимо клієнтів разом».
    • «Контракт vs OpenAPI-валідація — коли що?» Хочуть почути межу: CDC (напр. через Pact) — коли контролюєш обидва боки й споживачів мало; валідація проти специфікації — легкий односторонній контроль дрейфу для публічного чи одностороннього випадку.

    Джерела

    Контракт як межа між командами

    • Ian Robinson — Consumer-Driven Contracts: A Service Evolution Pattern — контракт постачальника — не лише схеми документів: він охоплює інтерфейси, «розмови», політики й характеристики якості; він закритий, повний і авторитетний, а контракт споживача — відкритий і неповний.
    • Pact — офіційна документація (Introduction) — контрактне тестування перевіряє точку інтеграції, звіряючи кожен застосунок в ізоляції зі спільним розумінням, задокументованим у контракті.
    • Prism (Stoplight) — README репозиторію — інструментальний бік дрейфу: валідаційний шукає розбіжності між реалізацією й документом OpenAPI, що її описує.

    Breaking vs non-breaking зміни

    • Martin Fowler — Tolerant Reader (bliki) — закон Постела на боці клієнта: брати лише потрібні елементи й мінімізувати припущення; додавання поля не має бути ламною зміною — але строгі схеми воно ламає.
    • JSON Schema Core, draft 2020-12 — технічна причина: заборона зайвих полів вмикається явно, і саме вона робить нове поле фейлом.

    Зворотна сумісність, версіонування і deprecation

    • Danilo Sato — ParallelChange (expand-contract) — expand-contract — спосіб внести зворотно несумісну зміну безпечно, розбивши її на три фази: expand, migrate, contract.
    • RFC 9745 — The Deprecation HTTP Response Header FieldDeprecation каже, що ресурс уже або буде оголошений застарілим; значення — Structured-Fields Date, тож дата може стояти й у майбутньому, а сам заголовок є лише підказкою.
    • RFC 8594 — The Sunset HTTP Header FieldSunset називає момент, коли ресурс, як очікується, перестане відповідати; мітка МАЄ бути в майбутньому, а модальність — «hints: it is not guaranteed» в обидва боки.
    • OWASP API Security Top 10 — 2023API9:2023 називає застарілі версії API окремим інвентаризації.

    Контрактні vs інтеграційні vs e2e тести

    Consumer-driven contracts (Pact)

    • Ian Robinson — Consumer-Driven Contracts: A Service Evolution Pattern — патерн дає постачальнику видимість зобовʼязань перед споживачами й не задає ані формату контракту, ані способу передати очікування.
    • Pact — офіційна документація (Introduction) — Pact — code-first реалізація: контракт генерується під час прогону тестів споживача, і перевіряються лише ті частини комунікації, якими він справді користується.
    • Pact Broker — Can I Deploycan-i-deploy звіряє конкретну версію з конкретним середовищем за матрицею перевірених пар — це блокувальний крок пайплайна, а не звіт.

    Валідація проти OpenAPI як полегшений контракт

    • Pact — офіційна документація (Introduction) — провайдерське контрактне тестування звіряє фактичну поведінку з задокументованим контрактом, але не дає гарантій щодо очікувань споживачів.
    • OpenAPI Specification — схема зі специфікації і є тим машиночитним описом, проти якого валідують реальну відповідь.
    • Ajv — Why use Ajv (офіційна документація) — інструментальна частина: схема компілюється у швидку функцію-валідатор.
    • JSON Schema Core, draft 2020-12 — строгість — вибір: без явного additionalProperties зайві поля дозволені й лишаються.

    Контракти в пайплайні

    • Pact Broker — Can I Deploy — щоб матриця стала придатною для рішення, пайплайн має повідомляти брокер про кожен успішний деплой через record-deployment.
    • The Practical Test Pyramid (Ham Vocke, martinfowler.com) — постачальник ганяє контрактні тести у власному , тож ламна зміна не пускається в прод із його ж пайплайна.

    Типові помилки

    • Martin Fowler — Tolerant Reader (bliki) — «безпечність» зміни визначає пара «API + клієнт»: строга привʼязка до схеми ламається, щойно постачальник додасть поле.
    • Pact — офіційна документація (Introduction) — контракт виконується як набір кейсів «один запит — одна відповідь» («contract by example»), а не як побайтове порівняння всього тіла.
    • RFC 8594 — The Sunset HTTP Header Field — вимкнення раніше за дату не порушує RFC: заголовок дає підказку, а не гарантію доступності до моменту.
    • Prism (Stoplight) — README репозиторію — незапущена специфікація дрейф не ловить — його показує валідація реального трафіку проти документа.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.