Curriculum Series

How to test asynchronous code without flaky tests

How to test asynchronous code without flaky tests

How to test asynchronous code without flaky tests

JavaScript

TL;DR

The core pattern: mark your test async, mock the network (with jest.fn() or MSW), and use helpers like findBy* or waitFor to let assertions run after the async work resolves. The single most important thing is making sure the test actually waits — otherwise you're asserting against the pre-resolved state and getting false passes.

Testing async functions in Jest

Mark the test async and await the work:

javascript
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
test('fetches user data', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, name: 'John' }),
})
);
const user = await fetchUser(1);
expect(user.name).toBe('John');
});

Heads up: skip the async/await and your assertions run before the promise settles. The test passes green even when the behavior is broken.

Testing async React components

The right tool is a findBy* query (which retries under the hood) or waitFor:

javascript
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
test('displays user name after loading', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John' }),
})
);
render(<UserProfile userId={1} />);
// findByText waits for the element to appear
expect(
await screen.findByText('John')
).toBeInTheDocument();
});

waitFor for arbitrary conditions

waitFor re-runs its callback until the assertion passes or the timeout hits:

javascript
test('removes item after delete', async () => {
render(<TodoList />);
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(
screen.queryByText('Buy milk')
).not.toBeInTheDocument();
});
});

Fake timers for time-based code

When the code under test uses setTimeout/setInterval, switch to fake timers and advance them manually:

javascript
test('calls callback after delay', () => {
jest.useFakeTimers();
const callback = jest.fn();
delayedCall(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});

No more sleeping your CI for a full second per test.

Common Pitfalls

The number-one async testing mistake: asserting before the promise resolves. The test picks up the initial state ("Loading…") and declares victory. Always thread an await, findBy*, or waitFor through the test so you assert against the post-resolution DOM.

Interview Tip

Cover the full toolkit: async/await for pure functions, findBy*/waitFor for components, fake timers for scheduled work. The underlying point is that tests are synchronous by default and you have to explicitly yield to async work.

Why interviewers ask this

Every production app has async — fetches, timers, animations. This question filters for engineers who've actually dealt with flaky tests and know how to avoid them. Mentioning waitFor, fake timers, and the false-pass trap signals real-world experience.

Finished reading?

Mark this topic as solved to track your progress.