Flaky Test Triage skill for Claude Code

Find flakes from CI data and fix the cause. Claude treats flakiness as a measurable defect. It aggregates JUnit reports from many CI runs to find tests that flip, ranks them by pipeline cost, quarantines the worst into a non-blocking job with an owner, reproduces them under repeat, shuffle and CPU stress, and only calls a fix done when the failure rate drops to zero over an equal number of runs.

  • nameflaky-test-triage
  • size149 lines · ≈1,750 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Detects, measures, quarantines, and fixes flaky tests in CI using data instead of reruns. Covers mining JUnit reports across runs for pass/fail flips, reproducing with repeat and shuffle flags in Playwright, Jest, Vitest, pytest, and Go, classifying root causes (async waits, shared state, ordering, time, network, resources), a quarantine policy with owners, and proving a fix by failure rate. Use when CI is red intermittently, when retries hide failures, when a test passes locally but fails in CI, or when setting up a flaky-test process for a team.

Try asking

  • CI fails randomly about once a day. Find which tests are flaky from these reports
  • This Playwright test passes locally but fails in GitHub Actions sometimes
  • Set up a quarantine process for flaky tests on our team

Use it when

  • CI goes red intermittently without code changes
  • Retries are hiding failures and slowing pipelines
  • A test passes locally but fails in CI
  • Designing a team policy for quarantining flaky tests

Skip it when

How to check the result

The reproduction loop that showed failures now passes with zero failures over at least as many runs, and the quarantine job stays green for the agreed window before release.

The common mistake it prevents

Adding a sleep or another retry so the test stops failing, which hides the race while it keeps costing pipeline time and sometimes masks a real product bug.

~/.claude/skills/flaky-test-triage/SKILL.md

--- name: flaky-test-triage description: Detects, measures, quarantines, and fixes flaky tests in CI using data instead of reruns. Covers mining JUnit reports across runs for… license: MIT ---

A flaky test is a test whose result changes without a code change. Done well: flakes are found from data, ranked by cost, quarantined with an owner within a day, fixed at the root cause, and released from quarantine only after a measured run of passes. Blanket retries are a painkiller, not a cure.

Ground rules

  • Never “fix” a flake by adding a sleep, a longer timeout, or another retry without knowing the mechanism.
  • A fix is proven only by failure rate: the same number of repeated runs that showed failures must now show zero.
  • Quarantine is not deletion. Quarantined tests keep running in a non-blocking job so the failure rate stays visible.
  • Sometimes the flake is a real product bug (race in production code). Check before blaming the test.

Workflow

- [ ] 1. Collect evidence: which tests flip, how often, where
- [ ] 2. Rank by cost (failure rate x pipeline runs x time lost)
- [ ] 3. Quarantine top offenders with owner and issue
- [ ] 4. Reproduce locally or in CI with repeat/shuffle/parallel stress
- [ ] 5. Classify the mechanism and fix the root cause
- [ ] 6. Prove the fix by failure rate; release from quarantine
- [ ] 7. Prevent: lint rules, isolation defaults, retry reporting

1. Collect evidence

Download JUnit XML reports from the last N CI runs (artifacts) and aggregate them:

python3 scripts/junit_flakes.py ci-reports/            # directory of XML files from many runs
python3 scripts/junit_flakes.py run-*/junit.xml --min-runs 5 --json > flakes.json

The script lists tests that both passed and failed across runs (or were retried within a run), with failure rate, retry counts, duration spread, and the most common failure message; it separates consistently failing tests, which are real failures. It exits 1 if any flaky test is found.

Other data sources: test reporters’ built-in flaky markers (Playwright reports tests that passed on retry as “flaky”), CI analytics (GitHub Actions re-run counts, Buildkite or CircleCI test insights), and “re-run failed jobs” frequency on main.

2. Rank

Cost ≈ failure rate x runs per day x (pipeline duration + human time per re-run). A 2% flake in a test that runs 300 times a day blocks six pipelines daily. Fix the top three first; long tails can wait.

3. Quarantine

FrameworkMechanism
PlaywrightTag { tag: '@quarantine' }; main job --grep-invert @quarantine, separate non-blocking job --grep @quarantine
Jest / VitestMove to a quarantine project or use a naming convention excluded from the blocking run; run it in a non-blocking job
pytest@pytest.mark.quarantine (registered marker); blocking job -m "not quarantine"
GoBuild tag or t.Skip guarded by an env var, run unskipped in a nightly job
JUnit 5@Tag("quarantine") and exclude it in the blocking build

Every quarantine entry gets: an issue link, an owner, a date, and the measured failure rate. Set a time limit (e.g., two weeks) after which it escalates.

4. Reproduce

Loop the single test first, then add stress:

npx playwright test e2e/cart.spec.ts --repeat-each=50 --workers=4 --retries=0
npx vitest run src/cart.test.ts --sequence.shuffle --retry=0     # loop in shell for counts
npx jest src/cart.test.ts --runInBand                            # vs. parallel to detect shared state
pytest tests/test_cart.py --count=50 -x                          # needs pytest-repeat
pytest -p no:randomly tests/                                     # compare with pytest-randomly on
go test ./cart -run TestCheckout -count=200 -race

Shell loop for any runner:

fails=0; for i in $(seq 1 50); do npm test -- cart >/dev/null 2>&1 || fails=$((fails+1)); done; echo "$fails/50 failed"

If it never fails locally, reproduce CI conditions: same container image, CPU limits (docker run --cpus=2), same parallelism, same test ordering or shard, same time zone (TZ=UTC), cold caches. Capture artifacts on failure (traces, logs, DB dumps).

5. Classify and fix

Experiment resultMechanismReal fix
Fails under CPU load or parallelismMissing await, race, timing assumptionAwait the operation; wait on an observable condition; web-first assertions
Fails only after a specific other testShared state leak (globals, DB rows, mocks, env)Reset in afterEach; per-test transaction or unique data; restore mocks
Fails only with shuffleOrder dependenceMake each test create its own preconditions
Fails around midnight/month-end/DST or in other TZReal clockInject or freeze time
Fails with different seedUnseeded randomnessSeed and log the seed; constrain generated data
Fails with parallel workers onlyShared port, file, DB schema, accountPort 0, temp dirs per test, schema/account per worker
Fails when network is slowReal external callsStub at HTTP boundary
Timeouts on CI onlyResource starvation, slow cold startProfile; reduce work; raise timeout only with evidence
Product race foundReal concurrency bugFix production code; keep the test as the regression guard

E2E-specific: replace waitForTimeout with expect(locator).toBeVisible() or page.waitForResponse; avoid asserting on transient states (spinners) that may finish before the check; make test data unique per test (e2e-${testInfo.testId}).

6. Prove and release

  • Re-run the reproduction loop with at least as many iterations as the one that failed (for a 2% flake, 200+ runs gives reasonable confidence).
  • Release from quarantine after the fix merges and the non-blocking job shows zero failures for an agreed window (e.g., 50 consecutive CI runs).

7. Prevent

  • Default to retries: 0 locally; in CI allow at most 1–2 retries and report every pass-on-retry as flaky (fail the build on new flakes if the tooling supports it).
  • Lint: forbid waitForTimeout, sleep in tests, focused tests (forbidOnly), and unawaited promises (@typescript-eslint/no-floating-promises).
  • Isolation defaults: restoreMocks: true, per-test DB transactions, random test order enabled in CI.

Gotchas

  • Retrying at the job level hides flakes from test-level reporting entirely.
  • A flake that disappears when you add logging is timing-sensitive; use low-overhead tracing instead of breakpoints.
  • Infrastructure flakes (runner OOM, registry timeouts) look like test flakes. Check whether failures cluster by runner or step, not by test.
  • Fixing the symptom in the test while the product has a real race ships the race.

Output

**Flake:** CartPage › applies coupon (e2e/cart.spec.ts) — 7/300 CI runs failed (2.3%)
**Mechanism:** assertion ran before POST /api/coupon resolved; button re-rendered and the
locator matched a detached element.
**Fix:** await the response and assert on the updated total with a web-first assertion.
**Proof:** `--repeat-each=300 --workers=6` → 0 failures (was 6/300).
**Quarantine:** released after 50 green non-blocking runs. Issue #1234 closed.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/junit_flakes.py 133 lines · 4.9 KB
#!/usr/bin/env python3
"""Find flaky tests across many JUnit XML reports.

Usage:
  python3 scripts/junit_flakes.py <report-or-dir> [<report-or-dir> ...] [--min-runs 3] [--json]

Point it at JUnit XML files from several CI runs of the same commit range (or a
directory tree of them). A test is reported as flaky when it both passed and failed
across the runs, or when a report records an in-run retry (Surefire-style
<flakyFailure>/<flakyError>/<rerunFailure>/<rerunError> elements).

Exit codes: 0 = no flaky tests found, 1 = flaky tests found, 2 = no parsable reports.
"""
import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict

RETRY_TAGS = {"flakyFailure", "flakyError", "rerunFailure", "rerunError"}


def iter_files(paths):
    for p in paths:
        if os.path.isdir(p):
            for root, _, files in os.walk(p):
                for f in files:
                    if f.endswith(".xml"):
                        yield os.path.join(root, f)
        elif os.path.isfile(p):
            yield p


def first_line(text):
    text = (text or "").strip()
    return text.splitlines()[0][:160] if text else ""


def parse(path, stats):
    try:
        tree = ET.parse(path)
    except ET.ParseError as exc:
        print(f"warn: skipping {path}: {exc}", file=sys.stderr)
        return False
    for case in tree.getroot().iter("testcase"):
        tid = f"{case.get('classname') or case.get('file') or '?'}::{case.get('name')}"
        s = stats[tid]
        tags = {child.tag for child in case}
        if "skipped" in tags:
            s["skipped"] += 1
            continue
        failed = [c for c in case if c.tag in ("failure", "error")]
        if failed:
            s["failed"] += 1
            msg = first_line(failed[0].get("message") or failed[0].text)
            if msg:
                s["messages"][msg] += 1
        else:
            s["passed"] += 1
        retries = [c for c in case if c.tag in RETRY_TAGS]
        if retries:
            s["retried"] += len(retries)
            msg = first_line(retries[0].get("message") or retries[0].text)
            if msg:
                s["messages"][msg] += 1
        try:
            s["time"].append(float(case.get("time") or 0))
        except ValueError:
            pass
    return True


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("paths", nargs="+")
    ap.add_argument("--min-runs", type=int, default=2, help="ignore tests seen in fewer runs (default 2)")
    ap.add_argument("--json", action="store_true", help="print machine-readable output")
    args = ap.parse_args()

    stats = defaultdict(lambda: {"passed": 0, "failed": 0, "skipped": 0, "retried": 0,
                                 "messages": Counter(), "time": []})
    parsed = sum(parse(f, stats) for f in iter_files(args.paths))
    if not parsed:
        print("error: no parsable JUnit XML reports found", file=sys.stderr)
        sys.exit(2)

    flaky, broken = [], []
    for tid, s in stats.items():
        runs = s["passed"] + s["failed"]
        if runs < args.min_runs and not s["retried"]:
            continue
        if (s["passed"] and s["failed"]) or s["retried"]:
            rate = s["failed"] / runs if runs else 0.0
            flaky.append((tid, s, runs, rate))
        elif s["failed"] and not s["passed"]:
            broken.append((tid, s, runs))

    flaky.sort(key=lambda x: (-x[3], -x[1]["retried"], x[0]))

    if args.json:
        out = {
            "reports": parsed,
            "flaky": [{"test": t, "runs": r, "failed": s["failed"], "retried": s["retried"],
                       "failure_rate": round(rate, 4),
                       "top_message": (s["messages"].most_common(1) or [("", 0)])[0][0]}
                      for t, s, r, rate in flaky],
            "consistently_failing": [t for t, _, _ in broken],
        }
        print(json.dumps(out, indent=2))
    else:
        print(f"Parsed {parsed} report(s), {len(stats)} distinct test(s).\n")
        if flaky:
            print(f"FLAKY ({len(flaky)}): failure rate across runs, in-run retries, top message")
            for tid, s, runs, rate in flaky:
                msg = (s["messages"].most_common(1) or [("", 0)])[0][0]
                times = s["time"]
                spread = f"{min(times):.2f}-{max(times):.2f}s" if times else "-"
                print(f"  {rate:6.1%}  {s['failed']}/{runs} runs  retries={s['retried']}  time={spread}  {tid}")
                if msg:
                    print(f"          {msg}")
        else:
            print("No flaky tests detected.")
        if broken:
            print(f"\nCONSISTENTLY FAILING ({len(broken)}) - real failures, not flakes:")
            for tid, s, runs in broken:
                print(f"  {s['failed']}/{runs} runs  {tid}")

    sys.exit(1 if flaky else 0)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc