Собираем свой тест-раннер: describe, it, ожидания, асинхронность

эксплуатация≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В первой части expect(факт).toBe(ожидание) уместился в три строки и печатал «ok» или «провал: 5». Настоящий раннер — node --test, Vitest, jest — делает сверх этого три вещи: сначала собирает тесты и только потом запускает, останавливает тест на первом несовпадении с сообщением, по которому видно, что сломалось, и дожидается асинхронных проверок. Все три соберём сами, по дороге наступив на ловушки, из-за которых тесты зеленеют там, где должны краснеть. А потом прочитаем вывод настоящего node --test — незнакомого в нём не останется.

Разделы темы:·

Проверка из первой части печатает и идёт дальше. Раннеру этого мало по двум причинам. Строки теста после несовпадения обычно опираются на то, что проверено выше: выполнять их дальше бессмысленно, а часто и вредно — следующая строка упадёт с TypeError и спрячет настоящую причину. И раннеру нужно как-то узнать, что тест провален. Канал, по которому код говорит «дальше нельзя», в JavaScript уже есть — исключение. Поэтому ожидание бросает ошибку, и тем же путём до раннера доедет TypeError из проверяемого кода: для него это такой же провал.

Второе требование — к тексту ошибки. Когда тест упал через месяц на чужой машине, у вас нет ни отладчика, ни консоли, только строка сообщения. «провал: 5» не отвечает на главные вопросы: что ожидалось и было ли полученное числом 5 или строкой "5". Поэтому show ниже печатает числа как есть, а строки и объекты — через JSON.stringify, с кавычками, и в сообщении всегда обе стороны.

Проверять будем pageOf — разбор номера и размера страницы из строки запроса, какой нужен любому списку в API. Тестов в привычном смысле пока нет: три проверки лежат в массиве, и каждую мы обернём в try/catch сами. Прогноз: какие проверки пройдут и что напечатают упавшие?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Первая прошла. Вторая упала с сообщением ожидалось 5, получено "5": URLSearchParams отдаёт значения строками, и Number в pageOf применён к page, но забыт у limit. Версия из первой части напечатала бы здесь «провал: 5» — правду, по которой ошибку не найти. Кавычки в сообщении — половина диагноза.

Третья упала, хотя обе стороны напечатались одинаково. toBe сравнивает через Object.is: у чисел и строк это сравнение значений, а у объектов — сравнение ссылок, и объект, собранный внутри pageOf, никогда не окажется тем же объектом, что литерал в проверке. Без приписки в конце такое сообщение сбивает с толку: ожидалось одно, получено то же самое. Настоящие раннеры пишут ровно эту приписку — у Vitest 4 и jest 29 это «serializes to the same string» и совет заменить toBe на toStrictEqual. Проверки по содержимому там две, toEqual и toStrictEqual, и честно написать такое сравнение сложнее, чем кажется, — это последняя задача статьи.

Object.is, а не ===: для него NaN равен NaN, а 0 не равен -0. Так сравнивают toBe в Vitest и jest и assert.equal из node:assert/strict.

Цикл с try/catch в конце примера — зародыш раннера. Не хватает имён у проверок, итога и главного — порядка работы, который и отличает раннер от цикла.

Тестовый файл читается как сценарий, но работает иначе. it(name, fn) ничего не проверяет — кладёт функцию в список под именем. describe(name, body) вызывает body сразу, чтобы все it внутри записались с общим префиксом. Проверять начнёт run, когда файл уже прочитан: пройдёт по списку и вызовет каждую функцию в своём try/catch, чтобы упавший тест не остановил соседей, а последней строкой подведёт итог.

Проверки — те же две из прошлого раздела, теперь с именами. Прогноз: в каком порядке выйдут «собираю тесты pageOf», «файл прочитан» и строки тестов? И как будет называться упавший тест?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первой вышла строка из тела describe, второй — «файл прочитан, тестов: 2», хотя в тексте она стоит после обоих it. describe выполнил тело сразу, а it только записали тесты: пока файл выполняется, раннер лишь узнаёт, какие тесты есть. В настоящих раннерах строки run() в тестовом файле нет — её зовёт сам раннер, когда файл выполнился, и тесты начинаются только тогда. Имя упавшего теста собрано из двух частей, pageOf › limit — тоже число: по нему тест находят в файле на сотню проверок.

Список до запуска — не деталь реализации. Именно поэтому раннер умеет запустить один тест по имени: у node --test это флаг --test-name-pattern, у Vitest и jest — -t. Сначала он видит все тесты, потом решает, какие из них выполнять.

Из того же порядка растёт ловушка. Всё, что лежит в describe вне it, выполняется один раз — при сборе, до первого теста. Ниже массив cart объявлен в describe, и оба теста им пользуются. Прогноз: пройдут ли оба?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй упал с ожидалось 0, получено 1, и виноват не код корзины, а соседство. Массив создан один раз при сборе, первый тест положил в него хлеб, второй получил корзину после первого. Поменяйте тесты местами — пройдут оба. Оставьте второй в одиночку — пройдёт. Такое падение хуже обычного: тест зелёный сам по себе и красный в компании, а стоит выше добавить ещё один тест с товаром — и упадёт уже и «товар добавляется».

Данные для теста создаются в самом тесте или в beforeEach — функции, которую раннер вызывает перед каждым тестом. Её вы соберёте в первой задаче; в тестах против базы на неё ляжет очистка между тестами.

На сервере синхронных тестов почти нет: чтение из базы, запрос к API, файл — всё возвращает промис. Тест становится async-функцией, а наш run вызывает её как раньше — fn() внутри try/catch.

findNote изображает поход в базу: заметка приходит через 20 мс, и заголовок у неё не тот, которого ждёт первый тест. Раннер — из прошлого раздела, без изменений. Прогноз: что напишет итог и появится ли где-нибудь сообщение о несовпадении заголовка?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба теста «прошли», итог чистый — а следом приходит Uncaught (in promise) с тем самым сообщением. async-функция не бросает: она сразу возвращает промис, fn() завершается без исключения, и try/catch ставит галочку. Ожидание упало позже, внутри промиса, у которого не нашлось хозяина, — это необработанное отклонение. Раннер к тому времени уже подвёл итог.

Чинится двумя словами: run становится async и ждёт каждый тест — await fn(). Синхронным тестам это не мешает: await над значением, которое не промис, просто продолжает функцию. Заодно тесты идут строго по одному, и каждый начинается, когда предыдущий закончился.

Но у ожидания своя беда — тест, который не закончится никогда. Забытый resolve, событие, которое не придёт, сервер, который молчит, — и раннер, честно ждущий промис, повиснет вместе с тестом. Поэтому у каждого теста есть срок: Promise.race между тестом и таймером, который отклоняется через заданное число миллисекунд. Прогноз: что раннер напечатает про каждый из трёх тестов и дойдёт ли он до третьего?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Провал первого теста теперь в своей строке и со своим сообщением. Второй получил вердикт «не закончился за 100 мс», и раннер пошёл дальше — третий прошёл. finally с clearTimeout нужен не для вывода: без него каждый быстрый тест оставлял бы после себя тикающий таймер, а в Node таймер держит процесс живым, пока не сработает.

Срок по умолчанию у настоящих раннеров разный. Vitest 4 и jest 29 дают тесту 5000 мс и в сообщении подсказывают, как поднять предел. У node --test в Node 26.5 срока по умолчанию нет: файл с тестом () => new Promise(() => {}) не завершился и за 65 секунд, и его пришлось остановить. Срок ставят флагом --test-timeout или опцией { timeout: 100 } у самого теста, и тогда тот же тест кончается строкой «test timed out after 100ms».

Самая частая ошибка асинхронного теста — промис без хозяина: findNote(7).then((note) => expect(note.title).toBe("купить хлеб")) без return перед ним. Тестовая функция вернула undefined, ждать раннеру нечего, и даже исправленный run поставит галочку. Настоящие раннеры замечают такое по косвенным признакам, и каждый по-своему. Проверено 15.09.2026 на заметках из примера выше: первый тест записан так, без return, второй по-прежнему ждёт свою заметку.

node --test в Node 26.5 ставит обоим тестам ✔, а следом печатает, что «находит заметку по id» «generated asynchronous activity after the test ended», и отмечает крестиком файл. Vitest 4.1 тоже засчитывает оба теста, но добавляет в конец блок «Unhandled Rejection»: в нём назван файл, а возможным виновником — «не теряет id», тест, который ещё шёл, когда ожидание упало. jest 29.7 ставит крестик прямо соседу: «не теряет id» падает с чужим сообщением про «купить хлеб», а тест без return остаётся зелёным. Код выхода у всех трёх — 1.

Запуск краснеет у всех трёх, но на сам тест указывает только node --test. Vitest к тому же краснеет лишь благодаря соседу: поставьте тест без return в файле последним — файл закончится раньше, чем упадёт ожидание, и Vitest 4.1 отчитается зелёным с кодом 0. Правило одно для любого раннера: у каждого промиса в тесте есть хозяин — await или return.

Встроенный раннер Node — модуль node:test, и ставить его не нужно. Словарь почти наш: test или его синоним it, describe, а вместо expect — модуль node:assert/strict, где assert.equal сравнивает через Object.is, как наш toBe, а assert.deepEqual — по содержимому. Запускается флагом --test. Без имён файлов Node сам найдёт *.test.mjs, *.test.ts и всё в папке test, и каждый файл пойдёт в отдельном процессе. Как поставить Node и запускать им свои файлы, разобрано в первой главе части.

Функция average делит сумму на длину. Тестов два: на обычном списке и на пустом — та самая граница «пусто» из первой части. Прогноз: какой тест упадёт, что Node напечатает вместо нашего «ожидалось …, получено …» и с каким кодом завершится процесс?

прогон node 26.5.0 · 15 сентября 2026
Настоящий раннер Node: два теста, итог и разбор упавшего
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { average } from './average.mjs';

test('среднее трёх чисел', () => {
  assert.equal(average([2, 4, 9]), 5);
});

test('среднее пустого списка — 0', () => {
  // граница «пусто» из первой части: на обычных списках её не видно
  assert.equal(average([]), 0);
});
node --test --test-reporter=spec average.test.mjs

Упал тест пустого списка: сумма пустого — 0, длина — 0, а 0 / 0 — NaN. Строки те же, что у нашего раннера: ✔ и ✖ по тестам, итог с числом прошедших и упавших. Для упавшего Node повторяет имя и место: average.test.mjs:9:1 — где объявлен тест, :11:10 в кадре стека — строка проверки. Сообщение NaN !== 0 показывает обе стороны литералами, как наш show. Поля actual, expected и operator в конце — то, из чего сообщение собрано: они есть у каждой AssertionError, и свой отчёт может печатать их как угодно.

Главное в выводе — не текст, а код выхода 1. Его читают скрипты: конвейер выкладки, хук перед коммитом, npm test в чужом скрипте. Строки с галочками там никто не разбирает, а ненулевой код останавливает выкладку. Нашему раннеру для этого на Node не хватает одной строки после итога — process.exitCode = 1, если что-то упало. В песочнице учебника process нет, а без этой строки раннер с упавшими тестами на Node рапортовал бы успех.

Отдельная беда — проверки с таймерами. Поиск по мере ввода отправляет запрос через 300 мс после последней буквы: debounce откладывает вызов и сбрасывает отсрочку при каждом новом символе. Честный тест ждал бы настоящие 300 мс на каждый случай, а случаев десяток. Поэтому раннеры умеют подменять таймеры. Пока подмена включена, setTimeout не ставит настоящий таймер, а записывает колбэк в свою очередь со временем срабатывания. Время идёт, только когда тест сам скажет tick(мс), и все созревшие колбэки выполняются прямо внутри этого вызова.

В node:test подмена живёт в контексте теста: t.mock.timers.enable включает её, а после теста таймеры сами становятся настоящими. Прогноз: что будет в sent через 299 мс после «кот» и ещё через 1 мс, и сколько настоящего времени займёт тест?

прогон node 26.5.0 · 15 сентября 2026
debounce под подменёнными таймерами: tick вместо ожидания
import { test } from 'node:test';
import assert from 'node:assert/strict';

// Поиск по мере ввода: запрос уходит, только когда человек перестал печатать
function debounce(fn, ms) {
  let timer;
  return (text) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(text), ms);
  };
}

test('поиск уходит один раз — после паузы в 300 мс', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout'] });
  const started = performance.now();
  const sent = [];
  const search = debounce((text) => sent.push(text), 300);

  search('к');
  search('ко');
  t.mock.timers.tick(200);
  search('кот');
  t.mock.timers.tick(299);
  console.log('через 299 мс после «кот»:', sent);
  t.mock.timers.tick(1);
  console.log('ещё через 1 мс:', sent);

  assert.deepEqual(sent, ['кот']);
  // performance не подменён: это настоящее время, которое занял тест
  console.log('настоящих миллисекунд меньше 100:', performance.now() - started < 100);
});
node --test --test-reporter=spec debounce.test.mjs

Через 299 мс после «кот» — пусто: 200 мс, прошедшие после «ко», не в счёт, потому что третья буква сбросила отсрочку. Ещё одна миллисекунда — и в sent ровно одна строка, кот, а не по запросу на каждую букву. По часам теста прошло полсекунды, настоящего времени — меньше 100 мс. Подменено только перечисленное в apis: здесь это setTimeout, а Date остался настоящим. performance.now() в node:test не подменить вовсе, поэтому он и показывает в тесте настоящие миллисекунды. У Vitest и jest тот же приём включается вызовами vi.useFakeTimers() и jest.useFakeTimers().

Vitest и jest пишутся теми же словами, что наш раннер: describe, it, expect(…).toBe, beforeEach. Отличия — вокруг скелета, и часть из них видна только при запуске. Сверено на Node 26.5, Vitest 4.1 и jest 29.7:

node --testVitestjest
откуда describe и itimport из node:testimport из vitest; глобальными — настройкой globalsглобальные, без импорта
когда выполняется тело describeсразу, пока файл читаетсяпосле того как файл дочитансразу, пока файл читается
ожиданиеassert из node:assert/strictexpectexpect
срок теста по умолчаниюнет; --test-timeout5000 мс5000 мс
TypeScriptстирание типов в самом Nodeпреобразование через Viteпреобразователь, в apps/api — ts-jest
подмена таймеровt.mock.timersvi.useFakeTimers()jest.useFakeTimers()

Тело describe Vitest выполняет позже двух других, но до первого теста — у всех трёх, так что ловушка с общей корзиной у них одна. Сверх скелета у Vitest и jest есть режим наблюдения, который перезапускает тесты при сохранении, подмена целых модулей (vi.mock, jest.mock), снимки и отчёт о покрытии. Многое из этого есть и в node:test: подмена функций и методов (mock.fn, mock.method), снимки через t.assert.snapshot, флаг --watch и покрытие под флагом --experimental-test-coverage. А вот подмены модулей mock.module в Node 26.5 без отдельного флага нет.

Какой брать. Для чистых функций и скриптов хватает node --test: ставить нечего, а TypeScript без декораторов и enum Node запускает сам. Проекту на Nest раннер достаётся со стартером: в Nest 12 это Vitest (^4.1.2, последняя версия на 29.09.2026 — 5.0.2), в apps/api учебника — jest 29 с ts-jest (последний jest — 30.5.2). Тесты сервисов против настоящей базы, очистка между тестами и подмена репозитория через контейнер — следующая статья, а версии и признаки того, что тестовая библиотека не нужна, — на полке сервера.

  • Раннер работает в два прохода: it кладёт тест в список, describe выполняет своё тело при сборе, и только когда файл прочитан, run запускает тесты по одному — каждый в своём try/catch.
  • Ожидание бросает исключение: тест останавливается на первом несовпадении, а раннер узнаёт о провале тем же путём, что и о TypeError в коде. В сообщении — обе стороны, строки в кавычках.
  • toBe сравнивает через Object.is: для объектов это одна ссылка. Содержимое сравнивает toEqual, и через JSON.stringify честно его не написать.
  • Всё, что в describe вне it, выполняется один раз при сборе и делится между тестами. Данные для теста — в самом тесте или в beforeEach.
  • Раннер обязан ждать: без await fn() асинхронный тест проходит, а провал всплывает после итога. У теста есть срок — у Vitest и jest 5000 мс, у node --test по умолчанию никакого.
  • node --test, Vitest и jest — тот же скелет: список тестов, ожидания, ожидание промисов, итог и код выхода 1, который читают скрипты. Время в тестах подменяют: t.mock.timers, vi.useFakeTimers(), jest.useFakeTimers().

1/4В describe, но вне it, написано const users = []. Когда выполнится эта строка?

2/4Проверка expect(pageOf("")).toBe({ page: 1, limit: 20 }) упала, хотя в сообщении обе стороны одинаковые. Почему?

3/4Тест it("…", () => { findNote(7).then((note) => expect(note.title).toBe("x")); }) запускает раннер с await fn(). Что он напишет про этот тест?

4/4Один тест из десяти упал в node --test. По чему это узнаёт скрипт выкладки?

beforeEach: свежая корзина на каждый тест

Вот корзина из раздела про сбор, переписанная правильно: переменная cart объявлена в describe, а свежий массив в неё кладёт beforeEach. Сейчас beforeEach пустая, и оба теста падают. Допишите её и поправьте run: функция, переданная в beforeEach, должна выполняться перед каждым тестом, а если упадёт она сама — падает тест, а не весь прогон.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Функции подготовки складываются в свой список, а run вызывает их перед каждым тестом — внутри того же try, что и сам тест:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Оба теста проходят: перед каждым cart получает новый пустой массив. Подготовка стоит внутри try — если она упадёт, крестик получит свой тест, а прогон пойдёт дальше.

У этого beforeEach одно упрощение: подготовка общая для всего файла. У настоящих раннеров beforeEach принадлежит describe, в котором вызван, и тесты соседнего describe её не видят. Чтобы так сделать, it должен запоминать не только имя, но и подготовку своей группы на момент записи.

toThrow, который обманывает сам себя

Ожидание toThrow(text) проверяет, что функция бросает ошибку с нужным текстом. Первые два теста честные. Третий нарочно неправильный: pageOf("page=2") ничего не бросает, и тест обязан упасть с сообщением «функция не бросила». Запустите: упасть он упадёт, но сообщение будет другим. Найдите, где toThrow ловит сам себя, и почините.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сейчас третий тест падает с сообщением «бросила «функция не бросила», а ждали «от 1»». Ошибка «не бросила» брошена внутри try, и её поймал catch того же ожидания: для него это просто ошибка с неподходящим текстом. Хуже того, если бы искомый текст входил в «функция не бросила» — скажем, toThrow("бросила"), — тест бы прошёл.

Своя ошибка ожидания должна бросаться за пределами try:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь третий тест падает с функция не бросила, а первые два проходят, как прежде.

toEqual, которого не обманет порядок ключей

Сравнение по содержимому напрашивается через JSON.stringify: объекты одинаковые — одинаковые и строки. Две проверки ниже показывают, где это врёт. Перепишите equal так, чтобы все четыре строки напечатали то, что нужно. Массивы остаются чувствительными к порядку, объекты — нет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

JSON.stringify пишет ключи в порядке их появления, поэтому { page, limit } и { limit, page } дают разные строки. А NaN он превращает в null — и две разные вещи становятся одной строкой. Сравнивать нужно значения, а не их запись:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Примитивы сравниваются через Object.is, как в toBe. У объектов сверяется число ключей, а потом каждое значение — тем же equal, рекурсивно. Порядок ключей в объекте не участвует, а у массивов ключи — индексы, поэтому ["a", "b"] и ["b", "a"] различаются. Настоящий toEqual вдобавок пропускает поля со значением undefined, а toStrictEqual — нет.