open atlas
↑ К треку
NestJS с нуля до senior NEST · 06 · 05

Тестирование асинхронного кода и борьба с flaky-тестами

Асинхронные тесты падают, когда проверка обгоняет работу, которую проверяет. Всегда await; подмени часы fake-таймерами (и слей микротаски); жди обработчик, а не setTimeout(50). Flaky-тест — это баг-репорт о недетерминизме: чини гонку, а не глуши её retryTimes(3).

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Тест проходил тысячу раз. it('notifies on signup') эмитил событие user.created и проверял, что почтовый сервис вызвали — зелёный на каждом ноутбуке, зелёный в CI. И вот во вторник он стал красным. Никто его не трогал. Перезапуск снова сделал его зелёным, поэтому кто-то вписал jest.retryTimes(3) в setup-файл, и сьют притих. То, что заглушил этот retry, было настоящей гонкой: обработчик события выполнялся на следующем тике, проверка выполнялась сейчас, и на нагруженной CI-машине обработчик иногда проигрывал забег. Та же гонка жила в продакшен-коде — два слушателя мутировали общий агрегат. Через шесть недель она под нагрузкой испортила балансы. Flaky-тест был не шумом. Он был баг-репортом, поданным заранее, который мы заретраили в тишину. Этот урок — о том, как тестировать асинхронный код так, чтобы он не мог гоняться, и как относиться к flakiness как к дефекту, которым она и является.

Почему async-тесты гоняются: непрожданная проверка

Корень провала прост: если тест не делает await асинхронной работы, которую проверяет, проверка выполняется до того, как работа завершится. Ожидание сверяется со старым состоянием — поэтому оно либо проходит по неправильной причине, либо падает время от времени, либо болтающийся промис утекает в следующий тест и портит его. Вернуть или дождаться промиса — это контракт, который говорит раннеру «жди вот это».

// WRONG — fire-and-forget: the assertion runs before saveUser resolves
it('creates a user', () => {
  service.createUser(dto);          // promise dropped on the floor
  expect(repo.save).toHaveBeenCalled(); // races the await — passes/fails by luck
});

// RIGHT — await the work, then assert against settled state
it('creates a user', async () => {
  await service.createUser(dto);    // runner waits for this promise
  expect(repo.save).toHaveBeenCalledWith(dto);
});

Используй форму async/await, а не легаси-колбэк done, если только тебе по-настоящему не нужен done для события, которое не превратить в промис — и даже тогда вызывай done(err) при ошибке, иначе брошенная проверка висит до таймаута. Правило абсолютно: никогда не делай fire-and-forget асинхронного вызова в тесте. Каждый промис, который ты создаёшь, ты ждёшь или возвращаешь.

Fake-таймеры: превращаем 30 секунд в ноль

Реальный код использует setTimeout, setInterval, debounce и retry-with-backoff. Тест, который пускает это на реальных часах, либо ждёт реальные секунды, либо, хуже, проверяет до того, как таймер сработал. jest.useFakeTimers() подменяет глобальные часы теми, которыми управляешь ты. jest.advanceTimersByTimeAsync(ms) и jest.runAllTimersAsync() детерминированно перематывают время вперёд — 30-секундный backoff прогоняется заметно меньше чем за миллисекунду.

// Тестируемый сервис: ретрай с экспоненциальным backoff, максимум 3 попытки
async function callWithBackoff(fn: () => Promise<string>): Promise<string> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= 2) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); // 1s, 2s
    }
  }
}

it('retries twice with backoff then succeeds', async () => {
  jest.useFakeTimers();
  const fn = jest
    .fn<Promise<string>, []>()
    .mockRejectedValueOnce(new Error('flap'))
    .mockRejectedValueOnce(new Error('flap'))
    .mockResolvedValueOnce('ok');

  const promise = callWithBackoff(fn);     // kicks off attempt 0, hits the 1s wait

  await jest.advanceTimersByTimeAsync(1000); // fast-forward the 1s backoff
  await jest.advanceTimersByTimeAsync(2000); // fast-forward the 2s backoff

  await expect(promise).resolves.toBe('ok');
  expect(fn).toHaveBeenCalledTimes(3);
});

Ловушка — смешивать fake-таймеры с реальными промисами. setTimeout подделан, но await между попытками — это реальный микротаск: перемотка таймера запускает колбэк, но прожданное продолжение выполнится, только когда очередь микротасков сольётся. Варианты таймерных API с суффиксом *Async сливают за тебя микротаски между перемотками; синхронный advanceTimersByTime — нет, поэтому продолжение никогда не выполняется и тест либо висит, либо проверяет недоделанное состояние. Используй асинхронные таймерные API всегда, когда между твоими таймерами стоят реальные промисы.

Почему это работает

Почему jest.retryTimes(3) — неправильное лекарство для flaky-теста? Потому что flakiness и есть недетерминизм, а retry маскирует его, не убирая гонку — та же гонка по-прежнему живёт в продакшене, где никакого retry нет. Тест, который проходит только с ретраем, говорит тебе нечто конкретное: в коде или тесте есть настоящий баг порядка или таймингов. Заглуши его — и ты отгружаешь баг, теперь невидимый. Retry меняет красный CI сегодня на скрытую порчу данных потом и активно уничтожает сигнал — единственное место, где гонку было дёшево поймать. Лекарство — детерминизм: подделай таймеры, изолируй и сбрасывай общее состояние, правильно жди работу, засей случайность. Сделай тест воспроизводимым, и он не сможет flake-ать, потому что гоняться будет нечему.

Тестирование очередей и событий: жди обработчик, не спи на нём

@nestjs/event-emitter и BullMQ (библиотека очередей задач для Node.js на основе Redis) оба по дизайну запускают обработчики асинхронно. Тест, который эмитит событие и сразу проверяет, гоняется со слушателем — ровно тот инцидент с регистрацией. Есть соблазнительный, сломанный фикс: await new Promise(r => setTimeout(r, 50)) перед проверкой, в надежде, что обработчик закончил. На быстрой машине — закончил; на нагруженной CI-машине — нет. Этот setTimeout(50) и есть flakiness.

// FLAKY — emit then assert immediately races the async listener
emitter.emit('user.created', user);
expect(mailer.send).toHaveBeenCalled();   // listener may not have run yet

// FLAKY — the "fix" that just lowers the failure rate, never to zero
emitter.emit('user.created', user);
await new Promise((r) => setTimeout(r, 50)); // hope, not guarantee
expect(mailer.send).toHaveBeenCalled();

// DETERMINISTIC — call the consumer directly and await it
await listener.handleUserCreated(user);   // no event bus, no race
expect(mailer.send).toHaveBeenCalledWith(user.email);

// DETERMINISTIC — or await a signal the handler resolves
const done = new Promise((res) => emitter.once('email.sent', res));
emitter.emit('user.created', user);
await done;                                 // resolves only after the work
expect(mailer.send).toHaveBeenCalled();

Для BullMQ раздели задачу: в юнит-тесте проверь, что джоба была поставлена в очередь (expect(queue.add).toHaveBeenCalledWith('send-email', payload)), и отдельно юнит-тестируй метод process процессора напрямую с фейковым Job. Если нужен end-to-end путь — запусти настоящий воркер в тесте и дождись его события completed, никогда не опрашивай таймаутом. Принцип один: замени «подожди и понадейся» детерминированным сигналом, который ты реально ждёшь.

Flaky-тест как баг-репорт

Flaky-тест проходит ~95% запусков. Воспринимай это число как уровень серьёзности дефекта, а не как допуск. Корневых причин — короткий список:

  1. ТаймингsetTimeout-ожидания, реальные часы, реальная сетевая задержка. Фикс: fake-таймеры, awaited-сигналы.
  2. Общее изменяемое состояние — статик на уровне модуля, реальная строка в БД, синглтон — в сочетании с зависимостью от порядка тестов или параллельными воркерами, наступающими друг другу на ноги. Фикс: сбрасывай состояние в beforeEach, изолируй данные на тест, не дели записываемый синглтон.
  3. Реальное IO и случайность — wall-clock время, Math.random(), сеть. Фикс: инжекти и стабь часы/RNG, засевай случайность, мокай границу.
  4. Непрожданный async — промис из теста A, оседающий во время теста B. Фикс: жди всё; роняй сьют на необработанных rejection.

Все четыре корневых причины имеют одну структуру: что-то гоняется с чем-то ещё. Диагностируй, какая пара гоняется, примени нужный фикс — и flakiness исчезнет не потому, что ты дал больше времени, а потому что убрал недетерминизм полностью.

Выбери лучший вариант

Тест для обработчика события проходит примерно в 90% случаев и падает в остальных. CI красный с перебоями, и команда начинает его игнорировать. Что ты делаешь?

Антипаттерн, который связывает их вместе, — потянуться за jest.retryTimes(3), чтобы «стабилизировать» сьют. Он работает в узком смысле — CI зеленеет — и проваливается во всех смыслах, которые важны: гонка по-прежнему в проде, сигнал потерян, а следующий инженер доверяет сьюту, который лжёт. Сеньорская позиция — flaky-тест есть отчёт о дефекте недетерминизма, в тесте или в коде, и единственный верный ответ — восстановить детерминизм. Цифры делают ставки конкретными. Один тест с проходимостью 95% в сьюте из 1000 тестов заставляет весь сьют падать постоянно — независимые провалы складываются, поэтому зелёная доля сьюта обваливается заметно ниже доли любого отдельного теста. И человеческая цена хуже цены CI: стоит сьюту начать флакать, инженеры учатся перезапускать красное, и в день, когда настоящая регрессия становится красной, они перезапускают и её. Flakiness не просто тратит минуты; она тренирует команду игнорировать тревогу.

Викторина

Тест эмитит событие `user.created` на @nestjs/event-emitter, а на следующей строке проверяет, что почтовик вызвали. Локально проходит, но в CI падает примерно в 10% случаев. Почему?

Викторина

Нужно протестировать путь ретрая с 30с экспоненциального backoff так, чтобы тест не занимал 30 секунд. Что делаешь и какая единственная засада?

Вспомните перед уходом
  1. 01
    Почему async-тесты гоняются и как детерминированно протестировать (а) 30с backoff-ретрай и (б) обработчик события/очереди?
  2. 02
    Что такое flaky-тест на самом деле, каковы его четыре корневые причины и почему jest.retryTimes(3) — неправильное лекарство?
Итог

Async-тесты гоняются, когда проверка выполняется до того, как завершится async-работа, которую она проверяет: fire-and-forget вызов оставляет ожидание сверяющимся со старым состоянием, поэтому оно проходит по неправильной причине, падает время от времени или утекает болтающимся промисом в следующий тест — всегда возвращай или жди промис, используй async/await вместо легаси-done. Чтобы тестировать код с setTimeout/setInterval/debounce/backoff без ожидания реальных секунд, подмени часы через jest.useFakeTimers() и перематывай через jest.advanceTimersByTimeAsync(ms) или runAllTimersAsync(), превращая 30с backoff в заметно меньше миллисекунды на реальном продакшен-расписании; ловушка в том, что fake-таймеры не сливают микротаски, поэтому ты обязан использовать *Async API, чтобы слить их между перемотками, иначе прожданное продолжение никогда не выполнится. Обработчики @nestjs/event-emitter и BullMQ выполняются асинхронно, поэтому emit-then-assert гоняется со слушателем, а await setTimeout(50) лишь снижает частоту флака — вместо этого вызови метод консьюмера напрямую и дождись его или дождись сигнала, который резолвит обработчик; для Bull проверь, что джоба поставлена в очередь, и отдельно юнит-тестируй процессор, запуская настоящий воркер только для end-to-end и дожидаясь completed. Flaky-тест (~95% pass) — это баг-репорт о недетерминизме с четырьмя корневыми причинами — тайминг, общее изменяемое состояние, реальное IO/случайность, непрожданный async — у каждой свой фикс детерминизма (fake-таймеры, изоляция/сброс, стаб+засев, await). Антипаттерн — jest.retryTimes(3): он зеленит CI, пряча гонку, которая всё равно отгружается в продакшен, где retry нет, и уничтожает единственный дешёвый сигнал о том, что гонка существует. Один тест с проходимостью 95% в сьюте из 1000 тестов флакает весь сьют постоянно и тренирует команду игнорировать красное. Теперь, когда увидишь jest.retryTimes(3) в setup-файле или комментарий «sleep, чтобы дождаться обработчик», — ты знаешь: это не стабильность, это заглушённый баг-репорт, ждущий своего часа в продакшене. Чини детерминизм.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.