ConstructiCat Logo
CodeBust.
Browse section ▾

Erratic (Flaky) Test.

An erratic (flaky) test passes and fails intermittently on the same code because its outcome depends on timing, ordering, shared state, or other nondeterministic factors rather than the behavior under test.

##Signs and Symptoms

A test that is green on one run and red on the next without any code change is erratic. You recognize it by the human behavior around it as much as the code: developers re-run CI "to make it pass," add @Flaky/retry(3), or quarantine the test instead of fixing it.

Common tells in the code and the failure pattern:

  • Timing/async: sleep/setTimeout "waits," un-awaited promises, or assertions that race the system under test. Failures correlate with machine speed or CI load (see Sleepy Test).
  • Order dependency: the test passes in isolation but fails in the full suite, or vice versa. Shuffling test order changes the result (see Test Order Dependency).
  • Shared mutable state: static collections, a reused DB row, a singleton, or a fixture mutated by an earlier test.
  • Nondeterministic inputs: Date.now()/new Date(), Math.random(), locale/timezone, hash-map iteration order, or auto-generated IDs.
  • External resources: real network, filesystem, or clock. Failures look like timeouts or "connection refused," not assertion mismatches.
  • Conditional assertions: expect hidden inside if/catch/callbacks, so the test silently passes when the branch never runs (see Conditional Test Logic).
// Smell: a hardcoded "wait," a shared array, and a real clock
const created = [];                       // shared across tests → interacting tests

test('shows a fresh receipt', async () => {
  render(<Checkout />);
  fireEvent.click(screen.getByText('Pay'));
  await new Promise(r => setTimeout(r, 300));   // hope the request finished
  created.push('order-1');                       // leaks into later tests
  expect(screen.getByRole('status'))
    .toHaveTextContent(`Paid ${new Date().toISOString()}`); // changes every run
});

This maps directly to the causes Meszaros lists under Erratic Test: Interacting Tests (tests in one run share state; see Test Order Dependency), Test Run War (several test runs at once fight over a shared resource such as a database; see Test Run War), Lonely Test (passes only when another test has run first, fails on its own), Resource Optimism (assumes an external resource is there; see Resource Optimism), Resource Leakage (doesn't clean up), Unrepeatable Test (state left behind by one run changes the outcome of the next, so the first run behaves differently from later ones), and Nondeterministic Test (time, randomness, and async races make the very same run pass or fail at random).

##Reasons for the Problem

Why it happens

  • Implicit timing. Async code is "synchronized" with fixed sleeps or by not awaiting at all. The delay is a guess: long enough today, too short under load tomorrow.
  • Hidden coupling. Tests share a database, a singleton, module-level variables, or files on disk. One test's side effects become another's preconditions (Meszaros's Interacting Tests / Test Run War), so results depend on order and on what ran in parallel.
  • Nondeterministic inputs leak in. Real wall-clock time, Math.random(), locale/timezone, and unordered collections vary between runs and machines.
  • Optimistic dependence on the environment. Tests hit a live network/service or assume a file exists (Resource Optimism) and never release what they acquire (Resource Leakage).
  • Assertions that can be skipped. Putting expect in a conditional or a .catch means the check may never execute, so the test "passes" by accident.

Why it hurts

  • False confidence and masked defects. A flaky test can fail for reasons unrelated to production, and a real regression can hide behind a failure everyone assumes is "just flake." You can no longer tell signal from noise.
  • Erosion of trust. Once a suite is known to be flaky, developers ignore red builds and reflexively retry, which trains the team to disregard the test suite entirely.
  • Wasted time and broken pipelines. Retries, reruns, and "is it me or the test?" investigations slow everyone down and block CI/CD on non-issues.
  • Poor maintainability. Erratic tests are hard to debug because the failure isn't reproducible; they tend to get disabled rather than fixed, quietly reducing real coverage.

##Treatment

Treat flakiness as a defect in the test, not a quirk to retry around. Reruns are for detecting flakiness, never for hiding it. Quarantine the test if it blocks the pipeline, then root-cause it.

1. Replace sleeps with condition-based waiting. Poll for the state you actually care about instead of guessing a duration.

// before — racy fixed delay
fireEvent.click(screen.getByText('Pay'));
await new Promise(r => setTimeout(r, 300));
expect(screen.getByRole('status')).toBeInTheDocument();

// after — wait for the condition, then assert
fireEvent.click(screen.getByText('Pay'));
expect(await screen.findByRole('status')).toBeInTheDocument();

2. Make inputs deterministic. Inject the clock or use fake timers; seed or stub randomness; pin locale/timezone.

// before: depends on the real date
const label = receiptLabel();             // formats new Date() internally
expect(label).toBe(`Paid ${new Date().toISOString()}`); // races the clock

// after (Vitest): freeze the clock, then assert on a fixed value
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
vi.useRealTimers();

// Jest: the same with the jest object
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
jest.useRealTimers();

3. Isolate every test. Give each test fresh fixtures, reset shared state (DB, singletons, module globals) in beforeEach/afterEach, and avoid module-level mutable variables. Run the suite in randomized order (e.g. Jest's --seed/randomize, JUnit MethodOrderer.Random) to surface ordering dependencies early.

4. Stub external resources. Mock network, filesystem, and system calls so the test never depends on a service being up (cures Resource Optimism); always release/clean up acquired resources (cures Resource Leakage).

5. Await all async work and assert unconditionally. Return/await promises and async test utilities; move side effects out of waitFor callbacks so they run once; never bury expect inside if/catch.

6. Verify the fix. Run the now-deterministic test many times (and under load / shuffled order) to confirm stability before taking it out of quarantine.

##Detected by