JavaScript Testing Async Code: Promises and Timers
Async code is where test suites go to die. You write what looks like a perfectly reasonable test, it passes, and six months later you discover the test was completing before your async operation even...
Key Insights
- Always return or await promises in tests—forgetting this causes false positives that silently pass while your code is actually broken
- Fake timers eliminate flaky tests and slow test suites; learn to use
jest.advanceTimersByTime()to make time-dependent code deterministic - Mock at the network boundary with tools like MSW rather than mocking implementation details, giving you realistic tests that survive refactoring
The Async Testing Challenge
Async code is where test suites go to die. You write what looks like a perfectly reasonable test, it passes, and six months later you discover the test was completing before your async operation even started. Meanwhile, your production code has been silently broken the entire time.
The core problem is that JavaScript’s event loop doesn’t wait for your assertions. A test function can complete successfully while promises are still pending and timers haven’t fired. This leads to three categories of pain: false positives (tests pass when code is broken), race conditions (tests randomly fail depending on timing), and slow suites (tests that actually wait for real timeouts).
Let’s fix all three.
Testing Promises with async/await
Modern test frameworks handle promises natively, but you have to actually tell them about your promises. The most common mistake is forgetting to return or await:
// BAD: This test always passes, even if fetchUser throws
test('fetches user data', () => {
fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
// GOOD: Return the promise
test('fetches user data', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
});
// BETTER: Use async/await
test('fetches user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
Testing rejection paths requires explicit handling. Jest and Vitest provide .rejects matchers that make this clean:
// Testing that a promise rejects
test('throws on invalid user ID', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
});
// Testing specific error properties
test('returns 404 error for missing user', async () => {
await expect(fetchUser(99999)).rejects.toMatchObject({
status: 404,
message: 'User not found'
});
});
// If you need to inspect the error in detail
test('error contains request metadata', async () => {
try {
await fetchUser(-1);
fail('Expected fetchUser to throw');
} catch (error) {
expect(error.requestId).toBeDefined();
expect(error.timestamp).toBeInstanceOf(Date);
}
});
The fail() call in the try/catch pattern is crucial—without it, a test passes if the function doesn’t throw, which defeats the purpose.
Mocking API Calls and External Dependencies
Real network calls in tests are slow, flaky, and require external services to be running. Mock them. You have two good options: mock the HTTP client directly, or intercept at the network level.
Direct mocking with Jest:
// api.js
export const fetchUser = async (id) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
};
// api.test.js
global.fetch = jest.fn();
beforeEach(() => {
fetch.mockClear();
});
test('returns parsed user data', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'Alice' })
});
const user = await fetchUser(1);
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user.name).toBe('Alice');
});
test('handles network errors', async () => {
fetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(fetchUser(1)).rejects.toThrow('Failed to fetch');
});
Mock Service Worker (MSW) is better for integration tests because it intercepts at the network level, meaning your actual fetch/axios code runs:
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
if (params.id === '1') {
return HttpResponse.json({ id: 1, name: 'Alice' });
}
return new HttpResponse(null, { status: 404 });
})
];
// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Now tests hit your mock server automatically
test('displays user name after loading', async () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
Taming Timers: Fake Timers and Time Travel
Real timers are the enemy of fast, deterministic tests. A function with a 5-second debounce shouldn’t make your test wait 5 seconds. Fake timers let you control time programmatically.
// debounce.js
export const debounce = (fn, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
// debounce.test.js
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('debounce delays execution', () => {
const callback = jest.fn();
const debounced = debounce(callback, 1000);
debounced();
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
});
test('debounce resets timer on subsequent calls', () => {
const callback = jest.fn();
const debounced = debounce(callback, 1000);
debounced();
jest.advanceTimersByTime(800);
debounced(); // Reset the timer
jest.advanceTimersByTime(800);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(1);
});
Testing retry logic with exponential backoff:
// retry.js
export const fetchWithRetry = async (url, maxRetries = 3) => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetch(url);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
};
// retry.test.js
test('retries with exponential backoff', async () => {
jest.useFakeTimers();
fetch
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ ok: true });
const promise = fetchWithRetry('/api/data');
// First retry after 1000ms
await jest.advanceTimersByTimeAsync(1000);
// Second retry after 2000ms
await jest.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result.ok).toBe(true);
expect(fetch).toHaveBeenCalledTimes(3);
});
Note the advanceTimersByTimeAsync—when combining fake timers with promises, you need the async version to let the microtask queue flush.
Testing Race Conditions and Concurrent Operations
Testing Promise.all and Promise.race requires controlling resolution order:
// concurrent.js
export const fetchAllUsers = async (ids) => {
const results = await Promise.all(
ids.map(id => fetchUser(id).catch(e => ({ error: e.message, id })))
);
return results;
};
// concurrent.test.js
test('handles partial failures in parallel fetches', async () => {
fetch
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 1, name: 'Alice' }) })
.mockResolvedValueOnce({ ok: false, status: 404 })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 3, name: 'Charlie' }) });
const results = await fetchAllUsers([1, 2, 3]);
expect(results[0].name).toBe('Alice');
expect(results[1].error).toBeDefined();
expect(results[2].name).toBe('Charlie');
});
Testing abort controllers:
test('aborts pending requests on cancel', async () => {
const controller = new AbortController();
const promise = fetchUser(1, { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toThrow('aborted');
});
Common Pitfalls and Debugging Strategies
When tests hang, it’s usually unresolved promises or timer leaks. Add this to your test setup:
// Detect unhandled rejections
beforeEach(() => {
process.on('unhandledRejection', (reason) => {
throw new Error(`Unhandled rejection: ${reason}`);
});
});
// Clean up timers between tests
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
Use waitFor for assertions that depend on async state changes:
import { waitFor } from '@testing-library/react';
test('updates state after async operation', async () => {
render(<AsyncComponent />);
// Don't do this - race condition
// expect(screen.getByText('Loaded')).toBeInTheDocument();
// Do this instead
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
}, { timeout: 3000 });
});
Best Practices Checklist
Keep these rules posted near your desk:
- Always await or return promises—no exceptions
- Use fake timers by default—opt into real timers only when testing actual timing behavior
- Mock at the boundary—prefer MSW over mocking fetch directly
- Clean up after each test—reset mocks, clear timers, restore spies
- Set meaningful timeouts—default Jest timeout of 5 seconds is often too short for complex async flows
- Make tests deterministic—if a test fails randomly, fix it immediately; flaky tests erode trust in your entire suite
Async testing isn’t hard once you internalize these patterns. The key insight is that you’re not testing timing—you’re testing behavior. Control time, mock boundaries, and always await your promises.