API-автотести в коді: клієнти, структура, ланцюжки
Зміст
Postman і Newman добре тримають першу сотню запитів. Але коли перевірок стають тисячі, коли їх треба ганяти в CI на кожен PR, перевикористовувати авторизацію, ділити тестові дані між прогонами й читати падіння в трейсі — колекція перетворюється на клубок скриптів, який ніхто не наважується чіпати. Тут API-тести переїжджають у код: той самий мовний стек, той самий репозиторій і той самий рев'ю-процес, що й у продукту.
Ця глава — про інженерію API-тестів, а не про тест-дизайн (техніки — у главі про тест-дизайн для API) і не про мову як таку (JS/TS — окремий розділ). Питають це на співбесідах на middle-позиції багато й прицільно: «чим ти ходиш в API з коду», «як готуєш стан», «чому fetch не впав на 500», «ретраї — це добре чи погано». Відповіді відрізняють того, хто писав автотести руками, від того, хто копіпастив приклади. (Передумова: базовий JS/TS.)
HTTP-клієнти: чим ходити в API
«HTTP-клієнт» — це бібліотека, що вміє відправити запит і повернути відповідь як об'єкт, з яким працює твій код. Вибір клієнта — не питання смаку: він визначає, як ти обробляєш помилки, таймаути й серіалізацію. Чотири інструменти, які реально зустрічаються в JS/TS-проєктах:
| Клієнт | Де живе | Кидає на 4xx/5xx | Типовий контекст |
|---|---|---|---|
fetch | Вбудований у браузер і в Node.js (з 18-ї версії) | Ні | Легкі перевірки без залежностей |
axios | Зовнішня бібліотека | Так (за замовчуванням) | Окремі API-сюїти, багато проміжної логіки |
APIRequestContext | Playwright | Ні | API-тести й підготовка стану поруч з e2e |
supertest | Зовнішня бібліотека | Ні (асертиться окремо) | Тестування свого сервера в процесі |
Ключова відмінність, яку люблять питати: fetch не вважає HTTP-помилку помилкою. Проміс fetch відхиляється лише на мережевому збої (DNS не резолвиться, з'єднання обірвалось). Статус 404 чи 500 — це успішно виконаний запит, у якого просто response.ok === false. Тому наївний код нижче ловить не те, що здається:
// Пастка: catch НЕ спрацює на 500 — fetch вважає це успіхом
try {
const res = await fetch('https://api.example.com/orders');
const body = await res.json();
} catch (e) {
// сюди зайдемо лише на розриві мережі, а не на 5xx
}
axios поводиться протилежно: на будь-який статус поза діапазоном 2xx він відхиляє проміс (це регулюється опцією validateStatus). Зручно для продакшн-коду, але для тестів радше заважає — ти й так хочеш перевіряти статус явно, а не через try/catch. APIRequestContext (фікстура request у Playwright) поводиться як fetch: помилка HTTP — це нормальна відповідь, яку ти асертиш. Його бонус — інтеграція з рештою Playwright: власний baseURL, запис запитів у трейс і спільне сховище cookie з браузерним контекстом через page.request/context.request (зручно для «залогінься через API, а перевіряй в UI»); сама фікстура request — ізольований контекст, cookie сторінки вона не бачить.
supertest стоїть окремо: він призначений тестувати HTTP-сервер у тому ж процесі, піднімаючи його на ефемерному порту. Ти передаєш йому свій застосунок (наприклад, Express) — це швидко й без мережі, але працює лише коли сервер під рукою в коді. Для тестування задеплоєного API по URL він не потрібен.
Практичний висновок: для більшості продуктових API-сюїтів, що ходять по мережі до реального середовища, природний вибір — APIRequestContext (якщо стек уже на Playwright) або axios/fetch у самостійній сюїті. supertest — це про компонентні тести бекенду зсередини.
Анатомія API-тесту: arrange–act–assert
Структура тесту не залежить від того, UI це чи API. Канон — arrange–act–assert (AAA), «підготуй — дій — перевір» (детально — у розділі про архітектуру автотестів). Для API три фази читаються так:
- Arrange — усе, що має існувати до головного запиту: токен авторизації, створений через API ресурс, вихідний стан бази. Це не предмет перевірки, а декорації сцени.
- Act — рівно один запит, поведінку якого перевіряє тест. Якщо в тесті два «головні» запити — це два тести.
- Assert — перевірки відповіді: статус, тіло, заголовки, відповідність схемі (повний чек-лист — у главі про перевірки відповіді).
test('POST /orders створює замовлення й повертає 201 зі схемою Order', async ({ request }) => {
// Arrange
const token = await auth.loginAs('customer');
// Act
const res = await request.post('/orders', {
headers: { Authorization: `Bearer ${token}` },
data: { productId: 42, qty: 2 },
});
// Assert
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ productId: 42, qty: 2, status: 'created' });
});
Два інваріанти, які роблять тест придатним до життя. По-перше, назва описує перевірку, а не дію: «повертає 201 зі схемою Order», а не «тест POST orders» — тоді червоний тест у звіті вже сам розповідає, що зламалось. По-друге, тест не залежить від порядку: він сам готує свій стан і не розраховує, що попередній тест щось лишив. Це передумова для паралельного запуску, до якого ми ще дійдемо.
Патерн API-клієнта
Якщо в кожному тесті писати сирий request.post('/orders', { headers: ... }), то заголовок авторизації, базовий шлях і розбір відповіді розповзуться копіпастою по всій сюїті. Змінився формат авторизації — правиш у ста місцях. Розв'язання те саме, що Page Object дає для UI: патерн API-клієнта — клас, який ховає ендпоінт за методом з людською назвою.
class OrdersApi {
constructor(private request: APIRequestContext, private token: string) {}
createOrder(data: OrderInput) {
return this.request.post('/orders', {
headers: { Authorization: `Bearer ${this.token}` },
data,
});
}
getOrder(id: string) {
return this.request.get(`/orders/${id}`, {
headers: { Authorization: `Bearer ${this.token}` },
});
}
}
Тепер тест читається як сценарій, а не як HTTP-механіка: await orders.createOrder({ productId: 42, qty: 2 }). Базовий URL, авторизація, серіалізація живуть в одному місці — це єдине джерело правди (принципи дизайну тестового коду — канон розділу про архітектуру). Клієнт віддає «сиру» відповідь, а перевірки лишаються в тесті: змішувати асерти всередину клієнта — спокуса, яка перетворює його на god object і ховає, що саме перевіряється.
Кожен шар знімає з тесту одну відповідальність: HTTP-клієнт — про транспорт, API-клієнт — про словник ендпоінтів, тест — лише про перевірку. Ту саму ідею потім перевикористовуєш і для підготовки даних в e2e-тестах.
Ланцюжки й підготовка стану через API
Багато перевірок не мають сенсу «на порожньому місці»: щоб перевірити GET /orders/{id}, замовлення спершу треба створити; щоб перевірити скасування — воно має існувати й бути в правильному стані. Так виникають ланцюжки (chaining): відповідь одного запиту дає вхід для наступного. Класика — витягнути id зі створеного ресурсу й підставити його в наступний виклик.
Головне правило: готуй стан найдешевшим надійним способом, а не через UI. Якщо тесту потрібне замовлення, не клацай його майстром у браузері — створи запитом. API-сідінг на порядок швидший і стабільніший за клікання, а тому не додає флаку до сценарію, який насправді про інше. (Стратегія тестових даних і стану — окрема глава цього розділу; канон стратегії даних — у розділі про архітектуру.)
Два підводні камені ланцюжків. Перший — прибирання (teardown): те, що тест насіяв, він і має прибрати, інакше середовище засмічується й сусідні прогони починають бачити чужі дані. Надійно вішати клінап на after-хук, щоб він відпрацював навіть коли основна перевірка впала. Другий — eventual consistency: у розподілених системах створений ресурс не завжди видно наступним запитом миттєво (репліка ще не наздогнала). Лікується це не sleep-ом навмання, а полінгом із таймаутом — до цього повернемось нижче й детально в главі про стабільність.
Валідація схем: ajv і zod
Перевіряти тіло відповіді пополе (expect(body.id).toBeDefined()) виснажливо й дірчасто: забув поле — пропустив регресію. Надійніше перевірити всю форму відповіді одним асертом проти схеми. Два робочі інструменти в JS/TS:
- ajv — валідатор JSON Schema. Ти описуєш очікувану структуру мовою JSON Schema, ajv компілює її у швидку функцію-перевіряч і повертає список розбіжностей. Головна вигода — JSON Schema не залежить від мови, тож ту саму схему можна взяти прямо з OpenAPI-специфікації і фактично перетворити доку на тест (в OpenAPI 3.1 схеми — це повноцінна JSON Schema; у 3.0 — близький діалект, дрібниці на кшталт
nullableдоведеться конвертувати). - zod — TS-first бібліотека: схему описуєш кодом на TypeScript, а
parseперевіряє дані в рантаймі. Бонус — з тієї ж схеми виводиться статичний тип, тож після валідації ти працюєш із типізованим об'єктом без ручнихas.
import { z } from 'zod';
const Order = z.object({
id: z.string().uuid(),
productId: z.number().int(),
qty: z.number().int().positive(),
status: z.enum(['created', 'paid', 'cancelled']),
});
const body = await res.json();
const order = Order.parse(body); // кине, якщо форма не збіглась; далі order типізований
Груба евристика вибору: якщо схеми вже є в OpenAPI й хочеться однієї правди для доки й тестів — ajv; якщо тести пишуться на TS з нуля й потрібні статичні типи — zod. Важлива дисципліна: за замовчуванням забороняй зайві поля (в JSON Schema — additionalProperties: false, у zod — .strict(), а в Zod 4 те саме дає z.strictObject()), інакше нове несподіване поле у відповіді проскочить повз перевірку. Схема ловить структурні регресії дешево — контрактне тестування розвиває цю ідею далі в окремій главі.
Таймаути й ретраї
Таймаут відповідає на питання «як довго чекати на відповідь, перш ніж вважати запит провальним». Без нього завислий запит підвісить весь прогон, поки в справу не втрутиться глобальний таймаут ранера з незрозумілою помилкою. Тому таймаут ставлять свідомо — і на рівні клієнта (окремий запит), і на рівні тесту. Значення обирають з голови сервісу: занизьке породжує флак на повільному CI, зависоке ховає деградацію продуктивності.
Ретрай (retry) — це автоматичний повтор запиту. І це найтонше місце теми, бо ретрай легко перетворюється на килим, під який замітають баги. Розрізняй три різні речі, які часто плутають:
- Мережевий ретрай на ідемпотентний запит. Повторити
GET, що впав через скидання з'єднання, — безпечно й нормально: повтор ідемпотентного запиту лишає стан сервера таким, яким його зробив перший виклик (аGETйого й так не змінює). Повторювати жPOST, який щось створює, наосліп — небезпечно: ризикуєш дублем (тут рятуєIdempotency-Key, якщо API його підтримує). - Полінг для eventual consistency. Коли даних ще немає, бо система не встигла узгодитись, — це не ретрай запиту, а очікування стану: періодично перепитувати
until-умову з таймаутом. У Playwright для цього єexpect.poll. Це легітимний патерн, а не маскування. - Ретрай усього тесту, що впав. Ось це — червоний прапорець. Якщо тест «зеленіє з другого разу», ретрай не полагодив причину, а сховав флак. Ретраї на рівні ранера доречні як тимчасовий буфер і сигнал («цей тест нестабільний»), але не як лікування (політика ретраїв і карантину — канон розділу про архітектуру; API-специфіка — глава про стабільність).
Одним рядком: таймаут і полінг лікують чесну асинхронність; сліпий ретрай мовчки ховає баг.
Паралельний запуск
API-тести швидкі (немає браузера й рендеру), тож їх ганяють десятками воркерів одночасно — прогін падає з десятків хвилин до одиниць. Але паралельність безжально карає за одну річ: спільний стан. Два воркери, що правлять один ресурс, дають гонку, і тест «іноді падає» без видимої причини.
Єдина передумова безпечної паралельності — ізоляція даних. Кожен тест працює зі своїми даними: унікальний email через генератор, окреме замовлення, а краще — окремий акаунт чи tenant на воркер. Антипатерн — один спільний тестовий юзер на всю сюїту: щойно тести стартують паралельно, вони стають у чергу за його станом і починають перетирати одне одному дані. (Концепція паралелізації й ізоляції — канон розділу про архітектуру; тут — лише API-проєкція.)
Практична дрібниця, що рятує нерви: якщо API віддає глобальний лічильник, послідовні id чи спільний кошик — закладай це в дизайн даних одразу, бо в паралелі такі місця «стріляють» першими.
Типові помилки
- Виглядає як «тест ловить помилку сервера», а насправді
fetchмовчки її проковтнув.fetchіAPIRequestContextне кидають на 4xx/5xx — статус треба перевіряти явним асертом, а не сподіватись наcatch. - Виглядає як стабільна перевірка тіла, а насправді дірка в покритті. Перевірка кількох полів пропускає зникнення чи зміну типу інших. Асерть всю форму проти схеми — і забороняй зайві поля.
- Виглядає як «баг у продукті — ресурс не з'явився», а насправді eventual consistency. Наступний запит прийшов раніше, ніж репліка узгодилась. Лікується полінгом із таймаутом, а не
sleep(3000). - Виглядає як стабілізація завдяки ретраям, а насправді замаскований флак. Тест, що зеленіє з другої спроби, досі зламаний — ти лише перестав це бачити у звіті.
- Виглядає як незалежні тести, а насправді прихована залежність через дані. Один спільний акаунт чи послідовність тестів «А створює — Б читає» валиться, щойно вмикається паралель.
- Виглядає як зручний API-клієнт, а насправді god object. Клієнт із асертами всередині ховає, що саме перевіряється, і плутає транспорт з перевіркою. Перевірки — у тесті.
Підсумок
- HTTP-клієнти діляться за поведінкою на помилку:
fetchіAPIRequestContextне кидають на 4xx/5xx,axiosкидає за замовчуванням;supertest— про тестування свого сервера в процесі. - Структура API-тесту — arrange–act–assert: один головний запит на тест, стан готується заздалегідь, назва описує перевірку.
- Патерн API-клієнта ховає ендпоінти, авторизацію й базовий URL за методами; перевірки лишаються в тесті, не в клієнті.
- Стан готуй через API, не через UI; ланцюжки прибирай за собою в after-хуці; eventual consistency лікуй полінгом, а не паузами.
- Валідація проти схеми (ajv або zod) ловить структурні регресії одним асертом; забороняй зайві поля.
- Паралельність безпечна лише за ізоляції даних; сліпий ретрай усього тесту маскує флак, а не лікує його.
Що питають на співбесіді
- «Чим ти ходиш в API з коду і чому саме цим?» — інтерв'юер перевіряє, чи є реальний досвід за назвами бібліотек. Сильна відповідь називає 2–3 клієнти й пояснює компроміс, а не хвалить один.
- «Чому
fetchне впав на статусі 500?» — класична пастка на розуміння, що HTTP-помилка ≠ помилка виконання запиту. Хто плутає, той писав тільки happy-path. - «Як готуєш тестові дані для API-тесту?» — дивляться, чи згадаєш API-сідінг замість UI, ізоляцію й прибирання. Відповідь «створюю руками в базі» насторожує.
- «Ретраї в тестах — це добре чи погано?» — перевірка зрілості. Очікують розрізнення: мережевий ретрай ідемпотентного запиту й полінг стану — ок; ретрай усього тесту, що падає, — маскування флаку.
- «Що потрібно, щоб запускати API-тести паралельно?» — ключове слово «ізоляція даних»; згадка про спільний акаунт як антипатерн — сильний сигнал.
- «Як перевіряєш тіло відповіді?» — очікують згадку про валідацію схеми (JSON Schema/ajv або zod) і заборону зайвих полів, а не лише
expectпо кількох полях.
Джерела
- MDN — Using the Fetch API (поведінка
fetch, чому HTTP-помилки не відхиляють проміс). - Playwright — API testing і APIRequestContext.
- Ajv JSON Schema validator — офіційна документація.
- Zod — офіційна документація.
- ISTQB Foundation Level 4.0, розділ 6 «Test Tools» — місце інструментів автоматизації в процесі (силабус загальний, не про конкретні бібліотеки).
Навіщо переносити API-тести з Postman/Newman у код?
Колекція чудово працює, поки запитів десятки чи сотня, але погано масштабується під тисячі перевірок у CI. У коді ти отримуєш той самий мовний стек, репозиторій і рев'ю-процес, що й у продукту: перевикористання авторизації, спільні тестові дані, читабельні падіння в трейсі, нормальна робота з гілками й код-рев'ю. Postman лишається зручним для ручного дослідження й дебагу окремого запиту, а систематичну регресію на кожен PR тримає код. Практичний тригер переходу — момент, коли колекцію вже страшно чіпати, бо її скрипти сплелися між собою.
Якими HTTP-клієнтами ходять в API з коду в JS/TS і чим вони відрізняються?
HTTP-клієнт (HTTP client) — це бібліотека, що відправляє запит і повертає відповідь як об'єкт. У JS/TS реально зустрічаються чотири: fetch (вбудований у браузер і Node.js з 18-ї версії), axios (зовнішня бібліотека), APIRequestContext (з Playwright) і supertest (для тестування свого сервера в процесі). Головна вісь відмінності, яку люблять питати, — поведінка на HTTP-помилку: fetch і APIRequestContext не кидають на 4xx/5xx, axios за замовчуванням кидає, supertest асертиться окремо. Вибір клієнта визначає, як ти обробляєш помилки, таймаути й серіалізацію, тож це не питання смаку.
Чому fetch не впав на статусі 500?
Бо fetch не вважає HTTP-помилку помилкою виконання запиту. Відхилення проміса зарезервоване за збоями транспорту: DNS не резолвиться, з'єднання обірвалось. А відповідь із 404 чи 500 для fetch — запит, що вдався: проміс резолвиться нормальним обʼєктом відповіді, лише response.ok буде false, тож catch навколо нього не спрацює. Тому статус треба перевіряти явним асертом (expect(res.status()).toBe(...)), а не сподіватись, що try/catch зловить 5xx. Хто плутає це, той писав тільки happy-path.
Чим axios поводиться інакше за fetch на помилку?
axios за замовчуванням відхиляє проміс на будь-який статус поза діапазоном 2xx — тобто робить те, чого не робить fetch. Це регулюється опцією validateStatus. Для продакшн-коду зручно (помилка одразу летить у catch), але для тестів радше заважає: ти й так хочеш перевіряти статус явним асертом, а не ловити його через try/catch. Тому в тестах на axios часто налаштовують validateStatus так, щоб він не кидав, і асертять статус вручну.
Що таке APIRequestContext і чим він зручний для API-тестів?
Це вбудований HTTP-клієнт Playwright, доступний у тестах як фікстура request. Поводиться він як fetch — HTTP-помилка не кидає виняток, її треба асертити. Головна вигода — інтеграція з рештою Playwright: свій baseURL, усі виклики видно в трейсі, а варіанти page.request/context.request ділять cookie з браузерним контекстом — залогінився через API, перевіряєш в UI (сама фікстура request ізольована й cookie сторінки не бачить). Якщо стек уже на Playwright, це природний вибір для API-сюїтів і підготовки стану поруч з e2e.
Навіщо supertest і коли він не потрібен?
supertest призначений тестувати HTTP-сервер у тому ж процесі: ти передаєш йому свій застосунок (наприклад, Express), він піднімає його на ефемерному порту й ходить у нього без реальної мережі. Це швидко й ізольовано, тому підходить для компонентних тестів бекенду зсередини. Але працює лише коли сервер під рукою в коді. Для тестування задеплоєного API по URL (стейдж, прод) він не потрібен — там ходять APIRequestContext, axios чи fetch.
Що таке arrange–act–assert і як він читається для API-тесту?
Arrange–act–assert (AAA), «підготуй — дій — перевір» — канонічна структура тесту, однакова для UI та API. Arrange — попередні умови сцени: логін і токен, насіяний через API ресурс, потрібний стан бази — усе, чого головний запит потребує, але що саме по собі не перевіряється. Act — рівно один запит, поведінку якого перевіряє тест. Assert — перевірки відповіді: статус, тіло, заголовки, відповідність схемі. Розділення фаз робить тест читабельним і показує, що саме тестується.
Чому в тесті має бути рівно один головний запит (act)?
Бо якщо в тесті два «головні» запити, поведінку яких ти перевіряєш, — це насправді два тести, злиті в один. Коли такий тест червоніє, незрозуміло, який саме крок зламався, і звіт бреше. Запити на підготовку стану (створити ресурс, залогінитись) не рахуються за act — це arrange. Один act на тест також спрощує назву: вона описує одну конкретну перевірку («повертає 201 зі схемою Order»), а не ланцюжок дій.
Що таке патерн API-клієнта і яку проблему він вирішує?
Патерн API-клієнта (API client) — це клас, який ховає ендпоінт за методом з людською назвою: замість сирого request.post('/orders', { headers: ... }) у кожному тесті — orders.createOrder({ productId, qty }). Проблема, яку він розв'язує, та сама, що й Page Object для UI: без нього базовий URL, заголовок авторизації й розбір відповіді дублюються в кожному тесті, і зміна формату авторизації вимагає правок у ста місцях. Клієнт робить це єдиним джерелом правди, а тест читається як сценарій, а не як HTTP-механіка.
Чому не варто класти асерти всередину API-клієнта?
Бо клієнт із перевірками всередині перетворюється на god object: він змішує транспорт (як сходити в ендпоінт) з перевіркою (що вважати правильним) і ховає, що саме асертиться в конкретному тесті. Клієнт має віддавати «сиру» відповідь, а перевірки лишатись у тесті — тоді один і той самий метод createOrder годиться і для позитивного тесту (очікуємо 201), і для негативного (очікуємо 422). Це та сама дисципліна розділення відповідальностей, що й «локатори в Page Object, асерти в тесті».
Що таке ланцюжки (chaining) в API-тестах?
Ланцюжок — це коли відповідь одного запиту дає вхід для наступного. Частина перевірок неможлива без попереднього стану: GET /orders/{id} нема з чим викликати, поки інший запит не створив замовлення й не віддав його id. Класичний ланцюжок: POST /orders віддає id, який ти підставляєш у POST /orders/{id}/cancel, а потім перевіряєш GET /orders/{id}. Частина ланцюжка — це arrange (підготовка стану), і лише один крок — справжній act, який ти перевіряєш.
Чому тестові дані для API краще готувати через API, а не через UI?
Бо API-сідінг (seeding) на порядок швидший і стабільніший за клікання майстром у браузері. Якщо тесту потрібне замовлення, клацати його через UI означає протягнути в arrange весь фронтенд з його рендером, анімаціями й флаком — і все це заради декорацій, а не заради самої перевірки. Головне правило: готуй стан найдешевшим надійним способом. Відповідь «створюю руками в базі» на співбесіді насторожує (крихко, обходить бізнес-логіку), а «сідж через API» — сильний сигнал.
Що таке teardown і чому його вішають на after-хук?
Teardown (прибирання) — це видалення того, що тест насіяв: створені замовлення, юзери, файли. Без нього середовище засмічується, і сусідні прогони починають бачити чужі дані, а на повторному запуску вилазять конфлікти (409 на зайнятий email). Прибирання вішають на after-хук (afterEach/finally), щоб воно відпрацювало навіть коли основна перевірка впала посередині: інакше кожен червоний тест лишав би за собою сміття. Це та сама пара «створив — прибрав», що й у будь-якому керуванні ресурсами.
Що таке eventual consistency і чому sleep — погане лікування?
Eventual consistency (кінцева узгодженість) — властивість розподілених систем, коли створений ресурс не завжди видно наступним запитом миттєво: репліка ще не наздогнала майстер. Симптом — «баг у продукті, ресурс не з'явився», а насправді наступний запит прийшов раніше, ніж дані узгодились. sleep(3000) — погане лікування одразу з двох причин: на швидкому середовищі він марно гальмує прогін, а на повільному все одно не встигне й тест флакне. Правильно — полінг (polling) з таймаутом: періодично перепитувати until-умову, поки не справдиться або не вийде час (expect.poll у Playwright).
Навіщо валідувати схему відповіді і чим ajv відрізняється від zod?
Перевіряти тіло пополе (expect(body.id).toBeDefined()) виснажливо й дірчасто: забув поле — пропустив регресію. Валідація проти схеми перевіряє всю форму відповіді одним асертом. ajv — валідатор JSON Schema: описуєш структуру мовою JSON Schema, ajv компілює її у швидку функцію й повертає розбіжності; головна вигода — схему можна взяти прямо з OpenAPI-специфікації, перетворивши доку на тест. zod — TS-first: схему описуєш кодом на TypeScript, parse перевіряє в рантаймі, а бонус — з тієї ж схеми виводиться статичний тип. Груба евристика: є OpenAPI й потрібна одна правда для доки й тестів — ajv; пишеш на TS з нуля й хочеш статичні типи — zod.
Чому у схемі важливо забороняти зайві поля?
Бо за замовчуванням валідатор перевіряє лише те, що ти описав, і пропускає все зверху. Якщо бекенд почне повертати нове несподіване поле (наприклад, витік внутрішнього passwordHash чи internalNote), м'яка схема цього не помітить — регресія проскочить. Тому за замовчуванням зайві поля забороняють: у JSON Schema це additionalProperties: false, у zod — .strict(). Тоді будь-яка структурна зміна відповіді — і зникнення поля, і поява зайвого — валить тест дешево, одним асертом.
Навіщо таймаут у тесті і чим загрожують занизьке та зависоке значення?
Таймаут (timeout) — це межа очікування відповіді, після якої запит вважається провальним. Без нього завислий запит тримає весь прогін, аж поки не спрацює глобальний ліміт ранера з малоінформативною помилкою. Тому межу задають свідомо на двох рівнях: для окремого запиту в клієнті й для тесту цілком. Занизьке значення породжує флак на повільному CI (нормальна відповідь просто не встигає прийти), зависоке — ховає деградацію продуктивності (сервіс уже відповідає вдвічі повільніше, а тест мовчить). Значення обирають з голови сервісу, а не наосліп.
Ретраї в тестах — це добре чи погано?
Залежить від того, що саме повторюють — тут перевіряють зрілість. Треба розрізняти три речі. Мережевий ретрай ідемпотентного запиту (повторити GET, що впав через скидання з'єднання) — безпечно, бо повтор не змінює стан сервера; а от наосліп повторювати POST, що щось створює, — ризик дубля (рятує Idempotency-Key, якщо API його підтримує). Полінг для eventual consistency — це не ретрай, а очікування стану з таймаутом, легітимний патерн. Ретрай усього тесту, що впав — червоний прапорець: тест, який проходить лише з другої спроби, лишився зламаним — повтор прибрав симптом зі звіту, а не причину. Одним рядком: таймаут і полінг лікують чесну асинхронність, сліпий ретрай усього тесту мовчки ховає баг.
Що потрібно, щоб запускати API-тести паралельно?
Ключове слово — ізоляція даних (data isolation). Без браузера й рендеру API-тести дешеві, тому їх запускають на десятках воркерів — і прогін скорочується з десятків хвилин до одиниць. Але паралельність безжально карає за спільний стан: два воркери, що правлять один ресурс, дають гонку й «іноді падає» без видимої причини. Тому кожен тест має працювати зі своїми даними: згенерований унікальний email, власне замовлення, а в ідеалі — окремий акаунт чи tenant на воркер. Антипатерн, згадка якого — сильний сигнал: один спільний тестовий юзер на всю сюїту, за стан якого тести стають у чергу.
Чому один спільний тестовий юзер — антипатерн для паралельного запуску?
Бо щойно тести стартують паралельно, вони починають перетирати одне одному стан цього юзера: один змінює його кошик, другий у цей момент його читає й бачить чуже. Виникає прихована залежність через дані, яка маскується під незалежні тести, — вона працює послідовно й розсипається в паралелі. Лікування — ізоляція: генерувати унікальні дані на кожен тест або виділяти окремий акаунт/tenant на воркер. Окрема пастка того ж роду: глобальний лічильник, послідовні id чи спільний кошик в API — такі місця «стріляють» першими, тож їх закладають у дизайн даних одразу.
Три кейси, які проходять шлях від сирого запиту до інженерної сюїти: винести ендпоінти в API-клієнт і написати чистий arrange–act–assert, зібрати ланцюжок з підготовкою стану, прибиранням і полінгом замість sleep, і замкнути перевірку тіла на схему з забороною зайвих полів. Скрізь — що дивитися і чому.
Кейс 1. API-клієнт і чистий arrange–act–assert
Сирий запит у кожному тесті розповзається копіпастою: заголовок авторизації, базовий шлях і розбір відповіді доводиться правити в десятках місць. Виносимо ендпоінти в клас-клієнт, а тест лишаємо про перевірку.
// orders-api.ts — словник ендпоінтів, жодних асертів усередині
import { APIRequestContext } from '@playwright/test';
type OrderInput = { productId: number; qty: number };
export class OrdersApi {
constructor(private request: APIRequestContext, private token: string) {}
createOrder(data: OrderInput) {
return this.request.post('/orders', {
headers: { Authorization: `Bearer ${this.token}` },
data,
});
}
getOrder(id: string) {
return this.request.get(`/orders/${id}`, {
headers: { Authorization: `Bearer ${this.token}` },
});
}
cancelOrder(id: string) {
return this.request.post(`/orders/${id}/cancel`, {
headers: { Authorization: `Bearer ${this.token}` },
});
}
deleteOrder(id: string) {
return this.request.delete(`/orders/${id}`, {
headers: { Authorization: `Bearer ${this.token}` },
});
}
}
// orders.spec.ts — тест читається як сценарій, а не як HTTP-механіка
import { test, expect } from '@playwright/test';
import { OrdersApi } from './orders-api';
test('POST /orders створює замовлення й повертає 201 зі статусом created', async ({ request }) => {
// Arrange — декорації сцени, не предмет перевірки
const token = await auth.loginAs('customer');
const orders = new OrdersApi(request, token);
// Act — рівно ОДИН головний запит
const res = await orders.createOrder({ productId: 42, qty: 2 });
// Assert — статус і форма відповіді
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toMatchObject({ productId: 42, qty: 2, status: 'created' });
});
Що дивитися і чому:
- Асерти лишились у тесті, клієнт віддає сиру відповідь. Той самий
createOrderгодиться і для позитивного тесту (чекаємо201), і для негативного (чекаємо422на биті дані). Занеси перевірку в клієнт — і він стане god object, який ховає, що саме тестується. - Один act на тест.
loginAsіnew OrdersApi— це arrange, а не другий головний запит. Якби в тесті було два перевірюваних виклики, червоний звіт не сказав би, який саме зламався. - Назва описує перевірку, а не дію. «Повертає 201 зі статусом created» у червоному звіті вже розповідає, що зламалось; «тест POST orders» — ні.
res.status()— явний асерт, а неtry/catch.APIRequestContextне кидає на4xx/5xx, тож статус перевіряється руками. Наївнийcatchтут не спрацював би на500.
Кейс 2. Ланцюжок: підготовка стану, прибирання, полінг
Скасування замовлення не перевірити «на порожньому місці» — замовлення спершу треба створити. Це ланцюжок: id зі створеного ресурсу йде у наступні виклики. Стан сіємо через API (не через UI), прибираємо в after-хуці, а на eventual consistency ставимо полінг замість паузи навмання.
import { test, expect } from '@playwright/test';
import { OrdersApi } from './orders-api';
test.describe('скасування замовлення', () => {
let orders: OrdersApi;
let orderId: string;
test.beforeEach(async ({ request }) => {
const token = await auth.loginAs('customer');
orders = new OrdersApi(request, token);
// Arrange — сідж стану найдешевшим надійним способом: запитом, не кліком
const created = await orders.createOrder({ productId: 42, qty: 2 });
expect(created.status()).toBe(201);
orderId = (await created.json()).id;
});
test.afterEach(async () => {
// Teardown на after-хуці — відпрацює навіть коли перевірка впала
await orders.deleteOrder(orderId).catch(() => {});
});
test('POST /orders/{id}/cancel переводить замовлення у cancelled', async () => {
// Act — те, що справді перевіряємо
const res = await orders.cancelOrder(orderId);
expect(res.status()).toBe(200);
// Assert зі стійкістю до eventual consistency: полінг, а не sleep(3000)
await expect.poll(async () => {
const got = await orders.getOrder(orderId);
return (await got.json()).status;
}, { timeout: 5000 }).toBe('cancelled');
});
});
Що дивитися і чому:
- Сідж через API, а не через UI. Замовлення в
beforeEachстворюється запитом за мілісекунди й без флаку фронтенду. Клацати його майстром у браузері означало б протягнути в arrange весь рендер заради декорацій. - Teardown у
afterEach, а не в кінці тесту. Якби прибирання стояло останнім рядком тесту, воно б не відпрацювало на падінніcancel, і середовище засмічувалось би..catch(() => {})глушить помилку клінапу, щоб вона не маскувала справжню причину падіння. expect.pollзамістьsleep. Післяcancelрепліка може ще не наздогнати, і миттєвийGETвіддасть старий статус. Полінг перепитує до 5 секунд і зеленіє щойно узгодилось — на швидкому середовищі не гальмує, на повільному не флакне.sleep(3000)програє в обидва боки.- Ланцюжок ≠ один тест. Створення в
beforeEach— arrange,cancel— act,getOrder— assert. Перевіряється рівно один перехід стану.
Кейс 3. Валідація тіла проти схеми з забороною зайвих полів
Перевіряти тіло пополе (expect(body.id).toBeDefined()) дірчасто: забув поле — пропустив регресію, а зайве поле взагалі не помітиш. Замикаємо всю форму на одну схему zod і забороняємо зайве через .strict().
import { test, expect } from '@playwright/test';
import { z } from 'zod';
import { OrdersApi } from './orders-api';
// .strict() → будь-яке несподіване поле у відповіді провалить parse
const Order = z
.object({
id: z.string().uuid(),
productId: z.number().int(),
qty: z.number().int().positive(),
status: z.enum(['created', 'paid', 'cancelled']),
})
.strict();
test('GET /orders/{id} повертає тіло, що точно відповідає схемі Order', async ({ request }) => {
const token = await auth.loginAs('customer');
const orders = new OrdersApi(request, token);
const { id } = await (await orders.createOrder({ productId: 42, qty: 2 })).json();
const res = await orders.getOrder(id);
expect(res.status()).toBe(200);
const body = await res.json();
const order = Order.parse(body); // кине, якщо форма не збіглась; далі order типізований
expect(order.status).toBe('created');
});
Що дивитися і чому:
- Одна схема замість десятка
expect.Order.parseперевіряє всю форму разом: типи, набір полів, допустимі значенняstatus. Зникло поле чи змінився тип —parseкине з описом розбіжності, і жодна регресія не проскочить. .strict()ловить зайве поле. Без нього відповідь із витеклимinternalNoteчиpasswordHashмовчки пройшла б валідацію. У JSON Schema (ajv) той самий ефект даєadditionalProperties: false.- Бонус типізації. Після
parseзміннаorderтипізована — далі працюєш без ручнихas, а редактор підказує поля. Це TS-first перевагаzod. - Коли натомість
ajv. Якщо схеми вже описані в OpenAPI й хочеться однієї правди для доки й тестів — беруть JSON Schema прямо зі специфікації й валідуютьajv, фактично перетворюючи доку на тест.zod— коли тести пишуться на TS з нуля й потрібні статичні типи.
HTTP-клієнти
- Знаю чотири клієнти в JS/TS (
fetch,axios,APIRequestContext,supertest) і чим вони відрізняються за поведінкою на помилку та контекстом застосування. - Можу пояснити, чому
fetchіAPIRequestContextне кидають на4xx/5xx: проміс відхиляється лише на мережевому збої, а500— це успішний запит зresponse.ok === false, тож статус перевіряють асертом. - Знаю, що
axiosза замовчуванням кидає на будь-який статус поза2xx(регулюєтьсяvalidateStatus), аsupertestасертить статус окремо. - Розумію, що
APIRequestContextдаєbaseURL, запис у трейс, а черезpage.request/context.request— спільні cookie з браузером (фікстураrequestізольована), тоді якsupertestпотрібен для свого сервера в процесі (ефемерний порт), не для задеплоєного API по URL.
Структура тесту (arrange–act–assert)
- Знаю три фази AAA і що для API arrange — це підготовка стану, act — рівно один головний запит, assert — перевірки відповіді.
- Розумію, чому в тесті один act (два «головні» запити — це два тести, і червоний звіт інакше бреше) і чому назва описує перевірку («повертає 201 зі схемою Order»), а не дію («тест POST orders»).
- Знаю, що тест не має залежати від порядку й сам готує свій стан — це передумова паралельного запуску.
Патерн API-клієнта
- Можу пояснити, яку проблему розв'язує патерн API-клієнта: ховає ендпоінт,
baseURLі авторизацію за методом з людською назвою, як Page Object для UI; розподіл шарів — HTTP-клієнт про транспорт, API-клієнт про словник ендпоінтів, тест лише про перевірку. - Розумію, чому асерти не кладуть у клієнт: інакше він стає god object, змішує транспорт з перевіркою і ховає, що саме тестується.
Ланцюжки й підготовка стану
- Знаю, що таке ланцюжок (chaining): відповідь одного запиту (
idствореного ресурсу) дає вхід для наступного. - Можу пояснити, чому стан готують через API-сідінг, а не через UI (швидше, стабільніше, не додає флаку до сценарію, що про інше), і чому teardown вішають на after-хук — щоб прибирання відпрацювало навіть коли основна перевірка впала.
- Знаю різницю між справжнім багом «ресурс не з'явився» та eventual consistency, коли репліка ще не наздогнала, і чому її лікують полінгом із таймаутом, а не
sleep(3000).
Валідація схем
- Розумію, чому перевірка всієї форми проти схеми надійніша за перевірку кількох полів пополе.
- Знаю різницю
ajvvszod:ajv— JSON Schema (можна взяти з OpenAPI),zod— TS-first зі статичним типом з тієї ж схеми. - Можу пояснити, чому за замовчуванням забороняють зайві поля (
additionalProperties: false/.strict()), інакше нове поле проскочить повз перевірку.
Таймаути й ретраї
- Розумію, навіщо таймаут (не підвісити прогін) і чому занизьке значення дає флак, а зависоке ховає деградацію продуктивності.
- Знаю різницю між трьома «повторами»: мережевий ретрай ідемпотентного запиту (наосліп
POST— ризик дубля, рятуєIdempotency-Key), полінг стану й ретрай усього тесту. - Розумію, чому ретрай усього тесту — червоний прапорець: «зеленіє з другого разу» ховає флак, а не лікує причину.
Паралельний запуск
- Знаю, що єдина передумова безпечної паралельності — ізоляція даних, чому спільний стан дає гонки й чому один спільний тестовий юзер — антипатерн прихованої залежності через дані.
- Розумію, чому глобальні лічильники, послідовні
idчи спільний кошик «стріляють» першими в паралелі.
Тест зробив fetch на ендпоінт, сервер відповів 500, а catch не спрацював. Чому?
Питання
Чому API-тести переїжджають з Postman/Newman у код?