Тест-дублёры и моки: швы, а не кувалда
Тест-дублёр подменяет коллаборатора на шве. Знай пять видов, предпочитай внедрённые швы мокам модулей, фейковые таймеры превратят тест 30-секундного бэкоффа в 5 мс — и никогда не мокай то, что тестируешь, иначе сьют зелёный, а прод отдаёт 500.
Твой сьют чекаута — 600 тестов чистой зелени, 0.8 с от старта до финиша — выкатил баг, который дважды списывал деньги. Функция списания вызывала stripe.charges.create, и каждый тест мокал этот вызов, возвращая { status: "succeeded" }. Так что когда рефакторинг начал передавать сумму в долларах вместо центов, мок радостно вернул успех для $4900 вместо $49.00 — он понятия не имел, как выглядит настоящее списание. Тесты проверяли, что мок вызван, но не что списание верное. Сьют был зеркалом: он отражал твои допущения обратно, включая ошибочное.
Пять дублёров, названных точно
Когда коллега говорит «просто замокай это» — какой из пяти инструментов он имеет в виду? От ответа зависит, ловит ли твой тест баги или просто отражает твои допущения обратно.
«Мок» — слово, к которому тянется каждый, и чаще всего ошибочно. По таксономии Мезароша (её популяризировал Фаулер) есть пять видов тест-дублёров, различаемых по тому, что они делают и на что ты проверяешь. Dummy — заглушка, которую ты передаёшь, чтобы удовлетворить сигнатуру, и никогда не используешь: null, {}, пустая функция. Stub возвращает заготовленные значения, чтобы провести путь: getUser() всегда отдаёт одного и того же админа, и ты тестируешь ветку админа. Spy — это stub, который вдобавок записывает, как его вызвали, чтобы потом проверить аргументы и число вызовов. Mock заранее запрограммирован ожиданиями: он знает, какие вызовы должен получить, и сам валит тест, если реальность расходится. Fake — настоящая, рабочая, лёгкая реализация: in-memory репозиторий на Map вместо Postgres, полностью функциональный, но непригодный для прода.
Граница, важная на senior-уровне, — верификация состояния vs верификация поведения. Stub и fake позволяют проверять итог — в каком состоянии оказалась система. Spy и mock тянут тебя проверять взаимодействие — какие методы вызвались и в каком порядке. Проверки взаимодействия соблазнительны и хрупки: они привязывают тест к текущей реализации, так что рефакторинг с тем же поведением может сделать сьют красным. Баг из хука — обратный провал: проверялось только взаимодействие, поэтому сломанный итог остался зелёным.
В node:test (встроен с Node 18, стабилен в 20) дублёр — это mock.fn(), а метод патчишь через mock.method(obj, 'name') — оба авто-восстанавливаются в конце теста, если использовать трекер моков контекста. Vitest повторяет это через vi.fn() и vi.spyOn(obj, 'method'). Вот spy поверх реального объекта, проверяющий и итог, и взаимодействие:
import { test } from "node:test";
import assert from "node:assert/strict";
test("notify spies on the mailer", () => {
const mailer = { send: () => "queued" };
const spy = test.mock.method(mailer, "send"); // wraps the real method
notify(mailer, "you@co.com", "hi");
// interaction: was it called, and how?
assert.equal(spy.mock.calls.length, 1);
assert.deepEqual(spy.mock.calls[0].arguments, ["you@co.com", "hi"]);
});Швы: подменить поведение, не трогая тестируемый код
Шов (seam) — это место, где можно изменить поведение, не правя тестируемый код (термин Физерса) — и самый полезный взгляд на тестируемость. Каждому дублёру нужен шов, чтобы в нём жить, и по сути швов у тебя два.
Чистый шов — внедрение зависимостей (DI): коллаборатор приходит как аргумент (конструктор, параметр функции, фабрика), так что тест просто передаёт stub или fake вместо настоящего. Никакой магии, никакого фреймворка, безопасно к рефакторингу — обвязка это обычные значения, текущие через обычные API. Цена в том, что это видно в сигнатуре: charge(order) становится charge(order, { gateway, clock }), что часть команд отвергает как «урон, наведённый тестами».
Другой шов — мокирование модуля, когда внедрить нельзя: модуль делает import { readFile } from "node:fs/promises" и вызывает напрямую. node:test даёт mock.module(specifier, { namedExports }); Vitest — vi.mock('./mod'). Подвох Vitest — хойстинг: vi.mock поднимается в начало файла выше твоих импортов, так что его фабрика не должна ссылаться на внешние переменные (используй vi.hoisted(), если нужно), а мок применяется ко всем импортёрам этого модуля на весь тестовый файл.
// Vitest module mock — поднимается выше импортов, применяется ко всему файлу
import { vi, test, expect } from "vitest";
import { loadConfig } from "./config.js";
vi.mock("node:fs/promises", () => ({
readFile: vi.fn().mockResolvedValue('{"port":8080}'),
}));
test("parses config from disk", async () => {
expect(await loadConfig("/app.json")).toEqual({ port: 8080 });
});▸Почему это работает
Почему DI выигрывает на рефакторингах, а мокирование модуля проигрывает? Внедрённый шов — часть твоего реального графа вызовов: тест прогоняет ту же обвязку, что и прод, лишь с другим подставленным значением. Мок модуля лезет за пределы графа вызовов и переписывает разрешение загрузчика модулей для одного файла. Поэтому мок модуля связан с путями импорта и формами экспорта — переименуй ./config в ./config/index, смени именованный экспорт на дефолтный, перенеси функцию в другой модуль — и мок тихо перестаёт перехватывать, пока тест продолжает проходить против не-замоканного реального кода (или падает по причинам, не связанным с твоей правкой). DI ломается громко на этапе компиляции/линта; моки модулей гниют тихо.
Фейковые таймеры: сжать реальные секунды в микросекунды
Код, который ждёт, — ретрай с бэкоффом, дебаунс, опрос, TTL кеша — не тестируем в реальном времени: 30-секундный экспоненциальный бэкофф (1 с, 2 с, 4 с, 8 с, с потолком 15 с) сделал бы один тест ценой в полминуты. Фейковые таймеры подменяют setTimeout, setInterval и Date.now/Date управляемыми часами, которые ты двигаешь вручную, так что виртуальное время идёт за нулевую реальную цену. node:test включает их на тест через t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }) и двигает через t.mock.timers.tick(ms); Vitest — через vi.useFakeTimers() и vi.advanceTimersByTime(ms) / await vi.advanceTimersByTimeAsync(ms).
Острый подвох — таймеры и микротаски это разные очереди. tick/advanceTimersByTime запускает колбэки таймеров, но промисы, которые эти колбэки резолвят, лежат в очереди микротасков, а она опустошается только когда ты делаешь await. Так что для асинхронного ретрая надо и продвинуть время, и уступить — advanceTimersByTimeAsync у Vitest делает оба; с синхронной версией ты вставляешь await Promise.resolve() или await flushPromises(). Сделай это верно — и бэкофф в 30 с реального времени отработает меньше чем за 5 мс.
import { vi, test, expect } from "vitest";
test("retries with backoff, virtually", async () => {
vi.useFakeTimers();
const op = vi.fn()
.mockRejectedValueOnce(new Error("503"))
.mockResolvedValueOnce("ok");
const p = retryWithBackoff(op, { base: 1000 }); // would sleep 1s for real
await vi.advanceTimersByTimeAsync(1000); // advance clock AND flush microtasks
expect(await p).toBe("ok");
expect(op).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});Сервис зависит от репозитория Postgres. Тебе нужны быстрые, надёжные тесты бизнес-логики сервиса на множестве строк и краевых случаев, без реальной БД в юнит-тестах. Что подставить на шве репозитория?
Провальный режим: овермокинг выкатывает зелёные баги
Глубочайший провал этого урока — овермокинг: замокать ровно то, что тестируешь, или столько вокруг, что тест больше не прогоняет реальный код. Когда ты мокаешь коллаборатора, чьё поведение и есть суть, мок кодирует допущение — и если это допущение и есть баг, тест утверждает, что баг корректен. Хук — каноничный случай: stripe.charges.create замокали всегда возвращать успех, так что дефект «доллары вместо центов» никогда не мог сделать сьют красным. Второй, тлеющий медленнее вариант — дрейф контракта: API реальной зависимости меняется (переименовали поле, добавили статус, 200-с-телом-ошибки), а рукописный мок нет, так что тесты остаются зелёными против выдумки, пока прод отдаёт 500.
Числа убийственны. Реальный проверенный сьют имел ~400 проверок вызовов моков и ноль реального I/O — каждая внешняя граница застаблена — и пропустил изменение схемы в теле ответа, сломавшее десериализацию в проде в течение часа после деплоя; моки были заморожены на старой форме месяцами. Фикс — не лозунг «мокай меньше», а три конкретных шага: (1) на критичном шве предпочти fake или реальный интеграционный тест стабу, чтобы прогонять поведение, а не свою догадку о нём; (2) проверяй итог, а не число вызовов — expect(result.chargedCents).toBe(4900), а не expect(stripe.create).toHaveBeenCalled(); (3) контракт-тестируй границу — гоняй маленький сьют против реальной зависимости (или её записанных ответов / контракта провайдера), чтобы изменение формы громко валило этот тест, а не протекало мимо твоего замороженного мока. Вместе эти три шага переключают сьют с зеркала твоих допущений в реальный зонд поведения кода: шаг (1) добавляет настоящую логику в путь теста, шаг (2) делает ассерт наблюдаемым, шаг (3) держит форму мока честной — без всех трёх любой из них по-прежнему пропустит баг в прод.
// ❌ over-mock: asserts the mock was called, not that the charge is right
test("charges the order", async () => {
const stripe = { charges: { create: vi.fn().mockResolvedValue({ status: "succeeded" }) } };
await checkout(order, { stripe });
expect(stripe.charges.create).toHaveBeenCalled(); // green even at $4900
});
// ✅ assert outcome against a behavior-honouring fake
test("charges the order in cents", async () => {
const fakeGateway = makeFakeGateway(); // validates & records real amounts
const receipt = await checkout(order, { gateway: fakeGateway });
expect(receipt.chargedCents).toBe(4900); // catches dollars-vs-cents
});Сьют чекаута полностью зелёный, бежит меньше секунды, мокает каждый внешний вызов и проверяет, что каждый мок вызван — и при этом выкатил баг, списавший в 100 раз больше. В чём корневая причина?
- 01Различи stub, spy, mock и fake — и какие из них толкают к хрупким тестам.
- 02Почему внедрённый (DI) шов бьёт мок модуля на рефакторингах, и когда всё же тянуться к мокированию модуля?
Тест-дублёр подменяет коллаборатора на шве, и точные имена меняют то, как ты тестируешь: dummy заполняет сигнатуру, stub возвращает заготовленные значения, чтобы провести путь, spy вдобавок записывает свои вызовы, mock несёт ожидания и валит себя сам, а fake — настоящая лёгкая реализация (in-memory репозиторий). Spy и mock тянут к верификации взаимодействия, что прибивает тесты к реализации и гниёт на рефакторингах; stub и особенно fake позволяют проверять итог, который выживает. Каждый дублёр живёт в шве: внедрение зависимостей — чистый, явный, безопасный к рефакторингу шов (ценой расширения сигнатуры), тогда как мокирование модуля — vi.mock с его ловушкой хойстинга или mock.module из node:test — это шов на-крайний-случай-когда-нельзя-внедрить, связывающий тест с путями импорта и формами. Фейковые таймеры (vi.useFakeTimers / t.mock.timers) сжимают 30-секундный бэкофф меньше чем в 5 мс, если двигать часы и опустошать микротаски вместе — advanceTimersByTimeAsync делает оба. Провал, который рушит карьеры, — овермокинг: замокай то, что реально тестируешь, и мок закодирует твоё ошибочное допущение, так что сьют зелёный, а прод отдаёт 500 — тот сьют из ~400 проверок и нуля I/O, что пропустил изменение схемы. Фикс — предпочесть fake или интеграцию на критичном шве, проверять итог, а не число вызовов, и контракт-тестировать границу, чтобы изменение формы валило громко, а не протекало мимо замороженного мока. Теперь, когда увидишь зелёный сьют, выкативший баг в деньгах, первый вопрос: что именно проверял тест — что вызов случился или что результат верен?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.