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 — через перехоплювач запитів. Один опис хендлерів працює в обох середовищах, тож ті самі моки використовують і фронтенд-тести, і бекенд.

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

    Інструменти: 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 у провайдера як адресу вебхука, і його виклики доходять до вашого приймача за ноутбуком.

    Локальний приймачТунельПровайдерТестЛокальний приймачТунельПровайдерТестОбробка, затримкаДія, що породить подію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

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

    Розв'язання — фейковий 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 і пояснити, що страхує від нього контрактне тестування.

    Джерела