How to test asynchronous code without flaky tests
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:
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:
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 appearexpect(await screen.findByText('John')).toBeInTheDocument();});
waitFor for arbitrary conditions
waitFor re-runs its callback until the assertion passes or the timeout hits:
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:
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.