Мокання залежностей: стаби, mock-сервери, вебхуки
Зміст
Реальний сервіс рідко живе сам по собі. Він ходить у платіжний шлюз, шле листи через сторонній SMTP, смикає чужий API курсів валют, чекає вебхук від банку. Щойно ваш автотест торкається такої залежності, він успадковує всі її болячки: чужий сервіс повільний, лежить у пісочниці по вихідних, коштує грошей за виклик і ніяк не хоче на замовлення повернути вам 503 чи таймаут. Тест стає флакі не тому, що продукт зламаний, а тому, що зламалось або гальмує щось, чого ви не контролюєте.
Мокання (mocking) розриває цю залежність: замість справжнього шлюзу тест бачить дублера, який відповідає миттєво, детерміновано і рівно так, як вам треба — хоч успіхом, хоч 429, хоч порожнім тілом. Ця глава — про підміну саме на рівні сервісів: коли ви замінюєте цілу зовнішню систему, від якої залежить бекенд. Це не те саме, що браузерне перехоплення через page.route (механіка підміни відповідей усередині браузера — у web-главі «Перехоплення й мокання мережі»); тут мова про залежності самого сервіса, часто поза браузером узагалі.
Навіщо мокати на рівні сервісів
Причини завжди зводяться до контролю. Ви мокаєте залежність, коли хочете:
- детермінованість — та сама відповідь на кожному прогоні, без «сьогодні курс 41, завтра 42»;
- швидкість — локальний дублер відповідає за мілісекунди, а не за секунди мережевого походу до чужого дата-центру;
- крайові випадки на замовлення — справжній шлюз майже неможливо змусити віддати
500, розрив з'єднання чи429 Too Many Requests, а мок робить це одним рядком конфігу; - ізоляцію падінь — коли чужа пісочниця лягла, ваша сюїта має лишатись зеленою, бо тестує ваш код, а не їхній аптайм;
- гроші й побічні ефекти — реальний платіж, реальний лист, реальна SMS у кожному прогоні — це витрати й спам.
Щоб говорити про це точно, потрібен спільний словник дублерів. Канонічна таксономія (dummy / stub / spy / mock / fake) розібрана в розділі про стратегію автоматизації; тут вистачить трьох робочих понять. Стаб (stub) віддає заготовлену відповідь і нічого не перевіряє — це «магнітофон». Мок (mock) ще й перевіряє, що виклик стався так, як домовлено: правильний метод, тіло, кількість разів. Мок-сервер (mock server) — окремий HTTP-сервер, що вдає цілу залежність: приймає запити на своїх ендпоінтах і повертає налаштовані відповіді. На межі HTTP більшість того, що ви робите в API-тестах, — це стаби й мок-сервери; слово «мок» у побуті вживають ширше, ніж у строгій таксономії.
Три точки, куди можна встромити мок
Головне питання не «яким інструментом», а «де саме перехопити виклик». Від цього залежить усе інше. Є три принципові точки.
У тому ж процесі. Інструмент на кшталт nock вклинюється у мережевий шар самого процесу Node і перехоплює вихідні HTTP-запити, перш ніж вони підуть у мережу. Це найдешевший спосіб, коли і тест, і код, що робить виклик, живуть в одному процесі Node. Мінус — він бачить лише свій процес: якщо ваш сервіс запущений окремо, nock до його вихідних викликів не дотягнеться.
Окремий мок-сервер за власним URL. WireMock чи Prism піднімаються як окремий процес-сервер. Ваш сервіс дивиться на нього через змінену базову адресу: у тесті ви кажете PAYMENTS_URL=http://localhost:8080, і всі виклики платежів ідуть у мок замість продового шлюзу. Такий підхід мово- і процес-незалежний — годиться, навіть коли сам сервіс написаний не на Node, а на Go чи Java.
На рівні мережі клієнта. msw (Mock Service Worker) перехоплює запити близько до їхнього джерела: у браузері — через Service Worker, у Node — через перехоплювач запитів. Один опис хендлерів працює в обох середовищах, тож ті самі моки використовують і фронтенд-тести, і бекенд.
Вісь, яка все визначає: мок живе всередині процесу, що робить виклик, чи це окремий сервер, на який процес свідомо дивиться. Перше простіше налаштувати, друге — універсальніше й ближче до продової топології.
Інструменти: nock, msw, WireMock
| Інструмент | Де живе | Що перехоплює | Коли обирати |
|---|---|---|---|
nock | у процесі Node | вихідні HTTP-запити цього процесу | код і тест в одному Node-процесі |
msw | браузер + Node | запити на рівні мережі, одні хендлери на обидва середовища | спільні моки для фронта й бекенда |
| WireMock | окремий сервер | усе, що прийде на його порт | сервіс не на Node; потрібна прод-подібна топологія |
nock описує очікуваний виклик і відповідь декларативно: «на GET /rate поверни 200 з таким тілом». Якщо реальний запит не збігся з описаним матчером — мок його не перехопить, і тест впаде (на перевірці scope.isDone(), а з nock.disableNetConnect() — одразу); це зручно як побічна перевірка контракту виклику. Пастка та сама, що й користь: занадто вузький матчер ловить не той запит, занадто широкий — ловить будь-що.
msw цінний тим, що один набір хендлерів переносний. Опис виглядає так:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.rates.io/v1/usd', () => {
return HttpResponse.json({ currency: 'USD', rate: 41.2 });
}),
];
WireMock — це вже повноцінний HTTP-сервер зі своїм сховищем «мапувань» (stub mappings). Стаб задають JSON-ом або через його REST API: опис запиту (метод, шлях, заголовки, тіло) і відповіді. Він уміє більше за прості стаби — зіставлення за складними матчерами, сценарії зі станом (перший виклик віддає одне, другий — інше), проксі та запис реального трафіку в стаби. Мінімальне мапування:
{
"request": { "method": "POST", "urlPath": "/charge" },
"response": {
"status": 402,
"jsonBody": { "error": "card_declined" }
}
}
Один рядок — і ваш сервіс щоразу отримує відмову платежу. Відтворити такий сценарій на справжньому шлюзі без спеціальних тестових карток ви б не змогли.
Mock зі специфікації: Prism
Усі попередні стаби мають одну спільну ваду: їх пишуть руками, і вони — ваша вигадка про те, як поводиться залежність. Якщо у залежності є OpenAPI-специфікація, є розумніший шлях: підняти мок прямо з неї. Це робить Prism (від Stoplight).
Команда prism mock openapi.yaml бере специфікацію й піднімає HTTP-сервер, який на кожен описаний ендпоінт віддає відповідь, що відповідає схемі: або приклад (example) із самої специфікації, або згенероване за схемою значення. Виграш принциповий — мок виведено з джерела істини, а не зі здогадів автора тесту. Оновили специфікацію — оновився й мок.
У Prism є й режим проксі-валідатора: він пропускає запити до реального бекенда й перевіряє, що і запит, і відповідь відповідають специфікації, лаючись на розбіжності. Це вже містком веде до контрактного тестування.
Пастка тут тонка: валідна за схемою відповідь — не те саме, що семантично правильна. Мок віддасть будь-який рядок, що підходить під тип string, і будь-яке число в межах формату. Він перевіряє форму, а не бізнес-сенс, тож логіку, яка залежить від конкретних значень, на такому моку не протестуєш.
Компонентне тестування
Коли ви замінюєте моками всі зовнішні залежності сервіса й ганяєте його через власний публічний API, це називають компонентним тестуванням (component testing) — у мікросервісному сенсі, як у статті про тестування мікросервісів на martinfowler.com. Термін оманливий: в ISTQB «component testing» означає натомість тестування окремих модулів (юніт-рівень), тож тут ідеться про інший рівень. Ваш сервіс — справжній, запущений; усе, що за його межами (база, платежі, пошта, чужі API), — дублери.
Це проміжний рівень між юніт-тестами (де мокають майже все) і наскрізними e2e (де все справжнє). Компонентні тести швидкі й детерміновані, бо не залежать від чужих сервісів, але перевіряють сервіс цілком, з реальною серіалізацією, роутингом і обробкою помилок. У мікросервісній архітектурі це основний спосіб перевіряти «сервіс в ізоляції», не піднімаючи все середовище (стратегія покриття мікросервісів — тема розділу про автоматизацію). Ціна відома й обговорюється нижче: що більше ви мокаєте, то більший ризик, що моки відстануть від реальності.
Вебхуки: локальний приймач і тунель
Вебхук (webhook) перевертає напрям. Досі запити робив ваш код; тут зовнішня система сама шле HTTP-запит на ваш URL, коли стається подія: оплата пройшла, лист доставлено, підписку скасовано. Щоб таке перевірити, потрібен не клієнт, а приймач — ендпоінт, який зловить запит і збереже його вміст для перевірок.
Дві проблеми роблять вебхуки незручними. Перша: щоб зовнішній провайдер достукався до вас, ваш приймач має бути доступний з інтернету, а він крутиться на localhost. Друга: вебхук приходить не в мить дії, а колись потім, тобто асинхронно.
Першу розв'язує тунель (tunnel) — сервіс на кшталт ngrok, localtunnel чи cloudflared, що дає публічний URL і проксує запити на ваш локальний порт. Ви реєструєте цей URL у провайдера як адресу вебхука, і його виклики доходять до вашого приймача за ноутбуком.
Приймач може бути дуже простим — крихітний сервер, що складає прийняте у пам'ять чи файл, звідки тест його потім забирає:
import http from 'node:http';
const received: unknown[] = [];
http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
received.push(JSON.parse(body));
res.writeHead(200).end();
});
}).listen(3001);
Другу проблему — асинхронність — розв'язує полінг із таймаутом, а не sleep навмання: тест періодично питає приймач, чи прийшов очікуваний вебхук, до розумного дедлайну (та сама дисципліна очікувань, що й для eventual consistency в даних). Не забувайте про два аспекти реальних вебхуків: провайдери зазвичай підписують payload (HMAC-підпис у заголовку) — приймач має перевіряти підпис, інакше ви пропустите баг верифікації; і провайдери зазвичай ретраять доставку, якщо ви не відповіли 2xx, тож один і той самий вебхук може прийти двічі, і ваш обробник має бути ідемпотентним. Повна стратегія перевірки вебхуків як класу вихідних викликів — канонічно у главі «SOAP, gRPC і асинхронні API».
Email-флоу: MailHog і Mailtrap
Пошта — окремий випадок залежності, який ловлять окремими інструментами. Реєстрація, скидання пароля, нотифікації — усе це шле листи, і перевірити треба три речі: що лист узагалі відправлено, що його вміст правильний і що посилання з нього працює. Слати на справжні адреси не можна — це спам і витік.
Розв'язання — фейковий SMTP-сервер, що ловить усю пошту й не пускає її далі. MailHog — локальний: піднімає SMTP для приймання, веб-інтерфейс для перегляду очима і HTTP API, щоб забирати листи з тесту (сам MailHog давно не оновлюється — його роль де-факто перебирає активно підтримуваний Mailpit, натхнений тією ж ідеєю). Mailtrap — хмарний варіант тієї ж концепції: тестова поштова скринька з API. Ви просто спрямовуєте застосунок на цей SMTP замість продового.
Далі тест: тригериш дію → опитуєш API приймача, поки лист не з'явиться → асертиш адресата, тему й тіло → витягуєш посилання з тіла й переходиш по ньому.
import { test, expect, request } from '@playwright/test';
test('лист верифікації містить робоче посилання', async ({ page }) => {
const address = `user_${Date.now()}@example.test`;
await triggerSignup(address); // дія, що шле лист
const mail = await request.newContext({ baseURL: 'http://localhost:8025' });
let link = '';
await expect.poll(async () => {
const res = await mail.get(`/api/v2/search?kind=to&query=${address}`);
const { items } = await res.json();
if (!items.length) return false;
const html = items[0].Content.Body; // структура залежить від приймача
link = html.match(/href="([^"]*\/verify[^"]*)"/)?.[1] ?? '';
return Boolean(link);
}, { timeout: 10_000 }).toBeTruthy();
await page.goto(link);
await expect(page.getByText('Акаунт підтверджено')).toBeVisible();
});
Три нюанси, на яких спотикаються. По-перше, реальні листи майже завжди багаточастинні (multipart): є текстова версія і HTML-версія, і посилання з них можуть відрізнятись — цільтеся у ту частину, якою користуватиметься людина. По-друге, лінк у листі часто загорнутий у трекінг-редірект поштового провайдера — у тесті переходьте по ньому як браузер, а не парсьте кінцеву адресу наївно. По-третє, посилання нерідко абсолютне й веде на конкретне середовище (https://app.prod...); переконайтесь, що воно вказує на те середовище, де крутиться тест, інакше перехід поведе вас не туди.
Розсинхрон мока з реальністю — міст до контрактів
Тепер головна ціна всього цього. Мок — це ваша знімкова копія чужої поведінки на момент, коли ви його писали. Залежність живе своїм життям: провайдер перейменував поле, змінив код помилки з 400 на 422, додав обов'язковий заголовок. Ваш мок про це не знає й далі бреше по-старому. Тести зелені, бо перевіряють згоду вашого коду з застарілим моком — а не з реальним сервісом. Це називають розсинхроном схеми (schema drift), і це системна вада будь-якого мокання: що надійніша ізоляція, то легше не помітити, що світ поїхав.
Саме тут мок передає естафету контрактному тестуванню. Контрактні тести перевіряють, що ваше уявлення про залежність (той самий мок) досі збігається з тим, що залежність реально віддає, — і сигналять, коли контракт поїхав. Мок дає швидкість та ізоляцію; контракт страхує від того, що ця ізоляція перетворилась на самообман. Механіку — consumer-driven contracts, Pact, валідацію проти OpenAPI — розбирає окрема глава «Контрактне тестування і сумісність версій».
Типові помилки
- Виглядає як зелена сюїта, а насправді — застарілий мок. Тести проходять, а прод падає, бо залежність змінила формат, а мок лишився старим. Лікує не ще один мок, а контрактна перевірка проти реального сервіса.
- Виглядає як баг мок-сервера, а насправді — матчер.
nockчи WireMock «не ловить» запит не тому, що зламані, а тому, що матчер описав не той шлях, метод чи заголовок. Звіряйте очікуваний запит з фактичним. - Виглядає як «вебхук не прийшов», а насправді — тунель, підпис або ретрай. Публічний URL протух, приймач відкинув запит через невірний HMAC-підпис, або провайдер ще ретраїть після вашого не-
2xx. Дивіться логи тунеля й провайдера, перш ніж винити свій код. - Виглядає як валідна відповідь, а насправді — порожня семантика. Мок із Prism віддав рядок, що підходить під схему, але без бізнес-сенсу; логіку на конкретних значеннях так не перевіриш.
- Виглядає як тест фічі, а насправді — тест мока. Замокали власний сервіс, який мали перевіряти, і асертите поведінку заглушки. Мокають залежності навколо, а не об'єкт перевірки.
- Виглядає як стабільний лист, а насправді — не та MIME-частина. Асерт на текстову версію, а користувач бачить HTML (або навпаки), і посилання з них різні.
Підсумок
- Мокають, щоб отримати контроль: детермінованість, швидкість, крайові випадки й ізоляцію від чужого аптайму та коштів.
- Ключове рішення — де перехопити виклик: у процесі (
nock), окремим мок-сервером за URL (WireMock, Prism) чи на рівні мережі клієнта (msw). - Мок зі специфікації (Prism) виводиться з джерела істини й не бреше про форму — але не гарантує бізнес-семантику.
- Вебхук перевіряють приймачем; для доступу ззовні потрібен тунель, а асинхронність беруть полінгом із таймаутом, не
sleep-ом. Пам'ятайте про підпис і ретраї. - Email-флоу ловлять фейковим SMTP (MailHog, Mailtrap): асерт на адресата/тему/тіло й перехід по посиланню з листа.
- Будь-який мок старіє. Швидкість мока страхує контрактне тестування — інакше зелена сюїта стає самообманом.
Що питають на співбесіді
- «Навіщо взагалі мокати залежності в тестах?» Інтерв'юер перевіряє, чи бачите ви компроміс, а не лозунг «мок = добре». Сильна відповідь називає детермінованість, швидкість, крайові випадки та ізоляцію — і одразу ж ціну: ризик розсинхрону з реальністю.
- «Стаб проти мока — у чому різниця?» Тут ловлять на плутанині термінів. Стаб віддає заготовлену відповідь; мок ще й перевіряє сам факт і форму виклику. Дублером, який верифікує взаємодію, є саме мок.
- «Як протестувати вебхук?» Дивляться, чи розумієте ви зворотний напрям виклику. Очікувана відповідь: приймач + тунель для доступу ззовні + полінг замість очікування наосліп + перевірка підпису й ідемпотентності на ретраях.
- «Як перевірити, що застосунок надіслав правильний лист?» Перевіряють, чи знаєте ви про фейкові SMTP. Слабка відповідь — «глянув пошту руками»; сильна — фейковий приймач (MailHog/Mailtrap), асерти на вміст через API й перехід по лінку з листа.
- «Твої тести з моками зелені — чому це не гарантія?» Питання на зрілість. Правильний хід — назвати schema drift і пояснити, що страхує від нього контрактне тестування.
Джерела
- ISTQB Glossary — терміни test double, stub, mock (усталена таксономія дублерів).
- WireMock — офіційна документація — standalone мок-сервер, стаби, сценарії, запис трафіку.
- Mock Service Worker (msw) — документація — перехоплення на рівні мережі в браузері й Node.
- Prism — репозиторій Stoplight — мок і валідація з OpenAPI-специфікації.
- MailHog та Mailtrap — приймачі пошти для тестування email-флоу.
Навіщо взагалі мокати зовнішні залежності в API-тестах?
Щоб отримати контроль над тим, чого ви не контролюєте. Реальний сервіс ходить у платіжний шлюз, чужий API курсів, сторонній SMTP — і кожна така залежність приносить свої болячки: вона повільна, її пісочниця може лежати всі вихідні, кожен виклик коштує грошей, а змусити її повернути 503 чи таймаут на вимогу майже нереально. Мок замінює справжню залежність дублером, що відповідає миттєво і щоразу однаково — рівно тим, що ви йому задали. Це дає чотири речі: детермінованість (та сама відповідь на кожному прогоні), швидкість (мілісекунди замість мережевого походу), крайові випадки на замовлення (500, розрив зʼєднання, 429 одним рядком конфігу) та ізоляцію падінь (чужа пісочниця лягла — ваша сюїта лишається зеленою). Сильна відповідь одразу називає й ціну: мок — це знімкова копія чужої поведінки, і вона старіє, тож ризик розсинхрону з реальністю нікуди не дівається.
Чим стаб відрізняється від мока?
Стаб (stub) — «магнітофон»: підсовує наперед записану відповідь і ні про що не питає. Мок (mock) робить те саме плюс верифікує саму взаємодію: чи стався виклик, яким методом, з яким тілом і скільки разів. Тобто ключова різниця — у перевірці взаємодії: дублером, який асертить сам факт і форму виклику, є саме мок, а не стаб. На межі HTTP у побуті слово «мок» уживають ширше, ніж у строгій таксономії дублерів (dummy / stub / spy / mock / fake), тож на співбесіді варто показати, що ви розрізняєте побутове і точне значення. Практичний наслідок: якщо тест лише хоче підсунути відповідь — це стаб; якщо він ще й падає, коли виклику не було або він був не таким, — це вже мок.
Що таке мок-сервер і чим він відрізняється від стаба в коді?
Мок-сервер (mock server) — окремий HTTP-процес, який прикидається цілою зовнішньою системою: слухає власні ендпоінти й відповідає тим, що ви в нього заклали. На відміну від стаба всередині процесу (як nock), він живе окремим процесом за власним URL, і ваш сервіс дивиться на нього через змінену базову адресу. Головна перевага — мово- і процес-незалежність: мок-сервер годиться, навіть коли сам сервіс написаний не на Node, а на Go чи Java, бо спілкування йде по HTTP, а не через внутрішні хуки рантайму. Приклади — WireMock і Prism. Такий підхід ближчий до продової топології (реальний сервіс теж ходить у зовнішній хост за URL), тому й реалістичніший, хоч і трохи дорожчий у налаштуванні.
Де саме можна перехопити виклик до зовнішньої залежності?
Головне питання не «яким інструментом», а «де перехопити», і принципових точок три. Перша — у тому ж процесі: інструмент типу nock вклинюється в мережевий шар самого процесу Node і ловить вихідні запити, перш ніж вони підуть у мережу; найдешевше, але бачить лише свій процес. Друга — окремий мок-сервер за власним URL: WireMock чи Prism піднімаються процесом-сервером, а сервіс дивиться на нього через змінену базову адресу (PAYMENTS_URL=http://localhost:8080); мово-незалежно й ближче до продакшену. Третя — на рівні мережі клієнта: msw перехоплює запити близько до джерела (у браузері через Service Worker, у Node через перехоплювач), тож одні хендлери працюють і на фронті, і на бекенді. Вирішальне питання одне: дублер сидить у тому самому процесі, що робить виклик, чи стоїть окремим сервером, куди сервіс ходить за URL — перший варіант простіший у налаштуванні, другий універсальніший.
Коли обирати nock, а коли WireMock?
nock — коли і код, що робить виклик, і тест живуть в одному процесі Node: він перехоплює вихідні HTTP-запити цього процесу декларативно («на GET /rate поверни 200 з таким тілом»). Це найдешевший спосіб, але його межа жорстка: якщо ваш сервіс запущений окремим процесом, nock до його вихідних викликів просто не дотягнеться, бо бачить лише власний процес. WireMock — коли сервіс не на Node або коли потрібна прод-подібна топологія: це повноцінний окремий HTTP-сервер, на який сервіс дивиться за URL, тож мова реалізації сервіса не має значення. Практичне правило: юніт-рівень з викликами в тому ж рантаймі — nock; чорноскринькове тестування запущеного сервіса, який ходить у зовнішній хост, — мок-сервер за URL.
Що таке msw і чим він цінний?
msw (Mock Service Worker) перехоплює запити на рівні мережі клієнта: у браузері — через Service Worker, у Node — через перехоплювач запитів. Його головна цінність — переносність: один набір хендлерів працює в обох середовищах, тож ті самі моки використовують і фронтенд-тести, і бекенд-тести, без дублювання опису. Ви описуєте хендлер один раз (http.get(...) повертає HttpResponse.json(...)), а де він виконається — у Service Worker чи в Node — залежить лише від оточення. Це прибирає класичну розбіжність, коли фронт і бек мокають той самий ендпоінт по-різному й розповзаються. Обирають msw саме тоді, коли потрібні спільні моки для фронта й бекенда.
Що дає підняття мока прямо з OpenAPI-специфікації, і як це робить Prism?
Ручні стаби мають спільну ваду: вони відбивають лише уявлення автора тесту про те, як поводиться залежність, а не її реальну поведінку. Prism (від Stoplight) прибирає цю ваду: команді prism mock openapi.yaml досить самої специфікації, щоб підняти HTTP-сервер, — на кожен ендпоінт зі spec він відповість або прикладом (example) звідти ж, або значенням, згенерованим за схемою. Головна перевага — такий мок породжений джерелом істини, а не фантазією автора тесту: щойно специфікація змінилась, разом із нею змінюється й поведінка мока. Додатковий режим — проксі-валідатор: Prism пропускає трафік до справжнього бекенда й звіряє обидві сторони обміну зі специфікацією, повідомляючи про розбіжності — фактично полегшена форма контрактної перевірки.
Мок з Prism віддав валідну за схемою відповідь — чому цього може бути замало?
Бо валідна за схемою відповідь — не те саме, що семантично правильна. Мок перевіряє форму, а не бізнес-сенс: у поле типу string він покладе перший-ліпший рядок, у числове — довільне число в межах формату, без огляду на те, чи має воно сенс у вашій логіці. Тому логіку, яка залежить від конкретних значень (наприклад, «якщо курс більший за поріг — заблокувати оплату»), на такому моку не протестуєш: він не гарантує, що поверне саме те значення, яке тригерить вашу гілку. Prism добрий для перевірки, що ваш код коректно розбирає структуру відповіді й не падає на очікуваних полях; для перевірки поведінки на конкретних даних потрібен стаб із заданим значенням, а не згенерований за схемою. Коротко: Prism страхує від розсинхрону форми, але не заміняє тести бізнес-семантики.
Що таке компонентне тестування залежностей і чим воно відрізняється від юніт- і e2e-тестів?
Компонентне тестування (component testing) у мікросервісному сенсі — це коли ви замінюєте моками всі зовнішні залежності сервіса й ганяєте його через власний публічний API. Сам сервіс справжній, запущений, з реальною серіалізацією, роутингом і обробкою помилок; усе, що за його межами (база, платежі, пошта, чужі API), — дублери. Це проміжний рівень між юніт-тестами (де мокають майже все, включно з внутрішніми модулями) і наскрізними e2e (де все справжнє). Такі тести швидкі й детерміновані, бо не залежать від чужих сервісів, але перевіряють сервіс цілком, а не окремий клас. Пастка термінології: у словнику ISTQB «component testing» — це рівень окремих модулів (юніт), тож на співбесіді варто уточнити, у якому сенсі йдеться про компонент.
Як протестувати вебхук?
Вебхук (webhook) перевертає напрям виклику: не ваш код стукає в чужий сервіс, а зовнішня система сама шле HTTP-запит на ваш URL, коли стається подія. Тому потрібен не клієнт, а приймач — ендпоінт, який зловить запит і збереже його вміст для перевірок (крихітний сервер, що складає прийняте в памʼять чи файл). Дві проблеми роблять це незручним: приймач крутиться на localhost, а провайдеру треба достукатись із інтернету; і вебхук приходить не в мить дії, а колись потім, тобто асинхронно. Першу розвʼязує тунель, другу — полінг із таймаутом. Повний рецепт: підняти приймач → відкрити тунель і зареєструвати публічний URL у провайдера → зробити дію, що породить подію → опитувати приймач до розумного дедлайну, поки вебхук не прийде → заасертити payload. І не забути про підпис та ідемпотентність на ретраях.
Навіщо для тестування вебхука потрібен тунель?
Бо ваш приймач крутиться на localhost, а зовнішній провайдер має достукатись до нього з інтернету — напряму він до вашого ноутбука за NAT не дотягнеться. Тунель (tunnel) — сервіс на кшталт ngrok, localtunnel чи cloudflared — дає публічний URL і проксує запити з нього на ваш локальний порт. Ви реєструєте цей публічний URL у провайдера як адресу вебхука, і його виклики доходять до приймача за ноутбуком. Практичний наслідок для дебагу: якщо «вебхук не прийшов», перша підозра — саме тунель (публічний URL протух або сесія тунеля впала), і дивитись треба логи тунеля, перш ніж винити свій код.
Чому для очікування вебхука не можна ставити sleep, і що замість нього?
Бо вебхук асинхронний: він приходить колись потім, і ви не знаєте коли. sleep навмання або флакить (заснули на 2 секунди, а вебхук прийшов на 3-й), або марно гальмує сюїту (заснули на 10, а він прийшов на першій). Правильно — полінг із таймаутом: тест періодично питає приймач, чи прийшов очікуваний вебхук, до розумного дедлайну, і завершується, щойно той зʼявився. Це та сама дисципліна очікувань, що й для eventual consistency в даних: чекаємо на умову, а не на фіксований час. У Playwright це expect.poll(...) з timeout, а не page.waitForTimeout.
Про які два аспекти реальних вебхуків часто забувають?
Про підпис і про ретраї. По-перше, провайдери зазвичай підписують payload — кладуть HMAC-підпис у заголовок, — і ваш приймач має цей підпис перевіряти; якщо тест приймає що завгодно, ви пропустите баг верифікації підпису, а це діра в безпеці. По-друге, провайдери зазвичай ретраять доставку, якщо ви не відповіли 2xx, тож один і той самий вебхук може прийти двічі, і ваш обробник має бути ідемпотентним — повторна доставка не повинна створювати дубль замовлення чи задвоювати нарахування. Обидва аспекти легко забути, коли приймач у тесті тупо складає все підряд і повертає 200. Тому в діагностиці «вебхук не спрацював» треба розрізняти: тунель протух, приймач відкинув запит через невірний HMAC, чи провайдер ще ретраїть після вашого не-2xx.
Як перевірити, що застосунок надіслав правильний лист?
Через фейковий SMTP-сервер, що ловить усю пошту й не пускає її далі — слати на справжні адреси не можна, це спам і витік. У MailHog три входи: SMTP-порт, куди застосунок шле пошту, веб-інтерфейс для очей і HTTP API для тестів (проєкт давно не розвивається — де-факто його змінив Mailpit); Mailtrap робить те саме як хмарний сервіс. Застосунок просто спрямовують на цей SMTP замість продового, а далі сценарій: виконати дію → полінгом дочекатися листа через API → перевірити адресата, тему й тіло → дістати з тіла посилання і пройти по ньому. Слабка відповідь на співбесіді — «глянув пошту руками»; сильна — фейковий приймач плюс асерти на вміст через API й перехід по лінку.
На яких трьох нюансах спотикаються при тестуванні email-флоу?
Перший — багаточастинність (multipart): реальні листи майже завжди мають текстову і HTML-версію, і посилання з них можуть відрізнятись, тож цільтеся в ту частину, якою користуватиметься людина, а не в першу-ліпшу. Другий — трекінг-редірект: лінк у листі часто загорнутий у редірект поштового провайдера для аналітики, тож у тесті переходьте по ньому як браузер, а не парсьте кінцеву адресу наївно. Третій — абсолютне посилання на конкретне середовище: лінк нерідко веде на https://app.prod..., і якщо тест крутиться на стейджі, перехід поведе вас не туди; переконайтесь, що посилання вказує на те середовище, де йде тест. Усі три виглядають як «нестабільний лист», а насправді це про неправильну MIME-частину, наївний парсинг URL і невідповідність середовищ.
Що таке розсинхрон схеми (schema drift) і чому це системна вада мокання?
Мок фіксує чужу поведінку станом на день, коли його написали, — і сам далі не оновлюється. Залежність тим часом живе своїм життям: поле перейменували, помилка тепер 422 замість 400, зʼявився новий обовʼязковий заголовок — а мок нічого цього не знає й відповідає по-старому. Тести зелені, бо перевіряють згоду вашого коду із застарілим моком, а не з реальним сервісом; це і є schema drift. Вада системна тому, що вона тим підступніша, чим краще працює мокання: що надійніша ізоляція від реальної залежності, то легше не помітити, що світ поїхав. Саме тому зелена сюїта з моками — не гарантія: вона може означати лише, що ваш код і ваш застарілий мок досі згодні між собою.
Твої тести з моками зелені — чому це не гарантія, і що страхує від самообману?
Бо зелений мок-тест доводить лише узгодженість вашого коду з вашим уявленням про залежність, а не з тим, що залежність реально віддає сьогодні. Якщо між написанням мока й прогоном стався schema drift, тести проходять, а прод падає. Тут мок передає естафету контрактному тестуванню: контрактні тести перевіряють, що ваше уявлення про залежність (той самий мок) досі збігається з тим, що вона віддає насправді, і сигналять, коли контракт поїхав. Тобто мок дає швидкість та ізоляцію, а контракт страхує від того, що ця ізоляція перетворилась на самообман. Механіку — consumer-driven contracts, Pact, валідацію проти OpenAPI — розбирає окрема тема, але на співбесіді достатньо назвати звʼязку «мок для швидкості + контракт проти дрейфу».
Замокав власний сервіс, який мав перевіряти, і тести зелені. Що не так?
Ви замокали обʼєкт перевірки замість залежностей навколо нього — і тепер асертите поведінку заглушки, а не продукту. Мокати треба те, що за межами сервіса (чужі API, платежі, пошта, база), а сам сервіс у компонентному тесті лишається справжнім, запущеним. Симптом «виглядає як тест фічі, а насправді — тест мока»: тест зелений завжди, бо перевіряє відповідь, яку сам же й задав. Це нічого не гарантує про реальний код сервіса — його логіка, серіалізація й роутинг узагалі не виконуються. Правило просте: дублером замінюють залежності, а не SUT (system under test).
Коли краще НЕ мокати залежність?
Коли обʼєкт перевірки — саме інтеграція з цією залежністю або її реальна поведінка. Якщо мета тесту — переконатись, що ваш код правильно розмовляє з конкретним зовнішнім API на реальних даних, мок лише сховає розбіжності: ви протестуєте згоду з власною вигадкою, а не з сервісом. Так само не варто мокати те, що дешево й детерміновано підняти по-справжньому (локальна база в контейнері часто краща за мок бази). І памʼятайте про баланс рівнів: що більше ви мокаєте, то більший ризик schema drift, тож наскрізні e2e з реальними залежностями та контрактні тести лишаються потрібними — вони ловлять те, що мок-тести за побудовою пропускають. Мок — інструмент ізоляції й швидкості, а не універсальна заміна реальності.
Три кейси, де мокання вирішує, зелений тест чи полювання за чужим аптаймом: форсування крайового випадку від зовнішнього API через nock (і пастка матчера), приймач вебхука з тунелем, полінгом, перевіркою HMAC-підпису та ідемпотентності, і таблиця рішень «що можна перевірити на Prism-моку зі специфікації, а що ні». Скрізь — що дивитися і чому.
Кейс 1. nock: форсуємо 503 від чужого API і ловимо пастку матчера
Ваш сервіс бере курс валют із зовнішнього API й має коректно поводитись, коли той віддає 503 — наприклад, віддати кешований курс, а не впасти. Справжній сервіс майже неможливо змусити повернути 503 на замовлення, а nock робить це одним описом. Тест і код виклику тут в одному процесі Node, тож перехоплення в процесі — найдешевший варіант.
import { test, expect } from '@playwright/test';
import nock from 'nock';
import { getRateWithFallback } from '../src/rates';
test.afterEach(() => {
nock.cleanAll(); // інакше стаб переживе тест і зловить чужий запит
});
test('на 503 від провайдера сервіс віддає кешований курс', async () => {
const scope = nock('https://api.rates.io')
.get('/v1/usd')
.reply(503, { error: 'service_unavailable' });
const rate = await getRateWithFallback('usd');
expect(rate.source).toBe('cache'); // не впав, пішов у fallback
expect(scope.isDone()).toBe(true); // виклик реально стався
});
scope.isDone() — це вже мок, а не просто стаб: він падає, якщо описаний виклик так і не стався. Тепер типова пастка. Уявіть, що код додає до запиту query-параметр версії — /v1/usd?api=2 — а матчер описаний як голий /v1/usd:
// НЕ зловить реальний запит із ?api=2 — шляхи не збіглися
nock('https://api.rates.io').get('/v1/usd').reply(503);
// правильно: матчимо і query
nock('https://api.rates.io').get('/v1/usd').query({ api: '2' }).reply(503);
Що дивитися і чому:
nockбачить лише свій процес. Якщо сервіс під тестом запущений окремим процесом (а не імпортований у тест), його вихідні виклики повзnockпройдуть у реальну мережу — тоді потрібен мок-сервер за URL, а неnock.- Занадто вузький матчер не ловить потрібний запит, занадто широкий ловить будь-що. Симптом «мок не спрацював» майже завжди означає, що матчер описав не той шлях, метод, query чи заголовок — звіряйте очікуваний запит із фактичним, а не вважайте
nockзламаним. nock.cleanAll()у teardown обовʼязковий. Невичищений інтерсептор переживе тест і або перехопить чужий запит у наступному кейсі, або лишиться «зайвим» і замаскує баг.nockяк побічна перевірка контракту. Якщо реальний запит не збігся з описаним — тест червоніє, і це безкоштовно ловить зміну форми вихідного виклику.
Кейс 2. Вебхук: приймач, тунель, полінг, підпис і ретрай
Треба перевірити, що після оплати провайдер шле вебхук payment.succeeded, а ваш обробник його коректно приймає. Виклик іде у зворотний бік — не ви стукаєте, а провайдер стукає до вас, — тож потрібен приймач, доступний ззовні через тунель, і полінг, бо подія асинхронна.
Приймач складає у памʼять тільки те, що пройшло перевірку HMAC-підпису, і памʼятає вже оброблені id, щоб ретрай не задвоївся:
import http from 'node:http';
import crypto from 'node:crypto';
const received: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
const SECRET = process.env.WEBHOOK_SECRET ?? '';
function validSignature(raw: string, header: string | undefined): boolean {
const expected = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
if (header == null || header.length !== expected.length) return false; // timingSafeEqual кидає на різній довжині
return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => (raw += chunk));
req.on('end', () => {
if (!validSignature(raw, req.headers['x-signature'] as string)) {
res.writeHead(401).end(); // невірний підпис — не приймаємо
return;
}
const event = JSON.parse(raw);
if (!seen.has(event.id)) { // ідемпотентність проти ретраю
seen.add(event.id);
received.push(event);
}
res.writeHead(200).end(); // 2xx, інакше провайдер ретраятиме
});
}).listen(3001);
export { received };
Публічний URL дає тунель — його реєструють у провайдера як адресу вебхука:
ngrok http 3001 # публічний https-URL → проксі на localhost:3001
Тест робить дію й опитує приймач полінгом до дедлайну, а не спить навмання:
import { test, expect } from '@playwright/test';
import { received } from '../receiver';
import { pay } from '../src/payments';
test('успішна оплата породжує вебхук payment.succeeded', async () => {
const orderId = await pay({ amount: 100 }); // дія, що згодом породить подію
await expect.poll(
() => received.find((e) => e.orderId === orderId)?.type,
{ timeout: 15_000, message: 'вебхук так і не прийшов' },
).toBe('payment.succeeded');
});
Що дивитися і чому:
- «Вебхук не прийшов» — спершу тунель, потім свій код. Публічний URL тунеля протухає між сесіями; якщо провайдер шле в мертву адресу, ваш приймач ні до чого. Дивіться логи тунеля й панель провайдера.
- Приймач мовчки відкидає невірний HMAC. Якщо приймач приймає що завгодно, тест зелений навіть тоді, коли верифікація підпису в проді зламана — це діра в безпеці, яку саме приймач і має підсвітити.
- Ретрай робить доставку «хоча б раз», а не «рівно раз». Провайдер повторює на будь-який не-
2xx, тож один вебхук може прийти двічі;seen-перевірка моделює вимогу ідемпотентності обробника. - Полінг замість
sleep.expect.pollзавершується, щойно подія зʼявилась, і падає з осмисленим повідомленням на таймауті — фіксованийwaitForTimeoutабо флакив би, або марно гальмував сюїту.
Кейс 3. Prism зі специфікації: що мок перевіряє, а що ні
У залежності є OpenAPI-специфікація, тож замість ручних стабів піднімаємо мок прямо з неї — він виведений із джерела істини й оновлюється разом зі специфікацією:
prism mock openapi.yaml # HTTP-сервер, що на кожен ендпоінт віддає відповідь за схемою
Тест перевіряє, що ваш клієнт коректно розбирає структуру відповіді:
import { test, expect } from '@playwright/test';
test('клієнт розбирає відповідь /users/:id за схемою специфікації', async ({ request }) => {
const res = await request.get('http://localhost:4010/users/42');
expect(res.status()).toBe(200);
const user = await res.json();
// форма гарантована схемою: поля й типи на місці
expect(typeof user.id).toBe('number');
expect(typeof user.email).toBe('string');
});
Ключова межа — валідна за схемою відповідь не дорівнює семантично правильній. Що на такому моку перевіряється, а що ні:
| Перевірка | Prism-мок зі схеми | Чому |
|---|---|---|
| Клієнт не падає на очікуваній структурі відповіді | так | форму гарантує схема — поля й типи на місці |
| Обовʼязкові поля присутні, типи збігаються | так | мок віддає лише те, що валідне за специфікацією |
| Клієнт коректно шле запит потрібної форми (проксі-режим) | так | режим проксі-валідатора лається на розбіжність запиту зі схемою |
«Якщо balance більший за поріг — блокувати оплату» | ні | мок віддасть будь-яке число в межах формату, не конкретне ваше |
| Точне повідомлення помилки чи бізнес-код у тілі | ні | мок перевіряє тип рядка, а не його зміст |
Що дивитися і чому:
- Мок оновлюється зі специфікацією. Змінили
openapi.yaml— змінився й мок; ручний стаб довелось би правити окремо, і він відстав би тихо. - Форма — так, семантика — ні. Для гілок логіки, що залежать від конкретних значень, потрібен стаб із заданим значенням, а не згенерований за схемою: Prism не гарантує, що поверне саме те число, яке тригерить вашу гілку.
- Проксі-режим — місток до контрактів. Пропускаючи запити до реального бекенда й звіряючи їх зі специфікацією, Prism ловить розсинхрон схеми ще до того, як він стане зеленим самообманом у мок-тестах.
Навіщо мокати і словник дублерів
- Можу назвати чотири причини мокати на рівні сервісів (детермінованість, швидкість, крайові випадки на замовлення, ізоляція від чужого аптайму й коштів) і одразу — ціну: розсинхрон із реальністю.
- Знаю різницю стаб vs мок: стаб віддає заготовлену відповідь і нічого не перевіряє, мок ще й верифікує факт і форму виклику.
- Розумію, що мок-сервер — це окремий HTTP-процес за URL, а не стаб усередині мого процесу.
Де перехопити виклик: три точки й інструменти
- Можу назвати три точки перехоплення (у процесі
nock, окремий мок-сервер за URL — WireMock/Prism, рівень мережі клієнтаmsw) і головну вісь: усередині процесу чи окремий сервер за URL. - Знаю межу
nock(бачить лише свій процес Node) і коли натомість потрібен WireMock (сервіс не на Node, потрібна прод-подібна топологія). - Розумію цінність
msw: одні хендлери переносні між браузером (Service Worker) і Node, тож моки спільні для фронта й бекенда.
Mock зі специфікації та компонентне тестування
- Можу пояснити, що
prism mock openapi.yamlвиводить мок із джерела істини, а не зі здогадів автора тесту (плюс режим проксі-валідатора — місток до контрактів). - Розумію ключову пастку Prism: валідна за схемою відповідь — не те саме, що семантично правильна; форму перевіряє, бізнес-сенс — ні.
- Розумію, що компонентне тестування у мікросервісному сенсі — це реальний сервіс + моки всіх зовнішніх залежностей; пастка: в ISTQB «component testing» означає юніт-рівень.
Вебхуки
- Розумію, що вебхук перевертає напрям: не мій код стукає, а зовнішня система шле запит на мій URL — тож потрібен приймач, що зберігає payload.
- Знаю рецепт: приймач + тунель для доступу ззовні + полінг із таймаутом замість
sleep. - Памʼятаю про підпис (HMAC у заголовку — приймач має перевіряти) і ретраї (при не-
2xxвебхук приходить повторно, тож обробник має бути ідемпотентним).
Email-флоу
- Знаю, що email ловлять фейковим SMTP (MailHog / Mailpit / Mailtrap), і можу описати флоу: тригер → полінг API приймача → асерт адресата/теми/тіла → перехід по лінку.
- Памʼятаю про multipart (текстова і HTML-версія, посилання можуть різнитись) і трекінг-редірект (переходжу по лінку як браузер, не парсю наївно).
- Перевіряю, що абсолютне посилання з листа веде на середовище тесту, а не на чужий
prod.
Розсинхрон і контракти
- Можу пояснити schema drift: мок — знімкова копія, залежність поїхала, а мок далі бреше по-старому.
- Розумію, чому зелена мок-сюїта не гарантія (доводить згоду коду з моком, а не з реальним сервісом) і що страхує — контрактне тестування (Pact, валідація проти OpenAPI).
- Розрізняю «тест фічі» і «тест мока»: мокають залежності навколо SUT, а не сам обʼєкт перевірки.
Навіщо мокають зовнішні залежності на рівні сервісів?
Питання
Навіщо мокати залежності на рівні сервісів?