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

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

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

    Три точки, куди можна встромити мок

    Головне питання не «яким інструментом», а «де саме перехопити виклик». Від цього залежить усе інше. Є три принципові точки.

    1 в тому ж процесі

    2 окремий сервер за URL

    3 на рівні мережі

    Тест

    Ваш сервіс / SUT

    nock
    підміна http-шару Node

    WireMock / Prism
    standalone mock-сервер

    Браузер

    msw
    Service Worker або Node-перехоплювач

    Заготовлена відповідь

    1 в тому ж процесі

    2 окремий сервер за URL

    3 на рівні мережі

    Тест

    Ваш сервіс / SUT

    nock
    підміна http-шару Node

    WireMock / Prism
    standalone mock-сервер

    Браузер

    msw
    Service Worker або Node-перехоплювач

    Заготовлена відповідь

    У тому ж процесі. Інструмент на кшталт nock вклинюється у мережевий шар самого процесу Node і перехоплює вихідні HTTP-запити, перш ніж вони підуть у мережу. Це найдешевший спосіб, коли і тест, і код, що робить виклик, живуть в одному процесі Node. Мінус — він бачить лише свій процес: якщо ваш сервіс запущений окремо, nock до його вихідних викликів не дотягнеться.

    Окремий мок-сервер за власним URL. WireMock чи Prism піднімаються як окремий процес-сервер. Ваш сервіс дивиться на нього через змінену базову адресу: у тесті ви кажете PAYMENTS_URL=http://localhost:8080, і всі виклики платежів ідуть у мок замість продового шлюзу. Такий підхід мово- і процес-незалежний — годиться, навіть коли сам сервіс написаний не на Node, а на Go чи Java.

    На рівні мережі клієнта. msw (Mock Service Worker) перехоплює запити близько до їхнього джерела. «Рівень мережі» — це властивість браузерного режиму (Service Worker); у Node сторінка інтеграції MSW описує те саме обмеження, що й у nock: він патчить нативні модулі, які роблять запити (http, https), і бачить трафік поточного процесу. (Оглядова сторінка тих самих доків каже протилежне — що MSW використовує розширення класів замість патчингу модулів; яка з двох актуальна, з доків не встановлюється.) Головна ж цінність — один опис хендлерів працює в обох середовищах, тож ті самі моки використовують і фронтенд-тести, і бекенд.

    Вісь, яка все визначає: мок живе всередині процесу, що робить виклик, чи це окремий сервер, на який процес свідомо дивиться. Перше простіше налаштувати, друге — універсальніше й ближче до продової топології.

    Інструменти: 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 є й режим проксі-валідатора: він пропускає запити до реального бекенда й перевіряє, що і запит, і відповідь відповідають специфікації, лаючись на розбіжності. Це вже містком веде до .

    Пастка тут тонка: валідна за схемою відповідь — не те саме, що семантично правильна. За замовчуванням Prism використовує статичну стратегію генерації, а динамічну вмикає -d. І якщо у властивості немає ні example, ні default, вона не nullable і не має format, статичний мок поверне літеральний 'string' для рядка і 0 для числа. Тобто дефолтний мок якраз детермінований, а проблема не в непередбачуваності значень, а в їхній беззмістовності: логіку, яка залежить від конкретних значень, на такому моку не протестуєш.

    Компонентне тестування

    Коли ви замінюєте моками всі зовнішні залежності сервіса й ганяєте його через власний публічний API, це називають компонентним тестуванням (component testing) — у мікросервісному сенсі, як у статті про тестування мікросервісів на martinfowler.com. Термін оманливий: в ISTQB «component testing» означає натомість тестування окремих модулів (модульний рівень), тож тут ідеться про інший рівень. Ваш сервіс — справжній, запущений; усе, що за його межами (база, платежі, пошта, чужі API), — дублери.

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

    Вебхуки: локальний приймач і тунель

    Вебхук (webhook) перевертає напрям. Досі запити робив ваш код; тут зовнішня система сама шле HTTP-запит на ваш URL, коли стається подія: оплата пройшла, лист доставлено, підписку скасовано. Щоб таке перевірити, потрібен не клієнт, а — ендпоінт, який зловить запит і збереже його вміст для перевірок.

    Дві проблеми роблять вебхуки незручними. Перша: щоб зовнішній провайдер достукався до вас, ваш приймач має бути доступний з інтернету, а він крутиться на localhost. Друга: вебхук приходить не в мить дії, а колись потім, тобто асинхронно.

    Першу розв'язує (tunnel) — сервіс на кшталт ngrok, localtunnel чи cloudflared, що дає публічний URL і проксує запити на ваш локальний порт. Ви реєструєте цей URL у провайдера як адресу вебхука, і його виклики доходять до вашого приймача за ноутбуком.

    Локальний приймачТунельПровайдерТестЛокальний приймачТунельПровайдерТестОбробка, затримкаДія, що породить подіюPOST на публічний URLПроксі на localhostЗберегти payloadПолінг: вебхук уже прийшов?payload подіїЛокальний приймачТунельПровайдерТестЛокальний приймачТунельПровайдерТестОбробка, затримкаДія, що породить подіюPOST на публічний URLПроксі на localhostЗберегти payloadПолінг: вебхук уже прийшов?payload події

    Приймач може бути дуже простим — крихітний сервер, що складає прийняте у пам'ять чи файл, звідки тест його потім забирає:

    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

    Пошта — окремий випадок залежності, який ловлять окремими інструментами. Реєстрація, скидання пароля, нотифікації — усе це шле листи, і перевірити треба три речі: що лист узагалі відправлено, що його вміст правильний і що посилання з нього працює. Слати на справжні адреси не можна — це спам і витік.

    Розв'язання — -сервер, що ловить усю пошту й не пускає її далі. 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 віддав літеральний 'string' замість осмисленого значення; логіку на конкретних значеннях так не перевіриш.
    • Виглядає як тест фічі, а насправді — тест мока. Замокали власний сервіс, який мали перевіряти, і асертите поведінку стаба. Мокають залежності навколо, а не об'єкт перевірки.
    • Виглядає як стабільний лист, а насправді — не та MIME-частина. Асерт на текстову версію, а користувач бачить HTML (або навпаки), і посилання з них різні.

    Підсумок

    • Мокають, щоб отримати контроль: детермінованість, швидкість, edge cases й ізоляцію від чужого аптайму та коштів.
    • Ключове рішення — де перехопити виклик: у процесі (nock), окремим мок-сервером за URL (WireMock, Prism) чи на рівні мережі клієнта (msw).
    • Мок зі специфікації (Prism) виводиться з джерела істини й не бреше про форму — але за замовчуванням віддає статичні стаби ('string', 0) і бізнес-семантики не гарантує.
    • Вебхук перевіряють приймачем; для доступу ззовні потрібен тунель, а асинхронність беруть полінгом із таймаутом, не sleep-ом. Пам'ятайте про підпис і ретраї.
    • Email-флоу ловлять SMTP (MailHog, Mailtrap): асерт на адресата/тему/тіло й перехід по посиланню з листа.
    • Будь-який мок старіє. Швидкість мока страхує контрактне тестування — інакше зелена сюїта стає самообманом.

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

    • «Навіщо взагалі мокати залежності в тестах?» Інтерв'юер перевіряє, чи бачите ви компроміс, а не лозунг «мок = добре». Сильна відповідь називає детермінованість, швидкість, edge cases та ізоляцію — і одразу ж ціну: ризик розсинхрону з реальністю.
    • «Стаб проти мока — у чому різниця?» Тут ловлять на плутанині термінів. Стаб віддає заготовлену відповідь; мок ще й перевіряє сам факт і форму виклику. Дублером, який верифікує взаємодію, є саме мок.
    • «Як протестувати вебхук?» Дивляться, чи розумієте ви зворотний напрям виклику. Очікувана відповідь: приймач + тунель для доступу ззовні + полінг замість очікування наосліп + перевірка підпису й ідемпотентності на ретраях.
    • «Як перевірити, що застосунок надіслав правильний лист?» Перевіряють, чи знаєте ви про фейкові SMTP. Слабка відповідь — «глянув пошту руками»; сильна — фейковий приймач (MailHog/Mailtrap), асерти на вміст через API й перехід по лінку з листа.
    • «Твої тести з моками зелені — чому це не гарантія?» Питання на зрілість. Правильний хід — описати саме явище (мок — знімкова копія, залежність поїхала, а мок бреше по-старому) і пояснити, що страхує від нього контрактне тестування.

    Джерела

    Навіщо мокати на рівні сервісів

    • WireMock — Overview (офіційна документація) — мотиви названі офіційною докою: стабільне середовище, ізоляція від нестабільних третіх сторін і симуляція API, якого ще немає.
    • xUnit Test Patterns — Test Double — дублер зобовʼязаний повторити API, а не поведінку; при цьому має лишатися хоча б один тест без дублерів, бо з ними перевіряється інша конфігурація.
    • Martin Fowler — Mocks Aren't Stubs — словник: стаб дає заготовлені відповіді, а мок є специфікацією очікуваних викликів і перевіряє їх.

    Три точки, куди можна встромити мок

    Інструменти: nock, msw, WireMock

    Mock зі специфікації: Prism

    • Prism (Stoplight) — README репозиторіюprism mock піднімає локальний сервер з опису й додатково валідує запит; prism proxy шукає розбіжності реалізації зі специфікацією.
    • Prism (Stoplight) — Mocking guide — дефолт — статична стратегія (динамічну вмикає -d); пріоритет підстановки exampledefaultnull → осмислене за форматом, інакше літеральні 'string' і 0.
    • Learn OpenAPI — Introduction (OpenAPI Initiative) — приклади зі специфікації стають відповідями автозгенерованих мок-серверів — тестувати можна ще до готового бекенду.

    Компонентне тестування

    • Toby Clemson — Testing Strategies in a Microservice Architecture — у мікросервісному прочитанні компонент — сам сервіс, і його тестують в ізоляції з дублерами по краях; вигода названа конкретно — відтворюване відтворення помилкових сценаріїв.
    • ISTQB Glossary — канон означує «component testing» як рівень, зосереджений на окремих компонентах, — звідси й розходження термінів.

    Вебхуки: локальний приймач і тунель

    Email-флоу: MailHog і Mailtrap

    Розсинхрон мока з реальністю — міст до контрактів

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

    Пояснення

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

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

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