IAnswerable

Kill Flaky Tests for Good

25 Mar 2026

A flaky test — passes sometimes, fails sometimes, same code — is worse than no test. It trains your team to re-run CI instead of investigate, and eventually someone re-runs past a real failure because “that one’s always flaky.” Kill flaky tests fast; don’t tolerate them.

Cause 1: Timing and async races

The test asserts before the async work finishes. Classic symptom: passes locally (fast machine, low load), fails in CI (slower, contended).

// flaky: assumes the request resolves before the assertion runs
fireEvent.click(button)
expect(screen.getByText('Success')).toBeInTheDocument()

// fixed: wait for the actual condition, not a fixed delay
fireEvent.click(button)
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument())

The fix is never await sleep(1000). A fixed delay just moves the failure threshold — it’ll still flake under enough load, and it makes every run slower even when nothing’s wrong. Wait for the actual condition (waitFor, polling with a timeout, an event) instead of a clock.

Cause 2: Shared state between tests

Test A creates a user named test@example.com. Test B does too. Run them in parallel, or run B before A cleans up, and one fails on a unique constraint that has nothing to do with what B is actually testing.

// flaky: fixed identifiers collide across tests and across parallel workers
const user = await createUser({ email: 'test@example.com' })

// fixed: unique per test run
const user = await createUser({ email: `test-${crypto.randomUUID()}@example.com` })

Same failure mode with shared database rows, shared temp files, or global module state that one test mutates and doesn’t reset. If your tests can’t run in any order, in parallel, they will eventually fail in exactly that order.

Cause 3: Real external dependencies

A test that calls a live third-party API, or depends on network latency, or reads system time without controlling it, is at the mercy of that system’s uptime and speed.

// flaky: real clock, fails right around midnight, fails on DST changes
expect(isExpired(token)).toBe(false)

// fixed: inject or mock the clock
const now = new Date('2026-01-15T12:00:00Z')
expect(isExpired(token, now)).toBe(false)

For third-party APIs, mock them for unit tests and reserve real calls for a small number of tests you explicitly accept as slower and less reliable, run separately from your main suite. Don’t let “hits Stripe’s test API” be in the same fast suite that gates every commit.

Cause 4: Order dependence and leaked state

Test suites that pass in file order but fail with --randomize almost always have one test relying on state a previous test happened to leave behind — a global counter, a mocked module that didn’t get reset, a database row test C assumes test A created.

# run your suite with randomized order regularly, not just when you suspect a problem
npm test -- --randomize

If randomized order finds a failure, that’s not new flakiness — that’s flakiness you already had, now visible instead of hidden.

The policy that stops flaky tests from accumulating

A flaky test gets fixed or deleted within the same week it’s noticed — not skipped, not left with a // TODO: fix flaky test for a quarter. A skipped test provides zero coverage and a false sense of one. If you genuinely can’t fix it immediately, delete it and file a ticket to rewrite it properly; a deleted test is honest about your coverage, a permanently-skipped one is not.

The underlying rule for all four causes: a test result should depend only on the code under test, nothing else — not timing, not execution order, not the network, not the wall clock unless the wall clock is the thing being tested.