open atlas
↑ К треку
Node.js с нуля до senior NODE · 06 · 03

Тест-дублёры и моки: швы, а не кувалда

Тест-дублёр подменяет коллаборатора на шве. Знай пять видов, предпочитай внедрённые швы мокам модулей, фейковые таймеры превратят тест 30-секундного бэкоффа в 5 мс — и никогда не мокай то, что тестируешь, иначе сьют зелёный, а прод отдаёт 500.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Твой сьют чекаута — 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 раз больше. В чём корневая причина?

Вспомните перед уходом
  1. 01
    Различи stub, spy, mock и fake — и какие из них толкают к хрупким тестам.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.