Тестові дублери: 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 реально «працює», просто зрізає кути (не персистить на диск, не масштабується).
Важлива практична заувага: у світі 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?». Мок несе очікування в собі й падає, якщо виклику не сталося.
| Аспект | Stub | Mock |
|---|---|---|
| Що перевіряє | Стан / результат 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).
Що таке тестовий дублер і навіщо він у тесті?
Тестовий дублер (test double) — це підставний об'єкт, який стає на місце справжньої залежності (dependency) під час тесту. Проблема в тому, що реальні залежності — БД, платіжний шлюз, поштовий сервіс, чуже API — повільні, недетерміновані й мають незворотні побічні ефекти: справжній платіж списує гроші, справжній лист летить живому користувачу. Якщо тест ганяти через них, він червонітиме не через баг застосунку, а через те, що сторонній сервіс сьогодні впав або відповів повільно. Дублер прибирає цю залежність і дає натомість керований вхід: ми самі програмуємо, що він поверне, і перевіряємо, як застосунок це обробляє. Назва — з кінематографу: каскадер робить трюк замість актора, і глядач цього не помічає.
Які чотири проблеми розв'язує дублер?
Швидкість, детермінованість, досяжність крайових випадків і відсутність побічних ефектів. Виклик у пам'яті замість походу в мережу — це мілісекунди замість секунд, тож сюїта біжить швидше. Дублер завжди повертає те саме, тоді як живий сервіс — ні, і це прямий засіб проти флакі. Крайові випадки на кшталт відповіді 500, порожнього списку чи «картку відхилено» дублером моделюються одним рядком, а реальним сервісом на вимогу — майже ніяк. І нарешті, жодного реального списання чи реального листа: ми працюємо з підробкою. Розплата за все це — віддалення від реальності: що більше підмінено, то вищий ризик, що дублер розійдеться зі справжньою залежністю.
Назви типи тестових дублерів.
Канонічну п'ятірку ввів Джерард Мезарош (Gerard Meszaros): dummy, stub, spy, mock, fake. Dummy лише заповнює параметр у сигнатурі й ніколи по-справжньому не викликається — часто це null чи порожній об'єкт. Stub віддає наперед задані («консервовані») відповіді, слугуючи джерелом входу для системи під тестом. Spy — це той самий стаб, який додатково фіксує, як його викликали. Mock несе в собі очікування щодо викликів і сам валить тест, якщо вони не справдились. Fake має справжню, але спрощену реалізацію — наприклад, база в пам'яті замість Postgres. Розрізняє їх не синтаксис, а роль у тесті: одні дають вхід, інші перевіряють вихід, а dummy взагалі бездіяльний.
Чим stub відрізняється від mock?
Це головне питання теми, і вся різниця тримається на одному: що саме ти перевіряєш. Stub годує систему під тестом (system under test, SUT) відповіддю, а далі ти сам перевіряєш результат чи стан SUT — це перевірка стану (state verification). Якщо SUT з якоїсь причини навіть не звернеться до стаба, тест усе одно пройде, бо стабу байдуже, викликали його чи ні. Mock натомість перевіряє сам факт взаємодії: чи викликали потрібний метод, скільки разів, з якими аргументами — це перевірка поведінки (behavior verification). Очікування вшите в сам мок, тож він падає без окремого expect, якщо виклику не сталося. Коротко: стаб дає вхід, мок верифікує вихід і фейлить самостійно.
Що таке перевірка стану проти перевірки поведінки?
Це дві філософії того, як довести коректність. Перевірка стану дивиться на результат: викликали дію, а потім питаємо систему, у якому вона стані — замовлення не створилось, баланс не змінився, повернувся правильний об'єкт. Тут дублер потрібен лише щоб підсунути потрібний вхід, а роль судді бере на себе твій асерт. Перевірка поведінки дивиться не на результат, а на сам факт і параметри взаємодії: чи покликали charge рівно один раз із сумою 100, чи пішов лист після реєстрації. Тут суддя — сам дублер (mock) або твоя ручна перевірка над spy. Практичне правило: важлива відповідь, що повертається, — бери stub; важливий сам факт виклику (побічний ефект) — бери spy чи mock.
Чим spy відрізняється від stub?
Spy — це стаб із записником. Стаб просто віддає консервовану відповідь і йому байдуже, як його смикали. Spy теж може віддавати відповідь, але додатково запам'ятовує кожен виклик: скільки разів, з якими аргументами, у якому порядку. Ключова відмінність від мока — момент перевірки: над spy асерт пишеш ти сам після дії («шпигун зафіксував рівно один виклик sendEmail на правильну адресу?»), тоді як мок несе очікування в собі й перевіряє їх автоматично. Тому spy зручний, коли хочеш перевірити побічний ефект, але лишити перевірку явною й читабельною поруч із рештою асертів.
Що таке fake і чим він відрізняється від стаба?
Fake має справжню робочу реалізацію, просто спрощену й непридатну для продакшену. Стаб нічого не «робить» — він механічно повертає заготовлену відповідь на кожен виклик. Fake реально виконує логіку: in-memory база справді зберігає й віддає записи, підроблений SMTP-сервер справді приймає листи, тільки все це живе в пам'яті, не персистить на диск і не масштабується. Тому fake добре пасує там, де стаб задовбешся налаштовувати — коли SUT робить багато різних звернень до залежності й тобі потрібна поведінка, а не одна застигла відповідь. Класика: in-memory репозиторій замість Postgres у наборі тестів сервісу.
Як би ти перевірив сценарій «картку відхилено»?
Негативний шлях зовнішнього сервісу форсують дублером, а не сподіваннями на реальний шлюз. Проблема в тому, що справжній платіжний шлюз на тестову картку відповідає «успіх», тож змусити його руками сказати «declined» майже неможливо — сценарій просто недосяжний наживо. Рішення — стаб відповіді: програмуємо, що шлюз повертає declined, і перевіряємо, що застосунок правильно обробив відмову (замовлення не створене, користувач бачить помилку). На рівні e2e це роблять мережевим стабом — у Playwright через page.route з fulfill. Альтернатива на живому провайдері — його пісочниця з тестовими картками, де окремі номери навмисно форсують відмову. Слабка відповідь тут — «якось відтворити на реальному шлюзі»; сильна називає стаб або пісочницю.
Що б ти мокав в e2e, а що ні?
Мокаю те, що незворотне або непідконтрольне, і лишаю справжнім те, що власне хочу перевірити. Незворотне — це дії з ефектом у реальному світі: платежі (справжнє списання Visa на кожному прогоні CI неможливе), пошта й SMS (реальні листи живим людям не шлють). Непідконтрольне — чужа інфраструктура: геолокація, погода, KYC, аналітика, соціальний логін; їхній аптайм і ліміти не твої, і якщо не підмінити, їхня нестабільність стає твоєю. Окремо підміняють час і випадковість — детермінований годинник (у Playwright page.clock) і фіксоване зерно (seed) вбивають флак «падає лише опівночі». А фронтенд, бекенд, БД і власне бізнес-логіку в e2e лишають справжніми — інакше це вже не наскрізний тест. Це питання перевіряє судження, а не завчений список.
Чому погано мокати все?
Тому що тоді ти тестуєш власні моки, а не застосунок. Це називають overmocking: у юніт-тесті підмінено геть усе навколо перевіреної функції, і в результаті тест доводить рівно одне — що твої дублери викликались так, як ти сам їх налаштував. Логіку застосунку він при цьому може не перевіряти взагалі: пройде навіть тоді, коли реальна взаємодія давно зламана. Друга біда — сліпота до розсинхрону: моки заморожені на старій формі відповіді, тож коли бекенд змінює контракт, надмоканий тест лишається зеленим і мовчки пропускає регресію. Тому дублери — це компроміс, і зловживати ними означає купувати зелену сюїту ціною її достовірності.
Чому мок може розійтися з реальністю?
Бо стаб — це заморожена копія того, як API відповідав колись, а бекенд живе своїм життям. Класичний приклад: бекенд перейменував поле status на state, а твій стаб і далі віддає старе status; тест лишається зеленим і при цьому бреше — перевіряє форму, якої вже немає. Це називають розсинхроном схеми (schema drift), і що більше моканих меж у сюїті, то вищий ризик, що зелений набір тестів перевіряє вигадку. Небезпека саме в тихості: ніщо не падає, поки на проді реальний фронтенд не спіткнеться об нову форму відповіді. Ловить drift не сам мок — від нього рятує контрактне тестування.
Що таке контрактне тестування і навіщо?
Контракт — це зафіксована домовленість про форму взаємодії між споживачем (consumer) і постачальником (provider) API. Ідея consumer-driven contracts (наприклад, Pact): очікування споживача — по суті його стаб — записують як контракт, а CI постачальника програє цей контракт проти справжнього сервісу й доводить, що той досі його дотримується. Тобто щойно бекенд ламає форму відповіді, падає контрактний тест на боці постачальника, а не прод у користувача. Полегшений варіант тієї самої ідеї — валідувати реальні відповіді проти схеми OpenAPI. Головна цінність: контракт дає швидкість моканих тестів без їхньої мовчазної брехні, страхуючи саме від drift.
page.route у Playwright — це mock чи stub?
За таксономією Мезароша це мережевий стаб, хоча вся команда навколо каже «замокав запит». Причина проста: page.route(...).fulfill(...) лише підсовує наперед задану відповідь на певний запит — це керований вхід, тобто рівно роль стаба. Мок він не робить: сам факт виклику він не верифікує й тест автоматично не валить. Це ілюструє ширшу річ — у світі JS/TS термінологію майже ніхто не тримає строго: sinon чесно розрізняє spy/stub/mock, а jest.fn() і jest.mock() змішують усе під словом «мок». Тому на співбесіді корисно вміти назвати річ точно, навіть коли навколо звуть її «моком».
Скільки дублерів на кожному рівні піраміди?
Закон простий: що вище піраміда, то менше дублерів і більше справжнього. На unit-рівні дублерів найбільше — усе навколо перевіреної функції підмінене, і саме тут живуть класичні stub/spy/mock у чистому вигляді (jest, sinon, vitest). На інтеграційному й API-рівні дублерів менше: справжню взаємодію лишаємо, підмінюємо лише зовнішні межі, і дублер тут — уже не об'єкт у коді, а мережевий стаб (msw, nock, WireMock). На e2e дублерів найменше: в ідеалі все справжнє, але окремі межі однаково підміняють через браузерне перехоплення мережі. Це прямо продовжує правило найнижчого рівня: логіку перевіряй якнайнижче з максимумом дублерів, а нагорі лиши мінімум перевірок на живій системі.
Що не так із моком без верифікації очікування?
Він мовчки пропускає баг — тобто безсенсовний. Сенс мока в тому, що він несе очікування («метод має бути викликаний з такими аргументами») і сам падає, коли очікування не справдилось. Якщо ти поставив дублер, налаштував очікування, але забув його перевірити (чи використав spy й не написав над ним асерт), то виклику могло не статися взагалі, а тест усе одно зелений. Це один із класичних самообманів: здається, що перевірка поведінки є, а насправді її нема. Тому над spy обов'язковий явний асерт після дії, а для мока — виклик на кшталт verify(), який змусить його звірити очікування.
Навіщо підміняти час і випадковість у тестах?
Щоб прибрати цілий клас недетермінованих падінь. Код, що спирається на поточний час чи випадкове число, поводиться щоразу інакше: тест на «нагадування о 00:00» падає лише опівночі, а перевірка, зав'язана на Math.random(), зеленіє й червоніє через раз. Рішення — зробити ці джерела керованими: підставити детермінований годинник (у Playwright — page.clock, який дає виставити й прокрутити час) і зафіксувати зерно генератора випадкових чисел. Тоді сценарій «що станеться о півночі» перевіряється будь-коли й відтворювано. По суті час і випадковість — це теж неконтрольована залежність, просто не мережева, і ставляться до них так само: підмінити на дублер із передбачуваною поведінкою.
Три кейси, де таксономія перестає бути теорією: як на конкретному коді відрізнити стаб від мока за тим, що ти асертиш; як у e2e форсувати відмову платежу мережевим стабом і піймати відправлений лист через spy; і як заморожений стаб тихо бреше, поки його не зловить контрактний тест. Скрізь — що дивитися і чому.
Кейс 1. Той самий сервіс: де стаб, а де мок
Одна й та сама залежність — платіжний шлюз у процесі оформлення замовлення — обслуговує два різні тести. Різниця не в бібліотеці, а в тому, що ти перевіряєш після дії.
import { describe, it, expect, vi } from 'vitest';
import { checkout } from './checkout';
// Тест A — стаб: важлива відповідь, перевіряємо СТАН SUT
it('відхилена картка не створює замовлення', async () => {
// консервована відповідь; байдуже, як саме її покликали
const gateway = { charge: async () => ({ status: 'declined' }) };
const result = await checkout({ items: 1, total: 100 }, gateway);
expect(result.orderCreated).toBe(false); // судимо по стану результату
expect(result.userMessage).toMatch(/відхил/i);
});
// Тест B — spy/mock: важливий сам факт виклику, перевіряємо ПОВЕДІНКУ
it('успішна оплата списує рівно раз і на правильну суму', async () => {
const charge = vi.fn(async () => ({ status: 'approved' }));
await checkout({ items: 1, total: 100 }, { charge });
expect(charge).toHaveBeenCalledTimes(1); // факт і кількість
expect(charge).toHaveBeenCalledWith({ amount: 100 }); // аргументи виклику
});
Що дивитися і чому:
- У тесті A
gateway— стаб. Він лише віддаєdeclined, і асерт дивиться на стан результату (orderCreated), а не на те, чи взагалі покликалиcharge. Якбиcheckoutчерез раннійreturnне дійшов до шлюзу — цей тест усе одно зелений, бо стабу байдуже. - У тесті B той самий
chargeпрацює як spy. Тілоvi.fnзаписує кожен виклик, аtoHaveBeenCalledWithперевіряє поведінку — сам факт і параметри взаємодії. Тут пропущений виклик уже валить тест. - Бібліотека одна (
vi.fn), роль різна. Саме тому «це мок» — надто грубе слово: у тесті A ти робиш перевірку стану, у B — перевірку поведінки, і на співбесіді цінують уміння назвати різницю, а не інструмент.
Коли не певен, який дублер тобі потрібен, орієнтуйся на цю табличку:
| Питання до тесту | Потрібен | Чому |
|---|---|---|
| «Що поверне SUT, якщо залежність відповіла X?» | Stub | Керований вхід, далі перевірка стану |
| «Чи справді пішов побічний ефект (лист, списання)?» | Spy / Mock | Перевірка факту й параметрів виклику |
| «Залежність складна, потрібна робоча логіка» | Fake | In-memory реалізація замість купи стабів |
| «Параметр треба заповнити, але він не використовується» | Dummy | Тільки щоб зійшлася сигнатура |
Кейс 2. E2E: форсуємо відмову платежу і ловимо лист
На реальному шлюзі тестова картка відповідає «успіх», тож негативний сценарій наживо недосяжний. Підміняємо саму мережеву межу — це, за таксономією, стаб відповіді, хоча в команді скажуть «замокали платіж».
import { test, expect } from '@playwright/test';
test('відхилена картка: замовлення не створюється, показано помилку', async ({ page }) => {
// мережевий стаб: форсуємо declined на межі платежу
await page.route('**/api/payments', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'declined', reason: 'insufficient_funds' }),
}),
);
await page.goto('/checkout');
await page.getByTestId('pay-button').click();
// решта шляху справжня: фронтенд, рендеринг, обробка відповіді
await expect(page.getByRole('alert')).toContainText('відхилено');
await expect(page).toHaveURL(/\/checkout/); // не перекинуло на success
});
Побічний ефект «після реєстрації пішов лист» перевіряють інакше — не стабом, а fake-поштовиком плюс перевіркою над тим, що застосунок надіслав. Реальні листи живим людям не шлють: підіймають перехоплювач (MailHog чи Mailtrap) і забирають останнє повідомлення через його API.
test('після реєстрації летить лист підтвердження', async ({ page, request }) => {
const email = `user+${Date.now()}@example.com`; // унікальні дані на прогін
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Зареєструватися' }).click();
// fake-поштовик як spy: перевіряємо, ЩО саме надіслав застосунок
const inbox = await (await request.get('http://localhost:8025/api/v2/messages')).json();
const last = inbox.items[0];
expect(last.Content.Headers.To[0]).toContain(email);
expect(last.Content.Body).toMatch(/підтверд/i);
});
Що дивитися і чому:
page.route— стаб, а не «юнітизація e2e». Він прибирає одну неконтрольовану залежність (платіжний провайдер), але браузер, рендеринг і вся інша частина шляху лишаються справжніми. Наскрізний тест не перестає бути наскрізним.- Пошту перевіряють як поведінку. MailHog грає fake-поштовика (справжній SMTP, але листи нікуди не летять), а читання його inbox — це spy: асерт над тим, що застосунок реально надіслав, включно з адресою й вмістом.
- Унікальні дані на кожен прогін.
Date.now()в email рятує від конфлікту стану між запусками — інакше повторна реєстрація впаде через дублікат, і це буде не баг продукту, а протікання тестових даних.
Кейс 3. Заморожений стаб бреше — контракт ловить
Стаб фіксує форму відповіді такою, якою вона була колись. Поки бекенд не чіпає контракт, усе добре. Але одного дня поле status перейменували на state — і починається тихий обман.
// Юніт зі стабом старої форми — лишається ЗЕЛЕНИМ після зміни API
const gateway = {
// стаб заморожено на формі, якої на проді вже немає
charge: async () => ({ status: 'approved', txId: 'tx_1' }),
};
const result = await checkout(cart, gateway);
expect(result.orderCreated).toBe(true); // зелено, хоча реальний шлюз шле { state: 'approved' }
Тест зелений, реліз їде, а на проді checkout читає неіснуюче поле status, отримує undefined і не створює замовлення. Це розсинхрон схеми (schema drift): що більше моканих меж, то вищий ризик, що зелена сюїта перевіряє вигадку. Ловить це не мок — від нього рятує контрактний тест, який програє очікування споживача проти справжнього постачальника.
// Контрактний тест на боці постачальника: реальна відповідь проти схеми
test('відповідь /payments відповідає контракту споживача', async ({ request }) => {
const res = await request.post('/api/payments', {
data: { amount: 100, card: 'tok_visa' },
});
const body = await res.json();
// контракт вимагає саме поле status — зміна на state завалить ТУТ, а не на проді
expect(body).toHaveProperty('status');
expect(['approved', 'declined']).toContain(body.status);
});
Що дивитися і чому:
- Зелений стаб-тест не доводить сумісність. Він перевіряє лише те, що
checkoutправильно обробляє форму, яку ти сам йому підсунув. Про те, що бекенд досі віддає цю форму, він не знає нічого. - Контракт зсуває падіння вліво. Щойно постачальник ламає форму, червоніє контрактний тест у його CI — до релізу, а не після нього на живому користувачі.
- Полегшена версія — схема OpenAPI. Якщо повноцінний Pact заважкий, валідуй реальні відповіді проти OpenAPI-схеми: це вже ловить перейменовані й зниклі поля, тобто найтиповіший drift.
Навіщо дублери взагалі
- Можу пояснити, чому реальні залежності шкодять тесту: повільні, недетерміновані, з незворотними побічними ефектами, — тому дублер дає натомість керований вхід.
- Знаю чотири виграші від ізоляції (швидкість, детермінованість, крайові випадки, відсутність побічних ефектів) і що вона не безкоштовна: більше підмінено — далі від реальності й вищий ризик розсинхрону.
Таксономія за Мезарошем
- Знаю, що test double — парасольковий термін, а під ним п'ять різновидів (dummy, stub, spy, mock, fake), і розрізняє їх роль у тесті, а не синтаксис.
- Розумію, що dummy лише заповнює параметр, fake має справжню, але спрощену реалізацію (in-memory база), а стаб лише віддає консервовану відповідь.
- Пам'ятаю, що в JS-бібліотеках межі розмиті:
sinonрозрізняє, аjest.fn()/jest.mock()звуть моком усе підряд.
Головна вісь: stub проти mock
- Можу пояснити різницю stub vs mock через вісь «перевірка стану проти перевірки поведінки».
- Знаю, що stub дає SUT відповідь (перевірку стану роблю я сам після дії), а mock несе очікування в собі й валить тест сам, без окремого
expect. - Розумію наслідок і правило вибору: без виклику стаб зелений, а мок червоний; важлива відповідь — стаб, важливий факт виклику — spy або mock.
Spy проти mock
- Знаю, що spy — це стаб, який ще й записує виклики (кількість, аргументи, порядок), і перевірку над ним пишу я сам, а mock перевіряє очікування автоматично.
- Пам'ятаю, що мок чи spy без явної верифікації безсенсовний — він мовчки пропускає баг.
Дублери на рівнях піраміди
- Знаю закон: що вище піраміда, то менше дублерів і більше справжнього.
- Розумію, що на unit усе навколо підмінене класичними stub/spy/mock (
jest,sinon,vitest), а на інтеграції/API дублер — це вже мережевий стаб (msw,nock, WireMock). - Розумію, що
page.routeу Playwright — за таксономією мережевий стаб, і він не перетворює e2e на юніт.
Що мокати в e2e
- Можу назвати, що підміняють навіть у чесному e2e: платежі, пошту/SMS, сторонні API, час і випадковість — незворотні дії й межі поза контролем команди.
- Розумію підхід до пошти: fake-поштовик (MailHog/Mailtrap) плюс spy на тому, що застосунок надіслав.
- Можу пояснити орієнтир: мокай неконтрольоване й недетерміноване, лишай справжнім те, що перевіряєш; критичний наскрізний шлях хоч кількома тестами жени проти справжнього бекенда.
Ризики й контрактні тести
- Знаю, що таке overmocking: надмоканий тест перевіряє власні дублери, а не логіку застосунку.
- Розумію розсинхрон схеми (drift): стаб заморожений на старій формі, тест зелений і бреше, а ловить це не мок, а контрактний тест.
- Знаю ідею consumer-driven contracts (Pact) — стаб споживача грають проти справжнього постачальника в його CI — і полегшений варіант: валідація реальних відповідей проти схеми OpenAPI.
Навіщо взагалі підміняти реальну залежність тестовим дублером?
Питання
Тестовий дублер (test double) — що це?