A flaky test is one that behaves unpredictably on unchanged code: green today, red tomorrow, with no edit between runs. The temptation is strong - to call it background noise, weather you cannot do anything about, and simply rerun the build. But flakiness is not weather; it is a defect. Either the test hides a source of nondeterminism, or the code itself depends on conditions you do not control. Both are a signal, not a nuisance.
The natural first reaction is to wrap the test in a retry: let a failed run repeat two or three times, and if even one is green, count the test as passed. The logic seems sound: the team does not want the build to fail over one capricious check when the other nine hundred tests are fine. Retry looks like cheap insurance that restores the pipeline to green and buys peace.
Here is where it breaks: retry does not cure, it hides. A test that passes on the third attempt still contains the bug - you have merely trained yourself not to look at it. Worse, if the nondeterminism lives in the product itself (a race over shared state, a dependency on the system clock), retry masks a real error that will eventually surface for a user. A flaky test allowed to stay flaky devalues the whole suite: people stop trusting red and start rerunning the build without looking.
The causes fit a short list. Time: the test compares Date.now() or relies on a real timeout. Order: one test leaves a mutated module or global, and the next relies on it. Network: a call to a real endpoint that answers sometimes and sometimes not. Races: async work that was not awaited. Shared state: a reused cache, a singleton, a database - alive between tests. Almost every flake reduces to one of these five.
Diagnosis starts with reproduction, not guesses. Fix the seed and run the suite in a single process: jest --runInBand turns off the worker pool, and then the order becomes deterministic - if the failure disappears, a race between workers or shared state is to blame. Shuffle the file order to pull out a hidden dependency. If Jest does not exit, --detectOpenHandles shows the open handles: a live server, a timer, a database connection. The table below links symptom, likely cause, and first step.
| Symptom | Likely cause |
|---|
| Diagnosis |
|---|
| Only the suite fails | Shared state | --runInBand, shuffle the order |
|---|---|---|
| Jest does not exit | Open handle | --detectOpenHandles, check server/timer/db |
| Only in CI | Race, resources, TZ | explicit waits, fixed timezone |
| Slow | Heavy setup/transform | --logHeapUsage, slow-test profiling |
The fix is removing the source of nondeterminism, not silencing the symptom. Make the clock deterministic with jest.useFakeTimers() and jest.setSystemTime(), and advance timeouts by hand with jest.advanceTimersByTime(). Order is cured by isolation: beforeEach resets state, clearMocks/resetMocks clear mocks between tests, and the module cache is reset with jest.resetModules(). The network is replaced by a mock at the architectural boundary. Asynchrony - with an explicit await and Testing Library's findBy waiting, instead of guessing delays. The time zone is pinned with the TZ variable so CI and laptop compute dates the same way.
The only justified use of retry is a temporary quarantine of an external E2E that calls someone else's service over the network: there the nondeterminism is not yours, and the repeat honestly reflects reality. For a unit test retry is inadmissible - it is treating the symptom. A classic failure: the cart test fails only in CI. Locally all is green, because the machine is faster and in a different time zone; in CI a slow worker does not finish mounting Cart before the assert, and the order date crosses midnight in UTC. Retry would make the build green and leave both bombs - the race and the time zone - waiting for a user.