12k
All articles

Отказ от Jest в пользу встроенного средства запуска тестов Node

Node test runner против Jest: стабильные функции, watch mode, snapshots, fake timers, coverage, поддержка TypeScript и ограничения миграции.

OpenReplay Team
OpenReplay Team
Отказ от Jest в пользу встроенного средства запуска тестов Node

Встроенный 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-гейты.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.