open atlas
↑ Back to track
NestJS, zero to senior NEST · 06 · 05

Testing async code and killing flaky tests

Async tests fail when the assertion races the work it checks. Always await; replace the clock with fake timers (and flush microtasks); await handlers, never setTimeout(50). A flaky test is a bug report about non-determinism — fix the race, never retryTimes(3) it away.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The test had passed a thousand times. it('notifies on signup') emitted a user.created event and asserted the email service was called — green on every laptop, green in CI. Then one Tuesday it went red. Nobody had touched it. A rerun made it green again, so someone typed jest.retryTimes(3) into the setup file and the suite went quiet. What that retry silenced was a real race: the event handler ran on the next tick, the assertion ran now, and on a loaded CI box the handler sometimes lost the foot race. The same race lived in the production code — two listeners mutating a shared aggregate. Six weeks later it corrupted balances under load. The flaky test wasn’t noise. It was a bug report, filed in advance, that we retried into silence. This lesson is about testing async code so it can’t race, and treating flakiness as the defect it is.

Why async tests race: the unawaited assertion

The core failure is simple: if a test does not await the async work it asserts on, the assertion runs before the work finishes. The expectation evaluates against the old state — so it either passes for the wrong reason, fails intermittently, or the dangling promise leaks into the next test and corrupts it. Returning or awaiting the promise is the contract that tells the test runner “wait for this.”

// 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);
});

Use the async/await form, not the legacy done callback, unless you genuinely need done for an event you can’t turn into a promise — and even then, call done(err) on failure or a thrown assertion hangs until the timeout. The rule is absolute: never fire-and-forget an async call in a test. Every promise you create, you await or return.

Fake timers: turning 30 seconds into zero

Real code uses setTimeout, setInterval, debounce, and retry-with-backoff. A test that lets those run on the real clock either waits real seconds or, worse, asserts before the timer fires. jest.useFakeTimers() swaps the global clock for one you drive. jest.advanceTimersByTimeAsync(ms) and jest.runAllTimersAsync() fast-forward time deterministically — a 30-second backoff is exercised in well under a millisecond.

// Service under test: retry with exponential backoff, capped at 3 attempts
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);
});

The trap is mixing fake timers with real promises. The setTimeout is faked, but the await between attempts is a real microtask — advancing the timer fires the callback, but the awaited continuation only runs when the microtask queue drains. The *Async variants of the timer APIs flush microtasks for you between advances; the synchronous advanceTimersByTime does not, so the continuation never runs and the test hangs or asserts on a half-finished state. Use the async timer APIs whenever real promises sit between your timers.

Why this works

Why is jest.retryTimes(3) the wrong fix for a flaky test? Because flakiness is non-determinism, and retrying masks it without removing the race — the same race still exists in production, where there is no retry. A test that only passes on retry is telling you something concrete: the code or the test has a real ordering or timing bug. Silence it and you ship the bug, now invisible. Retry trades a red CI today for a latent corruption later, and it actively destroys the signal — the one place the race was cheap to catch. The fix is determinism: fake the timers, isolate and reset shared state, await the work properly, seed the randomness. Make the test reproducible, then it can’t flake because there’s nothing left to race.

Testing queues and events: await the handler, don’t sleep on it

@nestjs/event-emitter and BullMQ both run handlers asynchronously by design. A test that emits an event and immediately asserts races the listener — exactly the signup incident. There is a tempting, broken fix: await new Promise(r => setTimeout(r, 50)) before asserting, hoping the handler finished. On a fast machine it does; on a loaded CI box it doesn’t. That setTimeout(50) is the 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();

For BullMQ, split the concern: in the unit test assert the job was enqueued (expect(queue.add).toHaveBeenCalledWith('send-email', payload)) and unit-test the processor’s process method directly with a fake Job. If you need the end-to-end path, run a real worker in the test and await its completed event — never poll with a timeout. The principle is one: replace “wait and hope” with a deterministic signal you actually await.

The flaky test as a bug report

A flaky test passes ~95% of runs. Treat that number as a defect severity, not a tolerance. The root causes are a short list:

  1. TimingsetTimeout-based waits, real clock, real network latency. Fix: fake timers, awaited signals.
  2. Shared mutable state — a module-level static, a real DB row, a singleton — combined with test-order dependence or parallel workers stepping on each other. Fix: reset state in beforeEach, isolate per-test data, don’t share a writable singleton.
  3. Real IO and randomness — wall-clock time, Math.random(), network. Fix: inject and stub the clock/RNG, seed randomness, mock the boundary.
  4. Unawaited async — a promise from test A settling during test B. Fix: await everything; fail the suite on unhandled rejections.

All four root causes share one structure: something races something else. Diagnose which pair is racing, apply the matching fix, and the flakiness disappears — not because you gave it more time, but because you removed the non-determinism entirely.

Pick the best fit

A test for an event handler passes about 90% of the time and fails the rest. CI is red intermittently and the team is starting to ignore it. What do you do?

The anti-pattern that ties them together is reaching for jest.retryTimes(3) to “stabilize” the suite. It works in the narrow sense — CI goes green — and fails in every sense that matters: the race is still in prod, the signal is gone, and the next engineer trusts a suite that lies. The senior stance is that a flaky test is a defect report about non-determinism, in the test or the code, and the only correct response is to restore determinism. The numbers make the stakes concrete. A single 95%-pass test in a 1000-test suite makes the whole suite fail constantly — independent failures compound, so the suite’s green rate collapses well below any single test’s. And the human cost is worse than the CI cost: once a suite flakes, engineers learn to rerun red, and the day a real regression goes red, they rerun that too. Flakiness doesn’t just waste minutes; it trains the team to ignore the alarm.

Quiz

A test emits a `user.created` event on @nestjs/event-emitter, then on the next line asserts the mailer was called. It passes locally but fails ~10% of the time in CI. Why?

Quiz

You must test a retry path with 30s of exponential backoff without the test taking 30 seconds. What do you do, and what's the one gotcha?

Recall before you leave
  1. 01
    Why do async tests race, and how do you test (a) a 30s backoff retry and (b) an event/queue handler deterministically?
  2. 02
    What is a flaky test really, what are its four root causes, and why is jest.retryTimes(3) the wrong fix?
Recap

Async tests race when the assertion runs before the async work it checks finishes: a fire-and-forget call leaves the expectation evaluating old state, so it passes for the wrong reason, fails intermittently, or leaks a dangling promise into the next test — always return or await the promise, use async/await over the legacy done. To test code with setTimeout/setInterval/debounce/backoff without waiting real seconds, replace the clock with jest.useFakeTimers() and fast-forward with jest.advanceTimersByTimeAsync(ms) or runAllTimersAsync(), turning a 30s backoff into well under a millisecond on the real production schedule; the trap is that fake timers don’t drain microtasks, so you must use the *Async APIs to flush them between advances or the awaited continuation never runs. @nestjs/event-emitter and BullMQ handlers run asynchronously, so emit-then-assert races the listener and await setTimeout(50) only lowers the flake rate — instead call the consumer method directly and await it, or await a signal the handler resolves; for Bull, assert the job was enqueued and unit-test the processor separately, running a real worker only for end-to-end and awaiting completed. A flaky test (~95% pass) is a bug report about non-determinism with four root causes — timing, shared mutable state, real IO/randomness, unawaited async — each with a determinism fix (fake timers, isolate/reset, stub+seed, await). The anti-pattern is jest.retryTimes(3): it greens CI while hiding a race that still ships to production, where there is no retry, and it destroys the one cheap signal the race exists. A single 95%-pass test in a 1000-test suite flakes the whole suite constantly and trains the team to ignore red. Now when you see jest.retryTimes(3) in a setup file, or a test comment that says “sleep to wait for handler” — you’ll know: that’s not stability, that’s a muted bug report waiting to surface in production. Fix the determinism instead.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.