ENGINEERING INSIGHTS · QUALITY ENGINEERING

Flaky tests are a signal, not noise.

How to detect flaky tests from CI data, quarantine them without losing coverage, and fix the usual causes: fixed waits, shared state, real clocks, and live network calls.

AUTHOR
NeuralSharks EngineeringEngineering team
AUTHOR EXPERTISE
  • AI engineering
  • Software architecture
  • Quality engineering
  • Cloud & DevOps
PUBLISHED
UPDATED
READING TIME
5 min1,199 words

01 / 05

What a flaky test actually costs

A flaky test passes and fails on the same code. The first time it fails, someone investigates. The fifth time, someone clicks rerun. By the twentieth, the team has learned that a red build means nothing in particular, and a real regression can slip through on the third retry.

That loss of trust is the real cost, and it compounds. Engineers stop reading failures. Automatic retries are added to keep the pipeline green, which hides new flakiness as it appears. At Google's scale, about 1.5% of all test runs reported a flaky result, and almost 16% of tests showed some level of flakiness, according to its testing blog in 2016. You do not need that scale to feel it; a few hundred browser tests are enough.

A flaky test is also not always a test problem. Sometimes the nondeterminism is in the product: a race between two requests, a cache that serves stale data under load, a missing database constraint. Treating every flake as noise means occasionally ignoring the only evidence of a real bug.

02 / 05

Detect flakiness from data, not anecdotes

The definition gives you the detection method: a test is flaky if it both passed and failed on the same commit. If your CI keeps results per test (most runners can emit JUnit XML, and it is worth loading into a table), one query finds them:

SQL
-- Tests that both passed and failed on the same commit in the last 14 days.
SELECT test_id,
       count(*)       AS flaky_commits,
       max(last_seen) AS last_seen
FROM (
  SELECT test_id, commit_sha, max(finished_at) AS last_seen
  FROM test_results
  WHERE finished_at > now() - interval '14 days'
  GROUP BY test_id, commit_sha
  HAVING bool_or(passed) AND bool_or(NOT passed)
) AS mixed
GROUP BY test_id
ORDER BY flaky_commits DESC;

Rank by the number of commits affected, not by raw failure count: a test that failed forty times on one broken branch is broken, not flaky. Rerunning failed tests automatically is a fine detection mechanism, as long as a pass on retry is recorded and reported as a flake and never silently counted as a pass.

03 / 05

Quarantine with an owner and an expiry date

Once a test is known to be flaky, it should stop blocking merges, because a gate that fails at random teaches people to bypass gates. It should not stop running. Quarantine means the test runs in a separate, non-blocking job, its results are still recorded, and it has an owner and a date by which it is fixed or deleted.

  1. 01Fails in CI

    Merge blocked; failure recorded with the commit SHA

  2. 02Rerun on the same commit

    Same code, fresh environment

    Fails again: a real failure; fix before merging

  3. 03Recorded as flaky

    Passed on retry, so it counts as a flake, not a pass

  4. 04Quarantined

    Non-blocking job, a named owner, a fix-by date

    Past its expiry: fixed or deleted, never silently renewed

  5. 05Fixed and returned

    Root cause fixed, stable across repeated runs, back in the gate

FIG. 1Quarantine keeps the signal without letting it block delivery. The expiry date is what stops the quarantine from becoming a graveyard.

With Playwright, tags and annotations carry the quarantine metadata in the test itself, where reviewers see it:

tests/reports.spec.tsTypeScript
test("exports the monthly report", {
  tag: "@quarantine",
  annotation: [
    { type: "owner", description: "billing-team" },
    { type: "expires", description: "2026-11-15" },
    { type: "issue", description: "https://tracker.example.com/QA-482" },
  ],
}, async ({ page }) => {
  // ...
});
Shell
npx playwright test --grep-invert @quarantine   # blocking job
npx playwright test --grep @quarantine          # non-blocking job; results still stored

A short script in CI that fails when any expires annotation is in the past keeps the list honest.

04 / 05

The usual causes, and what fixes them

Most flakiness comes from a short list of causes. An empirical study of flaky tests in open-source projects found the top three root causes to be asynchronous waits, concurrency, and test order dependency. It also found that most flaky tests (78%) were flaky from the moment they were written, which makes review and new-test stress runs the cheapest place to catch them.

Cause, symptom, and fix
CauseTypical symptomFix
Waiting for a fixed timeFails on slow CI machines, passes locallyWait for an observable condition, never a fixed sleep
Shared or leftover stateFails only alongside other tests, or in a certain orderEach test creates its own data and never depends on another test's output
Real clocks and time zonesFails at midnight, at month end, or on a runner in another zoneInject or control the clock, and pin the time zone
Live network dependenciesFails when a third-party sandbox is slow or downStub external services at the boundary; test the contract separately
Unordered resultsFails when the database returns rows in a different orderAssert on sets, or add an explicit ORDER BY in the code under test
Resource leaksFails late in long runs, more often with more parallel workersClose connections and pages in teardown; cap workers to what CI can hold
A race condition in the productFails rarely, in a way that also appears in production logsFix the product, and keep the test as the regression check

Wait for conditions, not time

The most common fix is the simplest. A fixed sleep is a guess about how long something takes, and CI machines are slower and noisier than laptops. Wait instead for the condition a user would see. Playwright's web-first assertions do this by default: they retry until the condition holds or the timeout expires.

TypeScript
// Flaky: assumes saving always finishes within two seconds.
await page.click("#save");
await page.waitForTimeout(2000);
expect(await page.textContent(".toast")).toBe("Saved");

// Deterministic: waits for what the user actually sees, up to the assertion timeout.
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("status")).toHaveText("Saved");

Control the clock

Anything that depends on the current time, such as expiry banners, "due today" badges, or reports for last month, will eventually run on a day that breaks it. Control time explicitly. In unit tests, pass a clock into the code under test. In browser tests, Playwright's clock API fixes the time the page sees, and setting timezoneId in the test configuration stops the runner's zone from changing the result.

TypeScript
test("shows the renewal banner seven days before expiry", async ({ page }) => {
  // The fixture account's subscription renews on 2026-03-31.
  await page.clock.setFixedTime(new Date("2026-03-24T09:00:00Z"));
  await page.goto("/account");
  await expect(page.getByRole("alert")).toContainText("Renews in 7 days");
});

05 / 05

A team policy that keeps the suite honest

  • A test that passes on retry is a flake, recorded and visible, never a silent pass.
  • A known flaky test leaves the blocking gate within a day and keeps running in quarantine.
  • Every quarantined test has an owner and an expiry date, and an expired quarantine fails the build.
  • Each fix records its root-cause category, so recurring causes become engineering work rather than individual chores.
  • The flake rate is reviewed with build time and failure rate as a measure of the suite's health.

The aim is not zero flaky tests forever. It is a suite where red means something, so that when it fails, people read it. That is the standard we hold test automation work to.

References

  1. Flaky Tests at Google and How We Mitigate Them Google Testing Blog, 2016

    Flake rates across a very large test corpus, and the mitigations Google used.

  2. An Empirical Analysis of Flaky Tests Luo, Hariri, Eloussi, and Marinov, FSE 2014

    Root causes and fixes of flaky tests across open-source projects.

  3. Eradicating Non-Determinism in Tests Martin Fowler, 2011

    Quarantine, isolation, asynchrony, and time as sources of non-determinism.

  4. Assertions Playwright documentation

    Auto-retrying, web-first assertions.

  5. Auto-waiting Playwright documentation

    The actionability checks Playwright performs before each action.

  6. Clock Playwright documentation

    Controlling time in browser tests.

HOW NEURALSHARKS CAN HELP

Related NeuralSharks services