Юнит-тесты с node:test и Vitest
Node несёт стабильный раннер (node:test) и node:assert; Vitest добавляет скорость Vite и богатые матчеры. Тестируй поведение, а не реализацию, держи тесты независимыми и подделывай часы, чтобы зависимые от времени тесты были быстрыми.
У хелпера ретраев был тест, который реально ждал пять секунд бэкоффа на setTimeout, и сюита была зелёной — пока CI не нагрузился. Под нагрузкой таймер срабатывал на пару миллисекунд позже, ассерт выполнялся до завершения ретрая, и тест падал примерно один прогон из двадцати. Команда пометила его skip, выкатилась, а через три недели реальная регрессия бэкоффа проскользнула, потому что единственный покрывавший её тест заглушили. Сбой никогда не был в коде ретрая. Это был тест, привязанный к настенным часам, а фикс — четыре строки: подделать таймеры, прокрутить их вручную, и тот же тест стал мгновенным и на 100% детерминированным.
Встроенный раннер: node:test и node:assert
Прежде чем тянуться к стороннему раннеру, спроси: а что уже есть? С Node 18 (стабильно с Node 20) встроенный раннер закрывает весь цикл юнит-тестирования без единой установки — и это важно, когда дорожишь чистотой зависимостей библиотеки или скоростью холодного CI.
С Node 20 раннер тестов стабилен и встроен — без зависимостей, без конфига. Тесты пишешь через test() (или describe() / it()), проверяешь через node:assert и запускаешь через node --test, который находит файлы вида *.test.js, *-test.js и файлы в каталогах test/. Добавь --watch для перезапуска при изменении и --experimental-test-coverage для сводки покрытия. Сабтесты вкладываются через await t.test(...), а хуки (before, after, beforeEach, afterEach) поднимают и сносят состояние.
import { test, describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Cart } from "./cart.js";
describe("Cart", () => {
let cart;
beforeEach(() => { cart = new Cart(); }); // свежее состояние на каждый тест
it("totals line items", () => {
cart.add({ price: 10, qty: 2 });
assert.strictEqual(cart.total(), 20);
});
it("rejects a negative quantity", () => {
assert.throws(() => cart.add({ price: 10, qty: -1 }), /quantity/);
});
});Ключевое с node:assert — импортировать строгий вариант (node:assert/strict), чтобы assert.equal означал ===, а не нестрогий ==, который тихо считает "2" и 2 равными. assert.strictEqual сравнивает примитивы; assert.deepStrictEqual обходит объекты и массивы структурно; assert.match проверяет строку против регулярки. Сбои, которые ты ожидаешь, проверяй напрямую: assert.throws(fn, /pattern/) для синхронных throw и await assert.rejects(promise, /pattern/) для зареджекченных промисов — тест, который проверяет отсутствие ошибки там, где она должна сработать, проходит по неправильной причине.
Мокаем на границах
Юнит-тест должен прогонять твою логику, поэтому ты заменяешь медленные или недетерминированные вещи на её краях — сеть, файловую систему, часы — а собственный код оставляешь настоящим. node:test даёт mock.fn() для отдельных шпионов, t.mock.method(obj, "name") для замены метода (автовосстановление по завершении теста) и t.mock.module() для подмены целого модуля. Шпион записывает каждый вызов: mock.calls хранит аргументы, возвращаемые значения и число вызовов, так что ты ассертишь как был использован коллаборатор.
import { test, mock } from "node:test";
import assert from "node:assert/strict";
test("charges the gateway once with the order total", () => {
const gateway = { charge: mock.fn(() => ({ id: "ch_1" })) };
const order = { total: 4200, gateway };
checkout(order);
assert.strictEqual(gateway.charge.mock.callCount(), 1);
assert.deepStrictEqual(gateway.charge.mock.calls[0].arguments, [4200]);
});Дисциплина, которая важна: мокай границу, а не собственную логику. Если замокать тестируемую функцию, тест ассертит твой мок, а не твой код. Заглуши HTTP-вызов платёжного шлюза; не заглушай checkout(), который ты пытаешься проверить.
▸Почему это работает
Поддельные таймеры — самый рычажный мок для бэкенд-кода. mock.timers.enable({ apis: ["setTimeout", "Date"] }) меняет реальные часы на управляемые; mock.timers.tick(5000) мгновенно продвигает виртуальное время, срабатывая всеми таймерами в этом окне, при этом настенные часы не двигаются вовсе. Тест бэкоффа на пять секунд бежит за микросекунды и никогда не флакает на загруженном CI, потому что реального времени нет — и значит, нет гонки. vi.useFakeTimers() / vi.advanceTimersByTime() у Vitest делает то же. Один этот приём превращает самую медленную и флакающую категорию юнит-тестов в самую быструю и надёжную.
Выбор раннера: node:test vs Vitest vs Jest
| Раннер | Зависимости | Старт / скорость | API и среда | Брать, когда |
|---|---|---|---|---|
node:test | ноль (встроен) | быстрейший холодный старт; без бандлера | core API + node:assert; без DOM | библиотеки, чистый бэкенд, минимум зависимостей |
| Vitest | одна dev-зависимость (Vite) | быстрый watch на HMR; переиспользует трансформ Vite | Jest-подобные expect/vi; jsdom; TS из коробки | богатые матчеры, снапшоты, DOM, монорепо |
| Jest | несколько зависимостей | медленнее старт; тяжёлый трансформ | зрелый expect; jsdom; огромная экосистема | уже есть сюита Jest, легаси-плагины |
node:test — верный дефолт для библиотеки или бэкенда с небольшим числом зависимостей: ставить нечего, быстрейший старт, и он говорит на core API. Бери Vitest, когда нужна эргономика в стиле Jest — цепочечные матчеры expect, инлайн-снапшоты, vi.mock, среда jsdom/happy-dom для кода, трогающего DOM, TypeScript без лишнего конфига и шустрый watch на HMR, перезапускающий только затронутые тесты. Vitest почти полностью совместим с Jest (vi.fn вместо jest.fn, vi.mock вместо jest.mock), так что это ещё и стандартный путь сбежать от медленной настройки Jest. Сам Jest бери в основном тогда, когда у тебя уже есть сюита Jest и плагины, которые не хочется мигрировать.
Ты начинаешь новую публикуемую npm-библиотеку: чистый TypeScript, без DOM, и хочешь минимальное дерево зависимостей и быстрейший холодный старт CI. Какой раннер — сеньорский дефолт?
Принципы, которые держат тесты честными
Когда видишь, как тест помечают skip вместо того чтобы починить, почти всегда нарушено одно из трёх правил. Три правила отделяют сюиту, которой доверяешь, от той, что заглушают. Тестируй поведение, а не реализацию: ассерти наблюдаемый исход (возвращаемое значение, вызов коллаборатора), а не приватные внутренности, чтобы рефакторинг, сохраняющий поведение, не красил сюиту. Тест, ломающийся при каждом переименовании приватного метода, тестирует не то. Arrange-Act-Assert: подготовь входы, выполни одно действие, проверь один исход — один логический ассерт на тест делает место сбоя точным. Держи каждый тест независимым: без разделяемого изменяемого состояния, без опоры на порядок прогона. Сбрасывай состояние в beforeEach; если тест B проходит лишь потому, что тест A отработал первым и оставил строку в массиве уровня модуля, у тебя порядко-зависимая сюита, которая флакнет в тот же миг, когда раннер распараллелит или перемешает.
// ❌ зависит от порядка: состояние уровня модуля течёт между тестами
const users = [];
test("a: adds a user", () => { users.push({ id: 1 }); assert.strictEqual(users.length, 1); });
test("b: starts empty", () => { assert.strictEqual(users.length, 0); }); // ПАДАЕТ после a
// ✅ независимо: свежее состояние каждый раз
let store;
beforeEach(() => { store = new UserStore(); });Тест мокает ту функцию, которую должен проверить, и ассертит, что мок был вызван. Что с ним не так?
Тест реально ждёт 5 секунд ретрая на setTimeout и флакает под нагрузкой CI. Почему поддельные таймеры чинят и медлительность, и флакость?
Расставь шаги, чтобы написать один детерминированный юнит-тест ретрая на setTimeout:
- 1 Включи поддельные таймеры: mock.timers.enable({ apis: ['setTimeout'] })
- 2 Arrange: собери субъект и заглуши его границу (например, шпион fetch: сначала падает, потом успех)
- 3 Act: вызови функцию ретрая (она планирует setTimeout вместо ожидания)
- 4 Продвинь виртуальное время: mock.timers.tick(5000), чтобы сработал запланированный колбэк
- 5 Ассерти поведение: шпион вызван ожидаемое число раз, а результат зарезолвился
- 01Почему подделка таймеров превращает медленный, флакающий тест таймаута в быстрый и детерминированный — и как это сделать через node:test?
- 02Когда выбирать node:test вместо Vitest и наоборот?
Node несёт стабильный раннер тестов без зависимостей: пиши тесты через test() или describe()/it(), проверяй через node:assert/strict (бери строгий импорт, чтобы equal означал ===) и запускай через node --test, который находит файлы *.test.js и поддерживает --watch и --experimental-test-coverage; хуки (before/after/beforeEach/afterEach) поднимают и сносят состояние. Сбои, которые ожидаешь, проверяй через assert.throws и await assert.rejects, а мокай только границы — mock.fn, t.mock.method и особенно поддельные таймеры (mock.timers.tick), которые превращают тест реального пятисекундного бэкоффа в мгновенный и детерминированный, продвигая виртуальное время вместо ожидания настенных часов. Бери Vitest, когда нужны матчеры expect в стиле Jest, снапшоты, среда DOM, TypeScript без конфига и быстрый watch на HMR — он почти полностью совместим с Jest — и держи node:test дефолтом для библиотек и бэкендов с лёгкими зависимостями. Что ни выбери, принципы одни: тестируй поведение, а не реализацию, чтобы рефакторинги оставались зелёными, следуй Arrange-Act-Assert с одним исходом на тест и держи каждый тест независимым от разделяемого изменяемого состояния и порядка прогона, иначе сюита флакнет и её заглушат — а именно так реальная регрессия проскальзывает мимо единственного покрывавшего её теста. Теперь, когда встретишь флакающий тест или тест с пометкой skip, ты знаешь: корень почти всегда в одном из этих трёх правил — и знаешь, как починить его, не трогая ни строки продакшен-кода.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.