You've got a flaky test — walk through your actual triage process to find and fix the root cause
Flaky tests are bug reports in disguise — real triage isolates order, parallelism, and timing until the shared state or race condition behind it is exposed.
LEARN BY DOINGPut your intuition to work3 steps
You've got a flaky test — walk through your actual triage process to find and fix the root cause
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Flaky Tests Are a Symptom, Not the Disease
A flaky test — one that passes and fails on the same code, with no changes — is not "just annoying." It's a bug report about the system under test, or about the test itself, that everyone is trained to ignore by clicking "rerun." That instinct is exactly backwards: most flakiness traces back to a race condition, shared mutable state, or an ordering dependency that will eventually bite in production, not just in CI.
#Step 1: Confirm It's Actually Flaky
Before triaging anything, rule out "this failed once because a dependency hiccupped." Rerun the specific test 20 to 50 times locally, and separately confirm it fails intermittently in CI, not just once. Most CI runners support a repeat flag or a simple loop; if it fails maybe one in fifteen runs, that ratio is itself a clue about timing sensitivity.
#Step 2: Reproduce It Reliably
Flaky tests are usually sensitive to one of a few dimensions — find which one by changing it in isolation:
- Run it alone versus as part of the full suite. If it only fails alongside other tests, suspect shared state.
- Shuffle the test order (most runners support a random-seed mode). If failures correlate with order, it's a state leak between tests, not true randomness.
- Run serially versus in parallel workers. If it only fails in parallel, suspect a shared resource — a port, a file, an in-memory singleton, a database row — that two workers are touching at once.
- Add artificial delay or load to the machine. If failures increase under load, it's timing-sensitive — usually a race condition or a missing await.
#Step 3: Look at the Actual Failure — A Real Example
Here's a real pattern: a module-level cache used as a quick memoization layer, tested with two tests that look completely independent.
// cache.tsexport const requestCache = new Map<string, number>();export function incrementAndGet(key: string): number {const next = (requestCache.get(key) ?? 0) + 1;requestCache.set(key, next);return next;}
// cache.test.tsimport { incrementAndGet } from "./cache";test("first call for a fresh key returns 1", () => {expect(incrementAndGet("requests")).toBe(1);});test("second call for the same key returns 2", () => {expect(incrementAndGet("requests")).toBe(2);});
Run the file top to bottom and both tests pass — the second test's assertion of 2 only works because the first test already incremented the shared requestCache to 1. Run the second test alone (test.only, or a runner that shards or reorders), and it fails: the freshly loaded module gives 1, not 2. Run the whole suite with the runner's random-order mode and it fails just as often. The bug isn't in incrementAndGet — it's that the test suite has a hidden dependency: test order is part of an unstated contract, and the module-level Map is shared mutable state leaking across tests that look independent.
#Step 4: Fix the Root Cause, Not the Symptom
The wrong fix is forcing a fixed run order or adding a retry annotation — that hides the coupling instead of removing it. The right fix removes the shared state so each test starts from a known baseline:
// cache.test.ts (fixed)import { requestCache, incrementAndGet } from "./cache";beforeEach(() => {requestCache.clear();});test("first call for a fresh key returns 1", () => {expect(incrementAndGet("requests")).toBe(1);});test("second call for the same key returns 2", () => {incrementAndGet("requests");expect(incrementAndGet("requests")).toBe(2);});
Even better long-term: stop exporting a module-level singleton and make the cache an instance the test constructs fresh (new RequestCache()), which makes shared-state bugs structurally impossible rather than merely reset between runs.
#Other Common Root Causes, Same Triage Logic
- Unawaited async work: an assertion runs before a promise resolves; it passes on a fast machine and fails on a loaded CI runner. Fix: await the actual completion signal, never use
setTimeoutas a synchronization primitive. - Real system clock: a test asserts on
Date.now()math and fails once a month at a boundary — midnight, month-end, a daylight-saving change. Fix: inject a fake clock. - Unseeded randomness: an intermittent edge-case input. Fix: seed the random number generator in tests.
- Leaked resources: a port or temp file from a previous test run that was never released. Fix: a strict
afterEachteardown.
🧠 Check your understanding
0/2 · 0/2 answered1. A test passes when run after another test in the same file, but fails when run alone. What does this pattern usually indicate?
2. Why is adding a retry annotation considered a poor fix for a flaky test caused by a race condition?