Testing9 min read

Testing Async Code: Avoiding Flakiness and False Positives in JavaScript

Async testing is full of traps: unhandled rejections, race conditions, and tests that pass when they shouldn't. Here's how to write reliable async tests in JavaScript.

async testingJavaScriptNode.jsjestpromisesflaky tests

Async code is the default in modern JavaScript — promises, async/await, callbacks. But testing async code is still a minefield. I've seen tests that pass when they should fail, fail randomly, or take 10 seconds to run because someone used real timeouts. The worst part: most developers don't realize their async tests are broken until something blows up in production.

This post covers the patterns I use to write async tests that are fast, deterministic, and actually catch bugs. No fluff, just practical advice with real code.

The Silent Failure: Unawaited Promises

The most common mistake I see is forgetting to await a promise inside a test. The test completes before the promise settles, so the assertion never runs. The test passes even if the promise rejects or produces wrong results.

Consider this test for a function that fetches user data:

This test passes because expect runs synchronously on a promise object, not the resolved value.
// ❌ Broken test - passes even if fetchUser fails
test('fetchUser returns user', () => {
  const user = fetchUser(1); // returns a promise
  expect(user.name).toBe('Alice'); // runs before promise resolves
});

The fix is simple: always return or await the promise:

Now the test waits for the promise and asserts on the actual value.
// ✅ Correct
test('fetchUser returns user', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});
warning

If you use Jest, enable jest/no-unhandled-promise-rejection ESLint rule. It catches unawaited promises in tests.

Testing Rejection Paths Correctly

Testing that an async function rejects is trickier. Many developers write something like this:

If fetchUser resolves instead of rejects, fail('should have thrown') is never called because the test exits.
// ❌ This test can pass when it shouldn't
test('fetchUser throws on error', async () => {
  try {
    await fetchUser(-1);
    fail('should have thrown');
  } catch (e) {
    expect(e.message).toMatch(/not found/);
  }
});

The problem: if fetchUser resolves, the catch block never runs, and the test finishes without failing. The fail() call is synchronous and works, but many developers don't realize they need it. A cleaner approach:

This is explicit and automatically fails if the promise resolves.
// ✅ Preferred pattern
test('fetchUser throws on error', async () => {
  await expect(fetchUser(-1)).rejects.toThrow(/not found/);
});
lightbulb

Use .rejects.toThrow() for async functions. For more granular error checks, combine with expect.objectContaining() or custom matchers.

Fake Timers: The Cure for Flaky Timeouts

Real timeouts in tests are a disaster. They make tests slow and flaky — a CI server under load might fail a test that expects a 100ms delay. Always mock timers when testing code that uses setTimeout, setInterval, or Date.now.

Here's a pattern I use with Jest:

Using fake timers makes the test run in milliseconds and eliminates timing flakiness.
jest.useFakeTimers();

test('polling stops after timeout', () => {
  const onComplete = jest.fn();
  startPolling(onComplete);
  
  // Fast-forward 5 seconds
  jest.advanceTimersByTime(5000);
  
  expect(onComplete).toHaveBeenCalledTimes(1);
});

// After all tests, restore real timers
jest.useRealTimers();
info

Always call jest.useRealTimers() in afterEach or afterAll if you use fake timers. Otherwise, other tests might break.

A War Story: The Stale Data Bug

The Race Condition That Cost a Feature Release

  1. 10:00Team merges a PR that adds optimistic UI updates with async data fetching.
  2. 10:15All tests pass in CI — including a new test for the async update logic.
  3. 14:00UI shows stale data after updates. User reports visible flicker.
  4. 16:30Root cause: the test awaited the first promise but not a second internal promise triggered by a state change.

Lesson

The test passed because the second promise was never awaited. The test finished too early. After that, I started using a custom helper that tracks all pending promises and waits for them before asserting.

The fix was to ensure all async operations are awaited. Here's a helper I now use:

This works because setImmediate yields to the microtask queue.
// Helper to flush all pending promises
function flushPromises() {
  return new Promise(resolve => setImmediate(resolve));
}

// In test:
test('update triggers re-fetch', async () => {
  updateData({ id: 1 });
  await flushPromises();
  expect(fetchMock).toHaveBeenCalledWith('/api/data/1');
});

Testing Race Conditions with Controlled Timing

Race conditions are hard to reproduce. The key is to mock async dependencies so you control when each promise resolves. I use a simple delay function to simulate specific timing:

By controlling the delay, you can test intermediate states reliably.
function delayedResolve(value, ms) {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}

// Mock to return a delayed result
api.fetchUser = jest.fn(() => delayedResolve(user, 100));

// Test that loading state is shown during fetch
test('shows loading while fetching', async () => {
  const promise = fetchUserAndUpdate(1);
  expect(isLoading()).toBe(true);
  await promise;
  expect(isLoading()).toBe(false);
});

What About Callbacks?

For callback-based async code, use done callback in Jest:

If done is never called, the test times out. Always call done() after assertions.
test('readFile contents', done => {
  readFile('test.txt', (err, data) => {
    expect(data).toBe('hello');
    done();
  });
});
warning

Never mix callbacks and promises in the same test. Convert callbacks to promises using util.promisify for cleaner tests.

Summary: Async Testing Checklist

  • arrow_rightReturn or await every promise in an async test.
  • arrow_rightUse .rejects.toThrow() for testing rejection paths.
  • arrow_rightReplace real timers with fake timers (jest.useFakeTimers).
  • arrow_rightMock all async dependencies to control resolution timing.
  • arrow_rightUse flushPromises() to ensure all microtasks complete.
  • arrow_rightFor callbacks, use the done callback and call it after assertions.

Frequently asked questions

Why does my async test pass even when the code has a bug?

If you don't return or await a promise in an async test, the test function finishes before the promise resolves. The test runner sees no failure, so the test passes regardless of the promise outcome. Always await or return the promise.

How do I test a function that uses setTimeout?

Use fake timers (jest.useFakeTimers()) to control time. Advance time with jest.advanceTimersByTime() instead of waiting for real time. This makes tests deterministic and fast.

What's the best way to test error handling in async code?

Use expect().rejects.toThrow() for async functions that reject. For callbacks, use a custom error handler spy. Make sure to test both the error type and message.

How can I prevent flaky tests due to race conditions?

Mock all async dependencies to return controlled promises. Use a queue or a delay function to control the order of resolution. Avoid real timers and network calls.