Отказ от Jest в пользу встроенного средства запуска тестов Node
Node test runner против Jest: стабильные функции, watch mode, snapshots, fake timers, coverage, поддержка TypeScript и ограничения миграции.
Встроенный test runner в Node способен заменить Jest для большинства серверных наборов тестов. Сам runner имеет статус стабильного начиная с Node 20.0.0, а режим watch, snapshot-тестирование и фиктивные таймеры уже доступны сегодня, хотя более старые руководства по миграции всё ещё указывают их как отсутствующие.
Причина, толкающая к такой миграции, хорошо знакома. Ваш сервис — это чистый ESM, но команда запуска тестов тянет за собой конвейер трансформаций, конфигурационный файл и дерево зависимостей, ломающееся при мажорных обновлениях, — и всё это ради того, чтобы вызвать функции и проверить результаты. Открытый вопрос не в том, существует ли node:test, а в том, какие его части достаточно стабильны, чтобы встроить их в CI. Далее мы последовательно разберём набор возможностей, укажем уровень стабильности каждого элемента согласно документации Node по test runner и обозначим, что вы теряете по сравнению с Jest и Vitest.
Ключевые выводы
- Test runner в Node стабилен начиная с v20.0.0, однако для сбора покрытия по-прежнему требуется экспериментальный флаг
--experimental-test-coverage, а режим watch также помечен как экспериментальный. - Snapshot-тестирование появилось в v22.3.0 и стало стабильным в v23.4.0; фиктивные таймеры через
mock.timersстабильны начиная с v23.1.0 и умеют мокатьDate. - Экспорты ES-модулей заморожены, поэтому
mock.methodне может подменить именованный экспорт; вместо этого экспортируйте объект либо используйте экспериментальныйmock.module()под флагом--experimental-test-module-mocks. - Файлы тестов
.tsзапускаются без загрузчика, поскольку удаление типов (type stripping) включено по умолчанию и стабильно начиная с v24.12.0. - Уходя от Jest, вы теряете не возможности, а эргономику: словарь матчеров, окружение jsdom и однострочные хелперы для заглушек вроде
mockResolvedValue.
Что вы получаете без единой зависимости?
Базовый вариант с нулевыми зависимостями — это node:test для структуры и node:assert для утверждений, запускаемые командой node --test. Вы получаете describe/it (псевдонимы для suite/test), хуки before/after/beforeEach/afterEach, вложенные тесты, skip и todo, а также ненулевой код завершения при падении.
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
Стабильность определяется для каждой возможности отдельно, а не для модуля целиком, и именно это различие важно учесть до того, как вы завяжете на него конвейер. Вот текущее положение дел:
| Возможность | Флаг / API | Статус | Версия |
|---|---|---|---|
| Ядро runner’а | node --test | Стабильно | Стабильно с v20.0.0 |
| Режим watch | --watch | Экспериментально | Добавлено в v19.2.0 |
| Снимки (snapshots) | t.assert.snapshot() | Стабильно | Добавлено в v22.3.0, стабильно с v23.4.0 |
| Фиктивные таймеры | mock.timers | Стабильно | Стабильно с v23.1.0 |
| Покрытие кода | --experimental-test-coverage | Экспериментально | - |
| Мокирование модулей | mock.module() | Ранняя разработка | Добавлено в v22.3.0 / v20.18.0 |
| Теги тестов | --experimental-test-tag-filter | Ранняя разработка | Добавлено в v26.2.0, портировано в v24.19.0 |
| Удаление типов TypeScript | включено по умолчанию | Стабильно | Стабильно с v24.12.0 |
Запуск и фильтрация в Node test runner
Без аргументов node --test находит файлы, соответствующие шаблонам **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js} и **/test/**/*.{cjs,mjs,js}, а также тем же шести шаблонам с расширениями {cts,mts,ts}, если вы не отключили удаление типов флагом --no-strip-types. Также можно передавать явные glob-шаблоны в качестве аргументов.
Фильтрация напрямую соответствует привычкам пользователей Jest:
node --test --test-name-pattern="parses headers" # аналог jest -t
node --test --test-skip-pattern="integration" # обратный фильтр
node --test --test-only # учитывать { only: true }
--test-only — это то, чего пользователям Jest не хватает в первую очередь: пометка теста { only: true } не даёт эффекта, пока не передан флаг. Теги тестов появились вместе с --experimental-test-tag-filter в v26.2.0 и были портированы в LTS-линейку в v24.19.0, причём в обоих случаях на уровне стабильности «ранняя разработка». Синтаксис фильтра в двух линейках различается: v26 принимает булевы выражения и подстановочные символы, тогда как 24.x сопоставляет литеральные имена тегов. В любом случае, стадия ранней разработки слишком сырая, чтобы завязывать на неё конвейер.
Режим watch
Режим watch существует и вызывается командой node --test --watch. Он следит за вашими тестовыми файлами и подключаемыми ими модулями, а затем перезапускает всё, что затронуто изменением. В документации режим watch по-прежнему помечен как Stability 1, Experimental, добавлен в v19.2.0. На практике это значит, что он вполне пригоден для локального цикла разработки, но его стоит держать подальше от CI-скриптов, которым он в любом случае не нужен.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
Покрытие кода всё ещё скрыто за флагом
Для сбора покрытия по-прежнему требуется --experimental-test-coverage, так что конвейер с гейтом по покрытию сознательно опирается на нестабильную поверхность. Ограничьте область измерения glob-шаблонами --test-coverage-include и --test-coverage-exclude, а для CI выводите машиночитаемый результат с помощью репортера lcov:
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
Пороговые значения также можно задавать — через --test-coverage-lines, --test-coverage-branches и --test-coverage-functions либо через эквивалентные опции lineCoverage, branchCoverage и functionCoverage программного API run(). Другие встроенные репортеры: spec (по умолчанию), tap, dot и junit.
Мокирование: шпионы, таймеры и стена замороженных экспортов
Объект mock из node:test покрывает шпионов (mock.fn), заглушки методов (mock.method) и фиктивные таймеры (mock.timers). Аналога mockResolvedValue нет; асинхронные результаты подменяются через асинхронный mockImplementation. Проверки читаются из mock.callCount() и mock.calls[n].arguments вместо матчеров:
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
Фиктивные таймеры стабильны с v23.1.0 и мокают setTimeout, setInterval, setImmediate и Date, а время продвигается через tick() или runAll(). Есть один нюанс, о котором стоит знать: если вытащить таймер из модуля деструктуризацией, как в import { setTimeout } from 'node:timers', мок к нему не применится.
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
Настоящее ограничение — мокирование модулей. Экспорты ES-модулей заморожены, поэтому mock.method не может подменить именованный экспорт; надёжный обходной путь — экспортировать объект и мокать его метод:
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
У Node есть и официальная альтернатива — mock.module(), которая мокает модули ESM, CJS, JSON и встроенные, но она скрыта за флагом --experimental-test-module-mocks и находится на стадии ранней разработки. Используйте её для экспериментов, а не как опору для CI-набора тестов.
TypeScript без загрузчика
Node запускает файлы тестов .ts, .mts и .cts напрямую благодаря удалению типов, которое включено по умолчанию (начиная с v23.6.0 и v22.18.0) и стабильно с v24.12.0, то есть стабильно в LTS-линейке 24.x. Test runner автоматически подхватывает шаблоны файлов TypeScript, если вы не передадите --no-strip-types. Старый рецепт с подключением загрузчика вроде tsx, описанный в статье Мехула Кара о миграции эпохи Node 20, для запуска тестов уже стал историей — хотя type stripping лишь стирает типы, так что enum и другой TS-синтаксис, существующий во время выполнения, всё ещё требует трансформации.
Что вы теряете по сравнению с Jest и Vitest?
Честный размен — это эргономика, а не возможности. Три потери вполне реальны. Первая — экосистема матчеров: expect из Jest даёт вам toHaveBeenNthCalledWith и сотни матчеров от сообщества, тогда как node:assert вынуждает собирать проверки из deepStrictEqual и mock.calls. Точка расширения — assert.register(), добавленная в v23.7.0 и v22.14.0, которая позволяет определять собственные утверждения в контексте теста. Вторая — окружения, имитирующие браузер: аналога jsdom или happy-dom здесь нет, поэтому тесты компонентов, работающие с DOM, стоит оставить на Vitest или Jest. Третья — удобство заглушек: нет mockResolvedValue, нет test.each (цикл for...of справляется с задачей), а подмена для отдельного вызова делается через mockImplementationOnce, а не через цепочки хелперов. Руководство по миграции Эрика Венделя сопоставляет эти замены пара за парой, хотя его раздел о фиктивных таймерах написан до появления реализованного API mock.timers и читается как черновое предложение.
Что это означает для мигрирующего набора тестов?
Для сервиса на Node, CLI-утилиты или библиотеки, которые никогда не касаются DOM, встроенный runner покрывает стабильное ядро того, что делал Jest, — без единой зависимости и без слоя трансформации; оставшиеся экспериментальные края — это покрытие кода, режим watch, мокирование модулей и теги. Малорисковый путь: перевести один пакет, оставить гейт по покрытию на существующем инструментарии до снятия флага и переписывать насыщенные матчерами проверки по мере того, как вы их затрагиваете. Запустите node --test на одном переведённом файле и посмотрите, какую часть каталога с конфигами вы сможете удалить.
Часто задаваемые вопросы
Запускает ли node --test тестовые файлы параллельно?
Да. Изоляция процессов используется по умолчанию, поэтому каждый тестовый файл получает собственный дочерний процесс, а --test-concurrency задаёт, сколько таких процессов может выполняться одновременно. Внутри одного файла тесты по-прежнему идут друг за другом, если только вы не зададите опцию concurrency для test или describe. Если ваши наборы тестов делят базу данных, порт или глобальное состояние, --test-concurrency=1 ограничит выполнение одним файлом за раз.
Можно ли во время миграции использовать Jest и node:test одновременно?
Да. Эти runner'ы независимы, поэтому можно держать отдельные npm-скрипты и мигрировать файл за файлом. Подвох — в пересекающемся обнаружении файлов: оба по умолчанию подхватывают файлы вида *.test.js, поэтому ограничьте каждый runner явными glob-шаблонами, отдельными каталогами или настройкой testMatch в Jest, чтобы переведённые файлы не запускались дважды, а непереведённые не падали под node --test.
Работает ли node:test с проектами на CommonJS?
Да. Runner не зависит от модульной системы: require('node:test') и require('node:assert') работают в файлах CommonJS, а шаблоны обнаружения по умолчанию явно включают .cjs наряду с .mjs и .js. Единственное требование — схема node:, поэтому require('test') или import test from 'test' завершится ошибкой. Смешанная кодовая база может выполнять тестовые файлы ESM и CJS в рамках одного вызова node --test.
На какую версию Node ориентироваться при внедрении node:test в CI?
Node 24 LTS покрывает стабильное ядро: сам runner (стабилен с v20.0.0), snapshot-тестирование, фиктивные таймеры mock.timers и включённое по умолчанию удаление типов TypeScript. Покрытие кода и режим watch остаются экспериментальными во всех линейках релизов. Две более новые возможности test runner попали в 24.x через бэкпорт, а не остались только в текущей линейке: теги тестов в v24.19.0 и рандомизация порядка запуска в v24.16.0, — но обе находятся на стадии ранней разработки, так что пока не стоит строить на них CI-гейты.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k