How do you test asynchronous code, timers, and error paths reliably?
Quick Answer
Return or await the promise so the runner waits for completion; assert rejections with expect(...).rejects.toThrow. Replace real time with fake timers to test timeouts/intervals/debounce without waiting. Keep tests deterministic and isolated to avoid flakiness — no real network, no shared state, no reliance on wall-clock timing.
Detailed Answer
Answer:
Async tests — make the runner wait:
// async/await (preferred)
test('fetches a user', async () => {
const user = await getUser(1);
expect(user.name).toBe('Alice');
});
// Assert a rejection
test('throws on missing user', async () => {
await expect(getUser(999)).rejects.toThrow('Not found');
});
- The common bug is a test that doesn't await the promise — it passes even when the assertion would fail, because the test finishes before the async work. Always
await(orreturn) the promise.
Timers — don't actually wait: Use fake timers to control time deterministically (fast, no flakiness):
jest.useFakeTimers();
test('debounce fires once after 300ms', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced(); debounced();
jest.advanceTimersByTime(300); // simulate time passing
expect(fn).toHaveBeenCalledTimes(1);
});
This tests timeouts, intervals, and debounce/throttle instantly.
Avoiding flaky tests:
- No real network/clock — mock external calls, use fake timers instead of
setTimeoutwaits. - No shared state — reset mocks and data in
beforeEach; don't depend on test order. - Deterministic data — seed randomness, fix "now" (fake timers / injected clock).
- Await everything — unhandled async is the #1 cause of intermittent failures.
- Assert on outcomes/state, not on arbitrary sleep durations.
Error paths: force failures by stubbing a dependency to throw/reject, then assert the code handles it (returns the right status, logs, retries, rolls back). Error branches are exactly where bugs hide, so test them explicitly.