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:
// ❌ 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:
// ✅ Correct
test('fetchUser returns user', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});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:
// ❌ 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:
// ✅ Preferred pattern
test('fetchUser throws on error', async () => {
await expect(fetchUser(-1)).rejects.toThrow(/not found/);
});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:
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();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
- 10:00Team merges a PR that adds optimistic UI updates with async data fetching.
- 10:15All tests pass in CI — including a new test for the async update logic.
- 14:00UI shows stale data after updates. User reports visible flicker.
- 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:
// 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:
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:
test('readFile contents', done => {
readFile('test.txt', (err, data) => {
expect(data).toBe('hello');
done();
});
});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.