Собираем свой тест-раннер: describe, it, ожидания, асинхронность
Здесь будет встроен видео-разбор темы (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 сами. Прогноз: какие проверки пройдут и что напечатают упавшие?
Первая прошла. Вторая упала с сообщением ожидалось 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», «файл прочитан» и строки тестов? И как будет называться упавший тест?
Первой вышла строка из тела describe, второй — «файл прочитан, тестов: 2», хотя в тексте она стоит после обоих it. describe выполнил тело сразу, а it только записали тесты: пока файл выполняется, раннер лишь узнаёт, какие тесты есть. В настоящих раннерах строки run() в тестовом файле нет — её зовёт сам раннер, когда файл выполнился, и тесты начинаются только тогда. Имя упавшего теста собрано из двух частей, pageOf › limit — тоже число: по нему тест находят в файле на сотню проверок.
Список до запуска — не деталь реализации. Именно поэтому раннер умеет запустить один тест по имени: у node --test это флаг --test-name-pattern, у Vitest и jest — -t. Сначала он видит все тесты, потом решает, какие из них выполнять.
Из того же порядка растёт ловушка. Всё, что лежит в describe вне it, выполняется один раз — при сборе, до первого теста. Ниже массив cart объявлен в describe, и оба теста им пользуются. Прогноз: пройдут ли оба?
Второй упал с ожидалось 0, получено 1, и виноват не код корзины, а соседство. Массив создан один раз при сборе, первый тест положил в него хлеб, второй получил корзину после первого. Поменяйте тесты местами — пройдут оба. Оставьте второй в одиночку — пройдёт. Такое падение хуже обычного: тест зелёный сам по себе и красный в компании, а стоит выше добавить ещё один тест с товаром — и упадёт уже и «товар добавляется».
Данные для теста создаются в самом тесте или в beforeEach — функции, которую раннер вызывает перед каждым тестом. Её вы соберёте в первой задаче; в тестах против базы на неё ляжет очистка между тестами.
На сервере синхронных тестов почти нет: чтение из базы, запрос к API, файл — всё возвращает промис. Тест становится async-функцией, а наш run вызывает её как раньше — fn() внутри try/catch.
findNote изображает поход в базу: заметка приходит через 20 мс, и заголовок у неё не тот, которого ждёт первый тест. Раннер — из прошлого раздела, без изменений. Прогноз: что напишет итог и появится ли где-нибудь сообщение о несовпадении заголовка?
Оба теста «прошли», итог чистый — а следом приходит Uncaught (in promise) с тем самым сообщением. async-функция не бросает: она сразу возвращает промис, fn() завершается без исключения, и try/catch ставит галочку. Ожидание упало позже, внутри промиса, у которого не нашлось хозяина, — это необработанное отклонение. Раннер к тому времени уже подвёл итог.
Чинится двумя словами: run становится async и ждёт каждый тест — await fn(). Синхронным тестам это не мешает: await над значением, которое не промис, просто продолжает функцию. Заодно тесты идут строго по одному, и каждый начинается, когда предыдущий закончился.
Но у ожидания своя беда — тест, который не закончится никогда. Забытый resolve, событие, которое не придёт, сервер, который молчит, — и раннер, честно ждущий промис, повиснет вместе с тестом. Поэтому у каждого теста есть срок: Promise.race между тестом и таймером, который отклоняется через заданное число миллисекунд. Прогноз: что раннер напечатает про каждый из трёх тестов и дойдёт ли он до третьего?
Провал первого теста теперь в своей строке и со своим сообщением. Второй получил вердикт «не закончился за 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 напечатает вместо нашего «ожидалось …, получено …» и с каким кодом завершится процесс?
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); });
✔ среднее трёх чисел (<мс>)
✖ среднее пустого списка — 0 (<мс>)
ℹ tests 2
ℹ suites 0
ℹ pass 1
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms <мс>
✖ failing tests:
test at average.test.mjs:9:1
✖ среднее пустого списка — 0 (<мс>)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
NaN !== 0
at TestContext.<anonymous> (file://<пример>/average.test.mjs:11:10)
at <внутренние кадры node> {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: NaN,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}Упал тест пустого списка: сумма пустого — 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 мс, и сколько настоящего времени займёт тест?
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); });
через 299 мс после «кот»: [] ещё через 1 мс: [ 'кот' ] настоящих миллисекунд меньше 100: true ✔ поиск уходит один раз — после паузы в 300 мс (<мс>) ℹ tests 1 ℹ suites 0 ℹ pass 1 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms <мс>
Через 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 --test | Vitest | jest | |
|---|---|---|---|
откуда describe и it | import из node:test | import из vitest; глобальными — настройкой globals | глобальные, без импорта |
когда выполняется тело describe | сразу, пока файл читается | после того как файл дочитан | сразу, пока файл читается |
| ожидание | assert из node:assert/strict | expect | expect |
| срок теста по умолчанию | нет; --test-timeout | 5000 мс | 5000 мс |
| TypeScript | стирание типов в самом Node | преобразование через Vite | преобразователь, в apps/api — ts-jest |
| подмена таймеров | t.mock.timers | vi.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, должна выполняться перед каждым тестом, а если упадёт она сама — падает тест, а не весь прогон.
Функции подготовки складываются в свой список, а run вызывает их перед каждым тестом — внутри того же try, что и сам тест:
Оба теста проходят: перед каждым cart получает новый пустой массив. Подготовка стоит внутри try — если она упадёт, крестик получит свой тест, а прогон пойдёт дальше.
У этого beforeEach одно упрощение: подготовка общая для всего файла. У настоящих раннеров beforeEach принадлежит describe, в котором вызван, и тесты соседнего describe её не видят. Чтобы так сделать, it должен запоминать не только имя, но и подготовку своей группы на момент записи.
toThrow, который обманывает сам себя
Ожидание toThrow(text) проверяет, что функция бросает ошибку с нужным текстом. Первые два теста честные. Третий нарочно неправильный: pageOf("page=2") ничего не бросает, и тест обязан упасть с сообщением «функция не бросила». Запустите: упасть он упадёт, но сообщение будет другим. Найдите, где toThrow ловит сам себя, и почините.
Сейчас третий тест падает с сообщением «бросила «функция не бросила», а ждали «от 1»». Ошибка «не бросила» брошена внутри try, и её поймал catch того же ожидания: для него это просто ошибка с неподходящим текстом. Хуже того, если бы искомый текст входил в «функция не бросила» — скажем, toThrow("бросила"), — тест бы прошёл.
Своя ошибка ожидания должна бросаться за пределами try:
Теперь третий тест падает с функция не бросила, а первые два проходят, как прежде.
toEqual, которого не обманет порядок ключей
Сравнение по содержимому напрашивается через JSON.stringify: объекты одинаковые — одинаковые и строки. Две проверки ниже показывают, где это врёт. Перепишите equal так, чтобы все четыре строки напечатали то, что нужно. Массивы остаются чувствительными к порядку, объекты — нет.
JSON.stringify пишет ключи в порядке их появления, поэтому { page, limit } и { limit, page } дают разные строки. А NaN он превращает в null — и две разные вещи становятся одной строкой. Сравнивать нужно значения, а не их запись:
Примитивы сравниваются через Object.is, как в toBe. У объектов сверяется число ключей, а потом каждое значение — тем же equal, рекурсивно. Порядок ключей в объекте не участвует, а у массивов ключи — индексы, поэтому ["a", "b"] и ["b", "a"] различаются. Настоящий toEqual вдобавок пропускает поля со значением undefined, а toStrictEqual — нет.