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

    06 · Автоматизація: стратегія

    Тестові дублери: stub, mock, fake, spy

    Зміст

    Автотест рідко перевіряє код у вакуумі: майже завжди навколо перевіреного шматка є залежності (dependencies) — база даних, платіжний шлюз, поштовий сервіс, чуже API. І кожна така залежність тягне за собою три біди: вона повільна, вона недетермінована, і вона має справжні побічні ефекти. Реальний платіж списує реальні гроші, реальна пошта летить реальному користувачу, а сторонній сервіс сьогодні відповідає за 200 мілісекунд, а завтра лягає — і твій зелений тест раптом червоніє не через баг застосунку. Щоб перевіряти логіку, а не чужу інфраструктуру, залежність підміняють підставним об'єктом — тестовим дублером (test double). Назва — з кіно: дублер (stunt double) робить трюк замість актора, а глядач бачить актора.

    Ця глава — канонічний довідник таксономії дублерів для всього сайту: інші розділи посилаються сюди по означення stub, mock, spy і fake. При першому проході її спокійно можна пропустити й повернутися пізніше — вона щільно спирається на піраміду тестування й анатомію автотесту, тож має сенс читати її, коли ті вже вкладені в голову. Головна складність теми не технічна, а термінологічна: слова mock і stub вживають як синоніми навіть досвідчені інженери, а на співбесіді саме різницю між ними й питають.

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

    Дублер розв'язує чотири проблеми одразу:

    • Швидкість. Виклик у пам'яті замість походу в мережу чи БД — мілісекунди замість секунд.
    • Детермінованість. Дублер повертає те саме щоразу; зовнішній сервіс — ні. Це прямий засіб проти флакі-тестів.
    • Досяжність крайових випадків. Помилку 500, порожній список, таймаут, «картку відхилено» легко змоделювати дублером і майже неможливо — реальним сервісом на вимогу.
    • Відсутність побічних ефектів. Жодного реального списання, жодного реального листа.

    Але за ізоляцію платимо. Що більше ми підмінили, то далі тест від реальності — і то більший ризик, що дублер розійдеться зі справжньою залежністю (до цього повернемось у розділі про контрактні тести). Тому дублер — не безкоштовний і не завжди правильний вибір: це компроміс між достовірністю й контролем.

    Таксономія: dummy, stub, spy, mock, fake

    Канонічний поділ увів Джерард Мезарош (Gerard Meszaros) у книзі «xUnit Test Patterns». «Test double» — це парасольковий термін, а під ним п'ять різновидів. Розрізняє їх не синтаксис, а роль у тесті: одні дають системі під тестом (system under test, SUT) вхідні дані, інші існують, щоб перевірити її вихід, а один взагалі нічого не робить.

    • Dummy — об'єкт-заглушка, який передають лише щоб заповнити параметр; його ніколи по-справжньому не викликають. Типовий приклад — null чи порожній об'єкт, потрібний тільки для того, щоб сигнатура методу зійшлася.
    • Stub — повертає наперед задані («консервовані») відповіді на виклики. Це джерело керованого входу для SUT: стаб платіжного клієнта завжди каже declined, стаб репозиторія завжди повертає того самого користувача. Стаб байдужий до того, як його викликали, — він просто віддає своє.
    • Spy — це стаб, який ще й записує, як його викликали: скільки разів, з якими аргументами. Перевірку робиш ти сам після дії: «шпигун зафіксував рівно один виклик sendEmail з правильною адресою?».
    • Mock — об'єкт із вбудованими очікуваннями (expectations): він наперед знає, як його мають викликати, і якщо очікування не справдилось — тест валить сам, без окремого expect. По суті мок — це специфікація взаємодії, вшита в об'єкт.
    • Fake — має справжню, робочу реалізацію, але спрощену й непридатну для продакшену. Класика — база в пам'яті замість Postgres, in-memory репозиторій, підроблений SMTP-сервер. Fake реально «працює», просто зрізає кути (не персистить на диск, не масштабується).

    Test double
    підставний об'єкт

    Дають вхід SUT

    Перевіряють вихід SUT

    Не використовуються

    Stub:
    консервована відповідь

    Fake:
    спрощена робоча реалізація

    Spy:
    записує виклики

    Mock:
    очікування, сам фейлить

    Dummy:
    заповнює параметр

    Test double
    підставний об'єкт

    Дають вхід SUT

    Перевіряють вихід SUT

    Не використовуються

    Stub:
    консервована відповідь

    Fake:
    спрощена робоча реалізація

    Spy:
    записує виклики

    Mock:
    очікування, сам фейлить

    Dummy:
    заповнює параметр

    Важлива практична заувага: у світі JS/TS цю термінологію майже ніхто не тримає строго. Бібліотека sinon чесно розрізняє spy / stub / mock, а от jest.fn() і jest.mock() змішують усе під словом «мок». На рівні браузера page.route(...).fulfill(...) у Playwright — це, за таксономією, мережевий стаб, хоча всі кажуть «замокав запит». Тому на співбесіді корисно вміти назвати річ точно, навіть коли команда навколо зве її «моком».

    Mock проти stub коротко

    Це найчастіше питання теми, і відповідь тримається на одній осі — що саме ти перевіряєш:

    • Stub → перевірка стану (state verification). Ти годуєш SUT відповіддю й далі перевіряєш результат чи стан самої SUT. Якщо SUT з якоїсь причини взагалі не звернеться до стаба — тест усе одно пройде: стабу байдуже.
    • Mock → перевірка поведінки (behavior verification). Ти перевіряєш не результат, а сам факт взаємодії: «метод charge викликали рівно один раз із сумою 100?». Мок несе очікування в собі й падає, якщо виклику не сталося.
    АспектStubMock
    Що перевіряєСтан / результат SUTФакт і параметри виклику
    Хто робить перевіркуТи, після дії (expect)Мок сам, за вбудованим очікуванням
    Якщо виклику не сталосяТест зеленийТест червоний
    Тип відповідіДає вхід у SUTПеревіряє вихід SUT

    Практичне правило: стаб — коли важлива відповідь, що повертається; mock або spy — коли важливий сам факт виклику (тобто побічний ефект). Перевіряєш, що з відхиленої картки не створилось замовлення, — тобі потрібен стаб відповіді. Перевіряєш, що після реєстрації пішов лист, — тобі потрібен spy чи mock на відправнику.

    // Stub: годуємо SUT відповіддю, перевіряємо стан ПІСЛЯ
    const gateway = { charge: async () => ({ status: 'declined' }) };
    const result = await checkout(cart, gateway);
    expect(result.orderCreated).toBe(false); // перевірка стану SUT
    
    // Spy: записує виклики, перевірка — вручну після дії
    const sendEmail = sinon.spy();
    await register(user, sendEmail);
    expect(sendEmail.calledOnceWith(user.email)).toBe(true); // перевірка поведінки

    Дублери на рівнях піраміди

    Кількість і тип дублерів залежать від рівня, на якому ти тестуєш. Загальний закон простий: що вище піраміда, то менше дублерів і більше справжнього.

    • Unit. Дублерів найбільше: усе навколо перевіреної функції підмінене. Працюють на рівні коду, у процесі — jest, sinon, vitest. Тут живуть класичні stub/spy/mock у чистому вигляді.
    • Integration / component / API. Дублерів менше: справжню взаємодію лишаємо, підмінюємо лише зовнішні межі. Тут дублер — це вже не об'єкт у коді, а мережевий стаб: msw, nock, WireMock, mock-сервер зі специфікації OpenAPI. Детально механіку сервісного мокання розбирає розділ API-тестування.
    • E2E. Дублерів найменше: в ідеалі справжній фронтенд, справжній бекенд, справжня БД. Але навіть тут окремі межі підмінюють — через браузерне перехоплення мережі (перехоплення й мокання мережі), наприклад page.route у Playwright.

    Це прямо продовжує правило найнижчого рівня з глави про піраміду: логіку перевіряй якнайнижче з максимумом дублерів, а нагорі лиши мінімум перевірок на справжній системі.

    Реальний бекенд чи мок

    На рівні e2e є вічний холівар: ганяти тести проти справжнього бекенда чи проти замоканого. У кожного підходу своя ціна.

    Справжній бекендЗамокані відповіді
    ДостовірністьВисока: ловить інтеграційні й контрактні багиНизька: перевіряє «вигадану» відповідь
    Швидкість / стабільністьПовільніше, флакішеШвидко, детерміновано
    Крайові випадки (500, порожньо, повільно)Важко відтворитиФорсуються тривіально
    РизикТест залежить від стенду й данихТест може розійтися з реальним API

    Правильна відповідь — не «або-або», а шарами. Критичний наскрізний шлях (checkout, логін, оплата успіхом) варто хоч кількома тестами прогнати проти справжнього бекенда — інакше ти ніколи не зловиш баг інтеграції. А десятки варіацій відображення й обробки помилок дешевше й надійніше ганяти на замоканих відповідях, де кожен 500 чи порожній список даються одним рядком.

    Орієнтир, який рятує від крайнощів: мокай те, чого не контролюєш, і те, що не можеш зробити детермінованим; лишай справжнім те, що власне хочеш перевірити.

    Що мокають навіть в e2e: платежі, пошта, сторонні API

    Навіть у найчеснішому «повністю справжньому» e2e є речі, які підмінюють майже завжди — бо в них або незворотний побічний ефект, або чужа неконтрольована інфраструктура.

    • Платежі. Прогнати справжнє списання Visa на кожному прогоні CI неможливо. Використовують пісочницю провайдера (наприклад, тестові картки Stripe — 4242 4242 4242 4242 для успіху й окремі номери, що форсують відмову) або власний фейк. Сценарій «картку відхилено» — це просто стаб відповіді шлюзу.
    • Пошта й SMS. Реальні листи не шлють. Ставлять перехоплювач — MailHog чи Mailtrap: тест забирає останнє повідомлення, перевіряє його вміст і навіть витягує посилання підтвердження, щоб піти по ньому далі. По суті це fake-поштовик плюс spy на тому, що застосунок надіслав.
    • Сторонні API. Геолокація, погода, KYC-перевірки, аналітика, соціальний логін. Їхній аптайм, дані й ліміти запитів — не твої; якщо не підмінити, їхня нестабільність стає твоєю. Їх стаблять на мережевій межі.
    • Час і випадковість. Детермінований годинник (у Playwright — page.clock) і зерно (seed) для генератора випадкових чисел прибирають цілий клас флаку «тест падає лише опівночі».
    // Форсуємо «картку відхилено» — стаб відповіді платіжного шлюзу в e2e
    await page.route('**/api/payments', (route) =>
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ status: 'declined', reason: 'insufficient_funds' }),
      }),
    );

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

    Контрактні тести як страховка

    У мокання є одна підступна вада, про яку варто сказати окремо. Стаб — це заморожена копія того, як API відповідав колись. Бекенд перейменував поле status на state — а твій стаб і далі віддає старе status, тест лишається зеленим і бреше. Це називають розсинхроном схеми (schema drift), і чим більше моканих меж, то більший ризик, що зелена сюїта перевіряє вигадку.

    Страховка від цього — контрактне тестування (contract testing). Контракт — це зафіксована домовленість про форму взаємодії між споживачем (consumer) і постачальником (provider). Ідея consumer-driven contracts (наприклад, Pact): очікування споживача — по суті його стаб — записуються як контракт, а CI постачальника програє цей контракт проти справжнього сервісу й доводить, що той досі його дотримується. Полегшений варіант тієї самої ідеї — валідувати реальні відповіді проти схеми OpenAPI.

    Головна думка оглядово: контракт дає тобі швидкість моканих тестів без їхньої мовчазної брехні — щойно постачальник ламає форму, падає контрактний тест, а не прод. Механіку контрактів детально розбирає розділ API-тестування, а роль контрактів у розподіленій системі — глава тестування мікросервісів.

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

    • Виглядає як «замокав — зелено — значить, працює», а насправді ти протестував свій мок. Overmocking: юніт-тест, де підмінено все навколо, доводить лише те, що твої дублери викликались так, як ти сам їх налаштував. Логіки застосунку він при цьому може не перевіряти взагалі.
    • Виглядає як mock, а насправді stub. Люди звуть моком усе підряд, бо бібліотека зветься jest.mock. Якщо ти лише повертаєш консервовану відповідь і перевіряєш стан SUT — це стаб, хоч як його називає API бібліотеки.
    • Виглядає як «мок ловить регресію API», а насправді ні. Мок заморожений на старій формі відповіді; коли бекенд змінює контракт, тест лишається зеленим (drift). Ловить це не мок, а контрактний тест.
    • Виглядає як мок, а насправді нікчемний spy. Поставив дублер, задав очікування, але забув його перевірити — і виклику могло не статись. Мок без верифікації expectation безсенсовний: він мовчки пропускає баг.
    • Виглядає як «page.route зробив із e2e юніт-тест», а насправді ні. Мережевий стаб на межі не перетворює наскрізний тест на юніт — він лише прибирає одну неконтрольовану залежність; браузер, рендеринг і решта шляху лишаються справжніми.

    Підсумок

    • Test double — парасольковий термін; за Мезарошем п'ять різновидів: dummy, stub, spy, mock, fake. Розрізняє їх роль у тесті, а не синтаксис.
    • Головна вісь — stub проти mock: стаб годує SUT відповіддю (перевірка стану), mock і spy перевіряють сам факт виклику (перевірка поведінки).
    • Що вище піраміда, то менше дублерів і більше справжнього; на межах, які не контролюєш, дублер стоїть завжди.
    • Навіть у e2e підмінюють незворотні й непідконтрольні межі: платежі, пошту, сторонні API, час.
    • Мок може розійтися з реальністю (drift) — від цього страхують контрактні тести, а не самі моки.

    Що питають на співбесіді

    • «Чим mock відрізняється від stub?» — головне питання теми. Інтерв'юер хоче почути не завчену дефініцію, а вісь «перевірка стану проти перевірки поведінки»: стаб дає вхід, мок верифікує взаємодію й падає сам.
    • «Назви типи тестових дублерів.» — очікують п'ять за Мезарошем і бодай по фразі про кожен; плюс бонус, якщо чесно скажеш, що в JS-бібліотеках межі розмиті.
    • «Що б ти мокав в e2e, а що ні?» — перевіряють судження, а не список. Сильна відповідь: мокаю незворотне й непідконтрольне (платежі, пошта, сторонні API, час), лишаю справжнім те, що власне перевіряю.
    • «Чому погано мокати все?» — чекають слова overmocking і думки, що такий тест перевіряє власні моки, а не застосунок, і сліпий до drift.
    • «Як перевіриш сценарій "картку відхилено"?» — дивляться, чи розумієш, що негативний шлях зовнішнього сервісу форсують стабом відповіді або тестовою карткою пісочниці, а не «якось на реальному шлюзі».
    • «Що таке контрактний тест і навіщо?» — тут перевіряють, чи бачиш ти ризик розсинхрону моків із реальністю й чи знаєш, що контракт — це страховка від нього.

    Джерела

    • Gerard Meszaros. xUnit Test Patterns — Test Double (канонічна таксономія dummy / stub / spy / mock / fake).
    • Martin Fowler. Mocks Aren't Stubs — першоджерело поділу на перевірку стану й перевірку поведінки.
    • Playwright: Mock APIs — механіка мережевих стабів у e2e (page.route, fulfill).
    • Pact — consumer-driven contract testing — контрактні тести як страховка від drift.
    • Силабус ISTQB CTFL 4.0 (istqb.org) — стаби, драйвери й симулятори як елементи тестового оточення (test environment elements).