Flaky tests — root causes and fixes
A test that passes and fails on identical code, with nothing actually changing between runs, is worse than no test at all — it trains everyone to distrust red CI checks, which is exactly when a real failure gets ignored.
4 min read
Why a flaky test is actively worse than no test
A test suite's entire value depends on a simple rule holding: red means something is actually wrong, green means it isn't. A test that occasionally fails on unchanged, correct code breaks that rule specifically — and once a team learns that a particular test (or, worse, the whole suite) "just fails sometimes, re-run it," the natural, rational response is to stop trusting red on sight. That's the exact moment a real regression can slip through: a genuinely broken change fails the suite, gets re-run reflexively without investigation, and happens to pass that time too — or gets waved through as "oh, that test's just flaky."
Root cause 1: real time
// Flaky — fails specifically when the test happens to run near midnight
test("greets the user with 'today'", () => {
const message = getGreeting(new Date());
expect(message).toContain("today");
});
// Fixed — the test controls time explicitly, instead of depending on when it happens to run
test("greets the user with 'today'", () => {
const fixedDate = new Date("2026-08-31T12:00:00Z");
const message = getGreeting(fixedDate);
expect(message).toContain("today");
});Any test whose outcome depends on the real, current wall-clock time is a test that behaves differently depending on when it happens to run — a classic, common flakiness source that's invisible until a test happens to run at exactly the wrong moment (midnight, month boundaries, daylight saving transitions). The fix is always the same shape: make time an explicit, controlled input to the code under test, rather than something the code reaches out and reads for itself.
Root cause 2: async timing and race conditions in the test itself
// Flaky — assumes the async operation finished, without actually waiting for it
test("loads and displays the user's name", () => {
render(<UserProfile userId="1" />);
expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); // may run BEFORE the fetch resolves
});
// Fixed — explicitly waits for the expected state, instead of assuming timing
test("loads and displays the user's name", async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
});getByText checks the DOM's state right now — if the component is still fetching data (a real, unpredictable amount of time, especially in CI environments under variable load), the assertion runs too early and fails, even though the code is completely correct and would have rendered the name a moment later. findByText (or an explicit waitFor) polls until the expected state actually appears or a timeout is hit — correct regardless of exactly how long the async operation actually takes, which is the fix for almost every "usually passes, occasionally fails" async test.
Root cause 3: shared, unreset state between tests
// Flaky — depends on test EXECUTION ORDER, which test runners don't guarantee
let cache = {};
test("caches a computed value", () => {
cache["key"] = computeExpensive();
expect(cache["key"]).toBeDefined();
});
test("cache starts empty", () => {
expect(Object.keys(cache)).toHaveLength(0); // passes ONLY if this runs first
});Module-level (or database, or filesystem) state that persists across tests means a test's outcome secretly depends on which tests ran before it, and in what order — something most test runners explicitly don't guarantee, and may even randomize on purpose to catch exactly this. The fix is resetting shared state in a beforeEach (or equivalent), so every test genuinely starts from the same known state regardless of what ran before it — the isolation the earlier unit-test lesson already named as one of the properties that makes a test trustworthy in the first place.
When you find a flaky test: fix the cause, don't just retry until green
Adding automatic retries to a flaky test can be a legitimate, pragmatic stopgap for a known, understood cause that's genuinely hard to eliminate (a real third-party service with occasional latency spikes, in an end-to-end suite) — but reaching for retries as the first response, without diagnosing why the test is actually flaky, just hides the symptom while leaving the real cause (often one of the three above) in place, ready to cause a much harder-to-diagnose problem somewhere else later.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why is a flaky test arguably worse than having no test at all for that behavior?
2. What's the fix for a test that's flaky because it depends on the real, current wall-clock time?
3. Why can shared, unreset state between tests cause flakiness?