Тесты в Node.js: типичные ошибки, которые ломают CI
Флаки-тесты, забытый await, общее состояние между кейсами и таймеры, которые никто не мокал. Разбираем ошибки, из-за которых зелёный прогон ничего не подтверждает.

Тест прошёл локально, упал в CI, потом снова прошёл после рестарта пайплайна — знакомая картина. В девяти случаях из десяти виноват не сам код, а то, как написан тест: забытый await, состояние, протекающее между кейсами, реальные таймеры вместо фейковых. Разберём ошибки, из-за которых зелёный прогон перестаёт что-либо значить, и как их чинить. Примеры даны для встроенного node:test (стабилен с Node.js 20.x) и для Jest 29.x — где различия важны, они отмечены.
Ошибка 1. Асинхронный тест без ожидания
Самая частая. Раннер считает тест пройденным, как только функция вернула управление. Если внутри висит промис без await, ассерты выполнятся уже после того, как раннер записал результат — то есть их провал никто не увидит.
const test = require('node:test');
const assert = require('node:assert');
// Плохо: тест зелёный, даже если fetchUser вернёт мусор
test('загрузка пользователя', () => {
fetchUser(1).then((user) => {
assert.strictEqual(user.name, 'Аня');
});
});
// Хорошо: раннер дожидается промиса
test('загрузка пользователя', async () => {
const user = await fetchUser(1);
assert.strictEqual(user.name, 'Аня');
});
Признак болезни: тест никогда не падает, даже если сломать проверяемую логику. Прежде чем доверять новому асинхронному тесту, один раз намеренно сломайте ассерт и убедитесь, что он краснеет.
Смешение колбэков и промисов
Если функция принимает колбэк завершения (в Jest — параметр done), нельзя одновременно возвращать промис. Раннер не знает, чего ждать, и поведение становится непредсказуемым. Выбирайте что-то одно: либо async/await, либо колбэк.
Ошибка 2. Общее состояние между тестами
Тесты должны проходить в любом порядке и по отдельности. Если один кейс пишет в модульную переменную, а следующий на неё рассчитывает, вы получаете зависимость от порядка — и падение, как только раннер запустит файлы параллельно или отфильтрует один тест по имени.
const test = require('node:test');
const assert = require('node:assert');
let cart = []; // общее состояние — источник флаки
test('добавление товара', () => {
cart.push('книга');
assert.strictEqual(cart.length, 1);
});
// Упадёт, если запустить в одиночку: cart уже пустой,
// а если после первого теста — length станет 2
test('второй товар', () => {
cart.push('ручка');
assert.strictEqual(cart.length, 2);
});
Лечение — сбрасывать состояние в хуках. В node:test это beforeEach, в Jest — он же, плюс jest.clearAllMocks() при использовании моков.
const { test, beforeEach } = require('node:test');
const assert = require('node:assert');
let cart;
beforeEach(() => {
cart = []; // чистое состояние перед каждым кейсом
});
test('добавление товара', () => {
cart.push('книга');
assert.strictEqual(cart.length, 1);
});
Тест, который проходит только вместе с соседями и только в этом порядке, — не тест, а совпадение. Проверка на изоляцию: запустите файл с флагом фильтра по одному кейсу и убедитесь, что он всё ещё зелёный.
Ошибка 3. Реальные таймеры и реальное время
Тест, который делает setTimeout на 5 секунд и ждёт, замедляет прогон и рано или поздно упрётся в таймаут раннера под нагрузкой CI. Ещё хуже — тесты, зависящие от Date.now(): они ломаются в полночь, в високосный год или при смене таймзоны на раннере.
В Jest фейковые таймеры включаются через jest.useFakeTimers() и продвигаются вручную. В node:test есть mock.timers (доступен с Node.js 20.4.0) — включается через context.mock.timers.enable().
const { test } = require('node:test');
const assert = require('node:assert');
test('отложенный вызов', (t) => {
// включаем мок таймеров для этого теста
t.mock.timers.enable({ apis: ['setTimeout'] });
let done = false;
setTimeout(() => { done = true; }, 5000);
// продвигаем виртуальное время мгновенно
t.mock.timers.tick(5000);
assert.strictEqual(done, true);
});
Точный набор поддерживаемых API и сигнатуру tick сверяйте с документацией вашей версии Node.js — набор расширялся между минорными релизами.
Ошибка 4. Проверка деталей реализации вместо результата
Мок вызвали правильное число раз, но пользователь всё равно видит ошибку — типичный симптом теста, который прибит гвоздями к внутренностям. Такой тест краснеет при любом рефакторинге, даже если поведение не изменилось, и не ловит реальные баги. Проверяйте наблюдаемый результат: что вернула функция, что записалось в базу, какой ответ ушёл клиенту.
- Хрупко: «метод
saveвызван с этим объектом» — сломается, как только вы поменяете форму объекта внутри. - Устойчиво: «после вызова в хранилище лежит запись с таким id и статусом» — проверяет то, ради чего код существует.
Ошибка 5. Ассерты внутри непойманного колбэка
Если ассерт бросает исключение внутри обработчика события или колбэка, который раннер не оборачивает, тест может завершиться зелёным, а процесс — упасть с необработанной ошибкой позже. Особенно коварно с EventEmitter.
const { test } = require('node:test');
const assert = require('node:assert');
const { EventEmitter } = require('node:events');
test('событие data приходит', (t, done) => {
const emitter = new EventEmitter();
emitter.on('data', (value) => {
try {
assert.strictEqual(value, 42);
done(); // успех
} catch (err) {
done(err); // явно передаём провал раннеру
}
});
emitter.emit('data', 42);
});
Где возможно, замените слушатель события на промис через events.once(emitter, 'data') и await — тогда исключение поймает раннер сам.
Ошибка 6. Незакрытые ресурсы
Открытое соединение с базой, поднятый HTTP-сервер, живой таймер — всё это держит event loop, и процесс не завершается. Jest пометит это как «open handle», а node:test может просто зависнуть до таймаута. Закрывайте всё в afterEach или afterAll.
const { test, before, after } = require('node:test');
const http = require('node:http');
let server;
before(() => new Promise((resolve) => {
server = http.createServer();
server.listen(0, resolve); // порт 0 — ОС выберет свободный
}));
after(() => new Promise((resolve) => {
server.close(resolve); // без этого процесс не завершится
}));
node:test и Jest: где отличается поведение
| Аспект | node:test (Node 20+) | Jest 29.x |
|---|---|---|
| Установка | встроен, зависимостей нет | отдельный пакет + конфиг |
| Изоляция модулей | общий кэш модулей в файле | свежий реестр модулей на файл |
| Фейковые таймеры | mock.timers (с 20.4.0) | jest.useFakeTimers() |
| Автосброс моков | вручную | опция clearMocks в конфиге |
| Мок модулей | ограничен, развивается | jest.mock() из коробки |
Главное следствие для тестов: в Jest каждый файл получает свежий реестр модулей, поэтому состояние на уровне модуля не протекает между файлами. В node:test при запуске нескольких файлов в одном процессе такой изоляции нет — не полагайтесь на неё, чистите состояние явно.
Что делать дальше
Первым делом настройте детектор флаки: прогоните весь набор с флагом повтора несколько раз подряд и посмотрите, какие кейсы падают через раз. Затем добавьте в CI обнаружение незакрытых хендлов — в Jest это --detectOpenHandles, для node:test следите за тем, чтобы прогон завершался сам, без принудительного таймаута. И заведите привычку один раз ломать каждый новый тест, чтобы убедиться, что он вообще способен покраснеть.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Node.js — Test runner (node:test), Jest — Documentation
Частые вопросы
У меня старая версия Node, mock.timers нет — что делать?
Тест проходит локально, но падает в CI — почему?
Как понять, что тест ничего не проверяет?
Нужно ли переходить с Jest на node:test?
Как избавиться от флаки-тестов навсегда?
Можно ли запускать асинхронные тесты параллельно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.