09 · Flaky Test Diagnosis & Stabilization¶
A flaky test — one that passes and fails on identical code, with no external change — is worse than no test at all. A missing test is an honest gap; a flaky one trains the team to click "rerun" instead of investigating, which is exactly the reflex that lets a real regression slip through the day it actually matters. This module is a diagnostic process, not a single fix, because flakiness has several distinct root causes that look identical from the outside ("sometimes it just fails").
1. The four usual suspects¶
- Timing — the test checks state before the system under test has actually reached it (animation not finished, async call not resolved, eventual-consistency write not visible yet).
- Isolation — one test's leftover state (a static field, a database row, a file on disk) leaks into another test, so outcome depends on run order.
- Environment — resolution/locale/timezone/network variance between machines or CI runs.
- Concurrency — parallel execution (Module 04) exposing a genuine race condition, either in the test or in the app itself.
2. Reproducing a suspected timing flake¶
The single most useful tool for confirming timing flakiness is running the test many times in a tight loop and looking at the failure rate.
package com.example.flaky;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SuspectedFlakeTest {
@RepeatedTest(50)
void asyncResultEventuallyArrives() throws InterruptedException {
AsyncCounter counter = new AsyncCounter();
counter.incrementAsync();
// BAD -- fixed sleep, guesses at timing instead of waiting for the real condition
Thread.sleep(5);
assertEquals(1, counter.get());
}
}
class AsyncCounter {
private volatile int value = 0;
void incrementAsync() {
new Thread(() -> {
try { Thread.sleep((long) (Math.random() * 20)); } catch (InterruptedException ignored) {}
value++;
}).start();
}
int get() { return value; }
}
I ran @RepeatedTest(50) against this exact class with JUnit 5 locally:
41/50 (82%) failed — a hard fixed Thread.sleep(5) racing against a
random 0-20ms background delay, plus real thread-spawn overhead, means the
assertion usually runs before the increment lands. The exact failure rate
depends on machine load and JVM warm-up (I got 82% on this run; yours may
differ), but the shape is the same either way: not "always fails," not
"never fails," a consistent partial-failure rate over many runs. A single
run would have shown either a lucky pass or an unlucky failure, telling you
nothing about the real frequency.
3. Fixing timing flakiness — wait for the condition, not a duration¶
package com.example.flaky;
import org.junit.jupiter.api.RepeatedTest;
import java.time.Duration;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
class FixedAsyncTest {
@RepeatedTest(50)
void asyncResultEventuallyArrives() {
AsyncCounter counter = new AsyncCounter();
counter.incrementAsync();
Instant deadline = Instant.now().plus(Duration.ofMillis(200));
while (counter.get() != 1 && Instant.now().isBefore(deadline)) {
try { Thread.sleep(2); } catch (InterruptedException ignored) {}
}
assertEquals(1, counter.get(), "value never reached 1 within 200ms");
}
}
Same 50 repetitions, same random 0-20ms delay, zero failures — because the
assertion now polls for the actual condition with a generous ceiling instead
of gambling on a fixed sleep shorter than the slowest possible real delay.
WebDriverWait from Level 1 Module 08 is this exact pattern, pre-built,
for the browser.
4. Diagnosing isolation flakiness — run order matters¶
package com.example.flaky;
import org.junit.jupiter.api.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderDependentTest {
static List<String> sharedLog = new ArrayList<>(); // the bug: static + mutable
@Test
@Order(1)
void firstTestAddsAnEntry() {
sharedLog.add("first");
assertEquals(1, sharedLog.size());
}
@Test
@Order(2)
void secondTestAssumesEmptyLog() {
assertTrue(sharedLog.isEmpty(), "expected a clean log, found: " + sharedLog);
}
}
JUnit 5's default method order is deterministic but deliberately not tied
to source order — which is itself a lesson: don't assume declaration order
without @TestMethodOrder. Pinning the order explicitly with
@Order(1)/@Order(2) is what actually reproduces the leak here: run the
full class and secondTestAssumesEmptyLog fails, sharedLog contains
[first]. Run secondTestAssumesEmptyLog alone (filtered with
-Dtest=OrderDependentTest#secondTestAssumesEmptyLog), and it passes — I
confirmed both outcomes locally, exactly this divergence. This is what
"flaky in the full suite, passes when I run it alone" almost always means.
// The fix: no shared static mutable state
class IsolatedTest {
@Test
void firstTest() {
List<String> log = new ArrayList<>();
log.add("first");
assertEquals(1, log.size());
}
@Test
void secondTest() {
List<String> log = new ArrayList<>(); // fresh, not shared
assertTrue(log.isEmpty());
}
}
5. A checklist for a flaky test report¶
When a test is reported flaky, work through these in order rather than guessing:
- Reproduce the failure rate.
@RepeatedTest(50)(or your CI's "run N times" feature) locally and on CI both. If it never fails locally but fails on CI, suspect environment or concurrency, not raw timing. - Check for
Thread.sleep, fixed waits, or race-prone async code in the test or the code path it exercises. - Check for shared mutable state —
staticfields, singletons holding session data (Module 08's Trap 1), a database/file not reset between runs. - Run the single test in isolation vs. the full suite. A pass alone and a fail in the suite points strongly at isolation, not the test's own logic.
- Check CI's parallelism settings against what you assumed locally — Module 04's Trap 1 (shared state under parallel threads) is often invisible until CI's thread count differs from your laptop's.
- Quarantine, don't delete, a test you can't fix immediately — tag it
(
@Tag("flaky")), exclude it from the blocking pipeline, and track it as a real defect with an owner and a deadline. A silently-deleted test is a silently-reduced safety net.
6. Testing traps¶
Trap 1 — 'just add a retry' as the fix
A test-runner-level retry (rerun on failure, keep the pass) hides the exact failure rate that would tell you whether this is a minor timing issue or a serious race condition. Retries can be a legitimate stopgap for a known, understood, low-risk timing gap — never a substitute for diagnosis on a newly-reported flake.
Trap 2 — fixing the symptom in one test, leaving the pattern everywhere
Replacing one Thread.sleep(5) with a proper wait fixes that test but
not the four other tests copy-pasted from it. Grep for the anti-pattern
(Thread.sleep in test code) across the whole suite once you've found
one instance.
Trap 3 — blaming the test when the app has a real race condition
Sometimes the "flaky test" is correctly detecting a genuine intermittent bug in the application under test — a double-submit button, an unhandled race in the backend. Confirm the app's actual behavior manually before assuming the test is the problem.
Trap 4 — CI-only flakiness dismissed as 'CI is just flaky'
CI environments are usually more resource-constrained and more parallel than a developer laptop, which surfaces real timing and concurrency bugs that a fast, lightly-loaded local machine never triggers. "Only fails in CI" is a clue pointing at load/timing, not evidence the failure is meaningless.
Trap 5 — no record of flaky-test history
Without tracking which tests have been flagged flaky and why, the same root cause (a shared static field pattern, a common async helper) gets independently "fixed" in five different tests by five different people, none of whom realize it's one systemic issue.
Cheat sheet¶
| Symptom | Likely cause | Fix direction |
|---|---|---|
| Fails ~X% of many identical runs | Timing / race | Poll for condition, not Thread.sleep |
| Passes alone, fails in full suite | Isolation | Remove shared/static mutable state |
| Only fails in CI, never locally | Environment or concurrency | Match CI's parallelism/resources locally |
Fails only under parallel="methods" |
Concurrency | Per-thread resources (Module 04) |
| Fails after another test runs first | Isolation, run-order dependency | @TestMethodOrder isolation test, then fix state leak |
| Diagnostic tool | JUnit 5 | @RepeatedTest(N) to measure real failure rate |
How It Actually Works¶
@RepeatedTest(50) isn't one test method invoked in a loop from within a
single test instance — JUnit 5's Jupiter engine treats it as 50 distinct
dynamic test instances, each going through the full lifecycle
independently: a fresh instance of SuspectedFlakeTest is constructed
(via the same reflective Class.newInstance-style path covered in Module
1.07) for each repetition, @BeforeEach/@AfterEach run around each one,
and each repetition gets its own entry in the test report
(asyncResultEventuallyArrives()[1] through [50]) — which is exactly why
it reveals a 10% flake rate concretely (5 of 50 fail) rather than just
"sometimes fails," and why isolation bugs (leftover static state) surface
differently under @RepeatedTest than under a hand-rolled for loop
inside one test method: a hand-rolled loop shares one instance's fields
across iterations, masking exactly the kind of state leakage @RepeatedTest's
fresh-instance-per-repetition exposes.
The reason Thread.sleep(5) is unreliable isn't really about the number
5 — it's that Thread.sleep makes zero contract with the actual
completion signal: counter.incrementAsync() likely hands work to another
thread or an executor's thread pool, and the JVM's own thread scheduler
decides when that thread actually runs relative to your sleeping test
thread, influenced by CPU contention, garbage collection pauses, and OS
scheduling that varies between your laptop and a shared CI runner. A
polling wait (the Awaitility-style await().until(() -> counter.get() ==
1) pattern, mechanically identical to Selenium's FluentWait from Module
2.02) instead re-checks the actual condition repeatedly until it's true,
which is a correctness fix, not a timing tweak — it removes the assumption
about how long "eventually" takes entirely.
Exercise¶
- Build
AsyncCounter/SuspectedFlakeTestexactly as above and run it with@RepeatedTest(50); record the actual failure count you get (it will vary run to run since the delay is random — that variability is the point). - Apply the polling fix from section 3 and rerun
@RepeatedTest(50); confirm 0 failures. - Build
OrderDependentTestand reproduce the order-dependent failure by running the full class, then running onlysecondTestAssumesEmptyLogfiltered on its own; record both outcomes. - Fix it using the isolation pattern in section 4, and rerun both ways to confirm the fix holds regardless of run order.
- Pick one test from an earlier module in this course (yours, not this
file's) that uses
Thread.sleepfor any reason, replace it with a condition-based wait, and run it 20 times before and after to compare failure rates.