Flakiness by the numbers

A flaky test passes and fails on the same code. The pipeline page introduces the idea; this page is about living with a large suite, where flakiness stops being an annoyance and becomes a property of the whole system. Google has published unusually concrete numbers on it:

  • John Micco (2016): "about 1.5% of all test runs" gave a flaky result, "almost 16% of our tests have some level of flakiness", and "about 84% of the transitions we observe from pass to fail involve a flaky test". In other words, most of the time a test went from green to red, the code was not the reason.
  • Jeff Listfield (2017): over a week, 0.5% of small tests, 1.6% of medium tests and 14% of large tests were flaky. Tests driving a browser or a phone emulator were flakier, but his conclusion was that "size is more predictive than tool": the more a test pulls in, the more can go wrong.

Note the different measures: 1.5% of runs, 16% of tests. Both matter, because a pipeline runs many tests at once. Kestrel's portal suite has about 1,200 tests. Suppose a dozen of them are flaky, each failing in 2% of runs for no reason:

def green_chance(flaky_tests, fail_rate, attempts=1):
    """Chance that a pipeline run is green when every flaky test fails
    independently with fail_rate per attempt, and gets `attempts` tries."""
    still_red = fail_rate ** attempts  # a test is red only if every try fails
    return (1 - still_red) ** flaky_tests


print("Flaky tests  red runs (no rerun)  red runs (1 rerun)")
for flaky in (1, 5, 12, 30):
    no_rerun = 1 - green_chance(flaky, 0.02)
    one_rerun = 1 - green_chance(flaky, 0.02, attempts=2)
    print(f"{flaky:>11}  {no_rerun:>19.1%}  {one_rerun:>18.2%}")

print()
# A real bug that breaks the test in half of all runs (a race condition)
for attempts in (1, 2, 3):
    print(f"Real 50% bug caught with {attempts} attempt(s): {0.5 ** attempts:.1%}")
Flaky tests  red runs (no rerun)  red runs (1 rerun)
          1                 2.0%               0.04%
          5                 9.6%               0.20%
         12                21.5%               0.48%
         30                45.5%               1.19%

Real 50% bug caught with 1 attempt(s): 50.0%
Real 50% bug caught with 2 attempt(s): 25.0%
Real 50% bug caught with 3 attempt(s): 12.5%

With twelve flaky tests, more than one pipeline run in five is red for nothing. Rerunning failed tests once makes the problem almost disappear, which is exactly why it is tempting. The last three lines show the price: a genuine intermittent bug, such as a race condition that breaks the code in half of all runs, is caught only a quarter of the time with one rerun. Reruns cannot tell a flaky test from a flaky product.

Causes and fixes

Flaky tests feel random, but their causes are a short, familiar list. Each has a standard fix:

Cause Typical symptom Fix
Fixed waits, asynchronous work Fails on a busy CI runner, passes locally Wait for a condition (an element, a message, a status), not for a number of seconds
Shared state, test order Fails alone or in another order; passes in the full run Every test creates its own data; fresh fixtures; no module-level state
The real clock, time zones Fails around midnight, at month end, on DST days Pass the date or clock in; test fixed dates on purpose
Randomness Fails once in a while with odd data Seed it with a random seed and log the seed
Concurrency Fails more often on machines with more cores Fix the race in the code or the test; it may be a real bug
External services, network Fails when a test API or DNS is slow Replace them with test doubles or a local container; keep a few real checks separate
Environment and resources Fails only on CI, or only when jobs run in parallel Same versions everywhere; unique ports, files and database names per job

Order-dependent tests

An order-dependent test passes or fails depending on which tests ran before it, because it relies on state that another test created or changed. It is easy to write by accident, and the full suite hides it: the test only fails when it runs alone, in another order, or in parallel on another worker. This module stores readings in a dictionary at module level:

cat > test_readings.py <<'EOF'
# A tiny in-memory reading store, shared by every test in the module
readings = {}


def add_reading(meter_id, kwh):
    readings.setdefault(meter_id, []).append(kwh)


def daily_total(meter_id):
    return round(sum(readings.get(meter_id, [])), 2)


def test_add_reading():
    add_reading("M-30001", 0.25)
    add_reading("M-30001", 0.40)
    assert len(readings["M-30001"]) == 2


def test_daily_total():
    # Quietly relies on the readings that test_add_reading left behind
    assert daily_total("M-30001") == 0.65
EOF

pytest -q                       # the whole file, in file order
pytest -q -k test_daily_total   # the same test, on its own
..                                                                       [100%]
2 passed in 0.16s
F                                                                        [100%]
=================================== FAILURES ===================================
_______________________________ test_daily_total _______________________________

    def test_daily_total():
        # Quietly relies on the readings that test_add_reading left behind
>       assert daily_total("M-30001") == 0.65
E       AssertionError: assert 0 == 0.65
E        +  where 0 = daily_total('M-30001')

test_readings.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_readings.py::test_daily_total - AssertionError: assert 0 == 0.65
1 failed, 1 deselected in 0.16s

Nothing is random here, yet in a real pipeline it looks random: it breaks the day someone deletes or moves test_add_reading, runs one test on its own while debugging, or turns on parallel test runs that put the two tests on different workers. The fix is that every test gets a fresh store and creates its own data. A pytest fixture does that (see Unit Testing with pytest). Run on its own again, with pytest -q -k test_daily_total, the test now passes:

import pytest


class ReadingStore:
    def __init__(self):
        self.readings = {}

    def add(self, meter_id, kwh):
        self.readings.setdefault(meter_id, []).append(kwh)

    def daily_total(self, meter_id):
        return round(sum(self.readings.get(meter_id, [])), 2)


@pytest.fixture
def store():
    return ReadingStore()  # a new, empty store for every test


def test_add_reading(store):
    store.add("M-30001", 0.25)
    store.add("M-30001", 0.40)
    assert len(store.readings["M-30001"]) == 2


def test_daily_total(store):
    store.add("M-30001", 0.25)  # the test creates the data it needs
    store.add("M-30001", 0.40)
    assert store.daily_total("M-30001") == 0.65
.                                                                        [100%]
1 passed, 1 deselected in 0.17s

The same problem appears one level up with a shared test database: one test inserts customer C-10001, another counts customers and expects 3. Give each test its own transaction that is rolled back, or its own database or schema (see Integration and Contract Testing). Running the suite in random order now and then, with a plugin such as pytest-randomly (not installed here), makes these hidden dependencies surface early.

Fixed waits

The classic flaky end-to-end test waits a fixed time and hopes the page is ready. This one is from the Portal team's suite for the cost page:

import time

from playwright.sync_api import Page, expect


def test_night_cost_flaky(page: Page):
    page.goto("https://portal-test.kestrel.example.com/costs")
    page.get_by_role("button", name="This month").click()
    time.sleep(2)  # "the chart is always there after 2 seconds"
    assert page.get_by_test_id("night-cost").inner_text() == "€17.80"


def test_night_cost(page: Page):
    page.goto("https://portal-test.kestrel.example.com/costs")
    page.get_by_role("button", name="This month").click()
    # Retries until the text matches or the timeout (5 s by default) ends
    expect(page.get_by_test_id("night-cost")).to_have_text("€17.80")

Two seconds is usually enough on a laptop and sometimes not on a CI runner shared with other jobs, or on the day the test database is slower. Making it ten seconds makes the suite slow and only moves the problem. The second version waits for the condition itself and continues as soon as it is true. Playwright's assertions retry automatically; see End-to-End and UI Testing.

Time and dates

A test that calls date.today() or datetime.now() tests a different input every day. At Kestrel the obvious trap is daylight saving time: a test asserting that "today" has 96 quarter-hours passes all year and fails on 29 March and 25 October. Make time an input: pass the date or a clock object into the code, and write separate tests for the special days on purpose (see Test-Driven Development, where the 92- and 100-quarter-hour days drive the implementation). A pinned date also keeps the test meaningful in ten years.

Finding flaky tests

You cannot fix what you have not identified. Useful signals:

  • Same commit, different result. A test that failed and then passed on the same commit is flaky by definition. CI systems and test reporting tools can record this per test.
  • Repeat runs. Run a suspect test 50 or 100 times in a loop, on CI and not only locally. A failure rate of 1% needs many runs to show up.
  • Run alone and shuffled. A test that only fails alone or in another order is order-dependent (above).
  • Look at the failure, not just the color. A timeout in a wait, a "connection refused" or a missing element points to timing and environment, not to your change.

Automatic reruns

An automatic rerun repeats a failed test once or more within the same pipeline run and counts it as passed if a later attempt passes. Many CI systems and plugins offer it; for pytest it is the pytest-rerunfailures plugin (not installed on this site's test machine, so the output below is illustrative):

pytest -q --reruns 2 tests/e2e/
.....R.....                                                              [100%]
10 passed, 1 rerun in 41.37s

Reruns are a reasonable first-aid measure for end-to-end suites, on one condition: every rerun must be reported and counted. "1 rerun" in the summary is a flaky test announcing itself. A team that hides reruns behind a green check loses both the signal and, as the calculation above shows, most real intermittent bugs.

Quarantine

When a test turns out to be flaky and the cause is not fixed within the hour, take it out of the path that blocks merges, without losing it. A test quarantine moves known flaky tests into a separate, non-blocking part of the pipeline, where they still run and report, while each one is tracked as a bug with an owner and a deadline.

The life cycle of a quarantined test. A flaky failure is seen. A ticket is created with an owner and a deadline. The test is moved to the quarantine job, which still runs but does not block merges. When the root cause is fixed and the test passes repeatedly, it returns to the blocking suite. If the deadline passes without a fix, the test is rewritten or deleted. Flaky failure seen on CI Ticket owner, deadline Quarantine runs, not blocking Root cause fixed passes 100 times back into the blocking suite Deadline passed rewrite or delete the test
Quarantine is a waiting room with an exit, not a place where tests go to be forgotten.

In pytest, a registered marker is the simplest mechanism: the blocking job deselects quarantined tests with -m "not quarantine", and a second job runs only them. The marker's reason carries the ticket:

cat > pyproject.toml <<'EOF'
[tool.pytest]
markers = [
    "quarantine: known flaky test with a ticket; does not block merges",
]
EOF

cat > test_costs.py <<'EOF'
import pytest


def night_rate(hour):
    return hour < 7 or hour >= 23


def test_23_is_night():
    assert night_rate(23)


def test_7_is_day():
    assert not night_rate(7)


def test_6_is_night():
    assert night_rate(6)


@pytest.mark.quarantine(reason="BUG-1046, owner Kwame Asante, fix by 2026-10-16")
def test_cost_page_shows_split():
    assert True  # stands in for the flaky end-to-end test
EOF

echo "--- blocking job"
pytest -q -m "not quarantine"
echo "--- quarantine job (allowed to fail)"
pytest -q -m quarantine
--- blocking job
...                                                                      [100%]
3 passed, 1 deselected in 0.41s
--- quarantine job (allowed to fail)
.                                                                        [100%]
1 passed, 3 deselected in 0.21s

In GitHub Actions the second job gets continue-on-error: true, so its failures show up in the run but do not fail the workflow or block a merge through a quality gate:

jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: pip install -r requirements-dev.txt
      - run: pytest -m "not quarantine"

  quarantine:
    runs-on: ubuntu-latest
    continue-on-error: true # reported, never blocking
    steps:
      - uses: actions/checkout@v7
      - run: pip install -r requirements-dev.txt
      - run: pytest -m quarantine -rA

Kestrel's first quarantined test was the cost page test with the two-second sleep above. Kwame Asante replaced the sleep with a waiting assertion, ran it 100 times on CI without a failure, and moved it back. Because three more end-to-end tests had shown the same pattern, Amara Okafor, as problem manager, opened PRB-0720 ("Flaky end-to-end suite on the portal") to find the shared cause in the test environment, not just patch each test.

Test smells

A test smell is a sign in test code that something is wrong with the test's design, even if it currently passes. Like code smells, a smell is a reason to look, not proof of a problem. Gerard Meszaros catalogued many of them in xUnit Test Patterns (2007); his names are still common:

Smell What you notice Usual remedy
Erratic Test Sometimes passes, sometimes fails (flaky) The causes and fixes above
Fragile Test Breaks after changes that did not change behavior, such as a renamed CSS class Test through stable interfaces (roles, public functions), not internals
Obscure Test You cannot see what the test checks or why Arrange-act-assert, good names, less setup in the way
Mystery Guest The test depends on a file or database row you cannot see in the test Create the data in the test or a visible fixture
Assertion Roulette Many assertions without messages; a failure does not say which one Fewer assertions per test, or parametrize
Conditional Test Logic if and loops inside the test itself Split into separate tests; parametrize
Slow Tests Developers stop running the suite locally Push tests down the test pyramid; replace slow neighbors with doubles

One smell not in every catalog deserves a mention: the test that cannot fail. An assertion on a value the test itself set, a mock that returns the expected answer, a try/except that swallows the failure. Mutation testing finds these systematically (see Code Coverage and Mutation Testing).

Maintaining a test suite

A test suite is code that the team has to read, change and run for years. Treat it that way:

  • Review test code in pull requests as carefully as production code. A wrong expected value in a test is a bug that guards another bug.
  • Refactor tests: shared setup into fixtures, test data into builders, repeated cases into parametrized tests.
  • Delete tests that earn nothing: duplicates, tests for removed features, tests that check only that a mock was called. Fewer, clearer tests beat many vague ones.
  • Update tests with behavior. When the variable tariff changed from €0.2500 to €0.2600 on 1 August 2026, the test that pinned the old price should fail, and the fix is to change the test together with the code, in the same pull request.
  • Watch the suite's health: total duration, the slowest tests, the number of quarantined and skipped tests, and how often the main branch is red. Put these on the team's dashboard and discuss them in the Sprint Retrospective (see Quality in Agile Teams).

Testing in production

No test environment equals production. Real traffic has other volumes, other data, other browsers and other timing, and a few problems only appear there. Testing in production means deliberately checking a new version in the production environment, in a controlled way, in addition to (never instead of) testing before release. The techniques are covered elsewhere on this site:

Technique What it tests
Canary release The new version on a small share of real traffic, compared with the old version
Feature flag A feature for staff or a few customers first, switched off in seconds if needed
Dark launch New code running on real data in the background, invisible to users
Synthetic monitoring Scripted user journeys, around the clock (below)
Real user monitoring What actual users experience in their browsers (below)
Chaos engineering How the system copes with injected failures

For the release of the cost page (change CHG-0781), the Portal team plans: deploy with the feature flag off; switch it on for Kestrel staff for two days; then 5% of customers through the flag, watched like a canary: error rate and latency compared with the old page; then 25%, 50% and 100% if the numbers stay within the service level objective. The oracle in production is monitoring: without good metrics and logs, a canary is just a smaller outage.

  • A kill switch. Every production test needs a way back that takes seconds: the flag off, traffic back to the old version.
  • Limits agreed in advance. Which numbers stop the rollout, and who decides? The release plan's back-out criteria, or the error budget policy, is the place to write this down.
  • No harm to customers or their data. Never test by changing real customers' contracts, readings or invoices. Use dedicated test accounts, and keep personal data out of test tooling.

Synthetic monitoring and real user monitoring

Synthetic monitoring runs scripted user journeys against the live system on a schedule, from outside, with test accounts: log in, open the cost page, check that the numbers are there. It is a form of black-box monitoring that goes beyond a single page load, and it is often the same end-to-end test, run against production every few minutes. Because it runs when no customer is active, it finds a broken journey at 03:00 instead of at the first customer's call at 08:00.

Kestrel runs a synthetic check of the cost page every five minutes as a test customer with meter M-TEST-0001. The check below shows the idea, against a small stand-in for the portal started inside the script. Note that it checks the content, not only the status code, and that it identifies itself with a header so the portal can keep synthetic traffic out of business numbers:

import json
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# A stand-in for the portal's cost API, with a switch to break it
state = {"broken": False}
requests_seen = {"customer": 0, "synthetic": 0}


class Portal(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # no request log lines in the output

    def do_GET(self):
        synthetic = self.headers.get("X-Synthetic-Check") is not None
        requests_seen["synthetic" if synthetic else "customer"] += 1
        parts = [] if state["broken"] else [
            {"part": "day", "eur": "41.12"},
            {"part": "night", "eur": "17.80"},
        ]
        body = json.dumps({"meter": "M-TEST-0001", "parts": parts}).encode()
        self.send_response(200)  # the bug still answers 200 OK
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def check_cost_page(base_url):
    """One synthetic check: load the test customer's costs as a user would."""
    request = urllib.request.Request(
        base_url + "/api/costs/this-month",
        headers={"X-Synthetic-Check": "kestrel-synthetics"},
    )
    started = time.monotonic()
    with urllib.request.urlopen(request, timeout=5) as response:
        status = response.status
        data = json.load(response)
    seconds = time.monotonic() - started
    if status != 200:
        return f"FAIL: HTTP {status}"
    if sorted(part["part"] for part in data["parts"]) != ["day", "night"]:
        return "FAIL: HTTP 200, but the day/night split is missing"
    if seconds > 2.0:
        return "FAIL: slower than 2 seconds"
    return "OK"


server = ThreadingHTTPServer(("127.0.0.1", 0), Portal)
threading.Thread(target=server.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{server.server_port}"

print("09:00 check:", check_cost_page(base))
urllib.request.urlopen(base + "/api/costs/this-month").read()  # a real customer
state["broken"] = True  # a bad release goes out
print("09:05 check:", check_cost_page(base))
print("Requests seen:", requests_seen)
server.shutdown()
09:00 check: OK
09:05 check: FAIL: HTTP 200, but the day/night split is missing
Requests seen: {'customer': 1, 'synthetic': 2}

Real user monitoring (RUM) measures what actual users experience: a small script in the page reports load times, errors and the browser used, back to a collector. It covers every device and network that customers really have, which no synthetic check can, but it only sees journeys that users take, and only while they take them. The two complement each other: synthetic checks for "is it working right now?", RUM for "how is it for everybody?".