Documentation

Everything on one page. Use ⌘K to search, or the contents on the left.

Install flakestat#

Same binary whichever route you pick. No runtime to install alongside it.

install
brew install rowhitswami/tap/flakestat

Or download a binary directly from Releases. macOS, Linux and Windows, on x86_64 and arm64. The binary is statically linked and has no runtime dependencies.

Verify the install#

flakestat version
flakestat --help

Which install should I use?#

RouteBest when
brewYou work on macOS or Linux and want it on your PATH globally.
npmThe project is JS or TS. Pins the version in package.json so CI and laptops match.
pipThe project is Python. Same benefit: the version is pinned with your other dev tooling.
go installYou have Go and want to build from source.
ScriptCI images without a package manager. Pin with -b and a version tag.
DockerYou'd rather not install anything. Mount the repo at /workspace.
Pin the version in CI A floating latest means a scoring change can move your verdicts between builds. The GitHub Action takes a version input, and the install script takes a tag.

Next#

Head to the quickstart. Two commands, and you'll know whether your suite is flaky.

Quickstart#

Two ways to use flakestat. They answer different questions, and most projects end up doing both.

1. Hunt: is this suite flaky right now?#

Runs your test command N times on unchanged code and reports what disagreed. Use it when you already suspect a suite, or before opening a pull request.

hunt for flaky tests
flakestat hunt --runs 20 --junit 'reports/junit-{run}.xml' \
  -- pytest --junitxml='{junit}'

{run} becomes the run number so each run writes its own report, and {junit} becomes that path inside your command. Everything after -- is your command, passed through untouched.

Let it detect your setup#

flakestat init      # writes .flakestat.json
flakestat hunt      # no flags needed

init looks at your project and writes the command and report path once, so the day-to-day invocation is a single word. See configuration.

2. Ingest: which tests are flaky over time?#

Records the CI runs you already pay for. This is the one that catches flakes that only appear on one platform, one runtime, or under CI load, and it costs no extra compute.

# in CI, after your tests run
flakestat ingest 'reports/**/*.xml'

# any time
flakestat report --top 20

See tracking flakiness over time for where to keep the history, and GitHub Actions for a complete workflow.

Which should I use? Hunt when you need an answer in the next ten minutes. Ingest when you want a reliable picture of the whole suite. They write to the same history, so using both just gives you more evidence.

What you get back#

flakestat report
VERDICT               SCORE  CONF  RUNS  PASS/FAIL  TEST
flaky                  0.62  high    20       14/6  test_demo::test_flaky_race
suspect                0.08   low     6        5/1  test_api::test_timeout
consistently-failing   0.00  high    20       0/20  test_demo::test_broken

Read the columns in reading a report, or ask about one test with flakestat explain.

If it finds nothing#

That is a real result, not a failure. It means nothing disagreed in that many runs. A test that fails 2% of the time will usually survive 20 runs untouched. Raise --runs, or record CI history where the sample grows for free.

Reading a report#

Six verdicts, one score and one confidence level. Here is what each of them is claiming.

VERDICT               SCORE  CONF  RUNS  PASS/FAIL  TEST
flaky                  0.62  high    20       14/6  test_demo::test_flaky_race
suspect                0.08   low     6        5/1  test_api::test_timeout
consistently-failing   0.00  high    20       0/20  test_demo::test_broken

Verdicts#

flakyDisagrees with itself often enough, with enough evidence, to act on.
suspectSome disagreement, below the flaky threshold. Worth watching, not worth a ticket yet.
stableNo meaningful disagreement.
consistently-failingFails every time. Broken, not flaky, and a different problem with a different fix.
insufficient-dataFewer runs than needed for a verdict. More runs would help.
always-skippedNever actually ran. More runs would not help, so this is deliberately not the same as insufficient data.

Score is not a failure rate#

Score is how often a test disagrees with itself: 0.62 means roughly six of ten consecutive runs changed their answer. A test that fails every single time scores 0.00, because its outcome never disagrees with itself. It is broken, and it shows up as consistently-failing so you can fix it as the different problem it is.

Why this matters A failure-rate ranking puts your most broken test at the top of the flaky list, where it wastes the time of whoever is hunting nondeterminism. Ranking by inconsistency puts the genuinely nondeterministic tests there instead.

Confidence#

Confidence is low, medium or high depending on how much evidence sits behind the score. A score of 0.62 from three runs and the same score from three hundred are very different claims, and a small sample can never reach high no matter how flaky the test looks.

Classification uses a lower bound on the score rather than the score itself, so a verdict requires evidence rather than a lucky flip in a short run. The mechanics are in how scoring works.

ColumnMeaning
SCOREWeighted rate at which consecutive runs disagreed.
CONFHow much evidence supports that score.
RUNSScored observations. Skips are excluded, because a skip is not evidence either way.
PASS/FAILCounts among scored runs.
TESTStable identity, from suite, class and name.

Other output formats#

flakestat report --format json      # for tooling
flakestat report --format markdown  # for a PR comment or wiki
flakestat report --all              # include stable and unscored tests
flakestat report --top 20           # worst 20 only

Hunting flaky tests locally#

Run the suite many times on unchanged code and see what disagrees.

hunt for flaky tests
flakestat hunt --runs 20 --junit 'reports/junit-{run}.xml' \
  -- pytest --junitxml='{junit}'

How many runs do I need?#

It depends entirely on how rare the flake is. These are measured against synthetic tests with known failure probabilities:

Flake rateRuns for reliable detection
25% or higher20 runs is plenty
10%50 or more
5%100+, and a local burst is starting to be the wrong instrument
Below 5%Record CI history instead

Chasing one test#

flakestat hunt --runs 50 --junit 'reports/junit-{run}.xml' \
  -- pytest tests/test_checkout.py::test_race --junitxml='{junit}'

Narrowing to one test is usually much faster per run, so you can afford far more runs, which is exactly what a rare flake needs.

If it finds nothing#

That is a real result. It means nothing disagreed in that many runs, which is evidence the test is not flaky at a rate this sample could detect. It is not evidence the test is clean. Raise --runs, or let CI history accumulate.

A warning about --parallel

--parallel N runs N copies of your command in the same working directory. A suite that writes fixed paths, binds a fixed port or shares a database will collide with itself and look flaky when it isn't.

This is real, not theoretical. Running against spf13/cobra with --parallel 4 reported TestDeadcodeElimination as flaky at 0.60. It isn't. the test builds a binary at a fixed path, and concurrent copies deleted each other's build. Sequentially, cobra is completely clean.

So flakestat verifies its own findings. Candidates found under --parallel are re-run sequentially and demoted from flaky to suspect if they don't reproduce. Sequential (the default) is always trustworthy. Use --parallel to hunt faster and let verification sort out the difference, or --verify 0 to opt out.

Scoring the whole history instead#

By default hunt scores only the burst it just ran. Add --history to score everything recorded so far, burst and CI runs together.

flakestat hunt --runs 20 --history

Tracking flakiness over time#

A burst proves flakiness exists. History measures it, and catches flakes a local run never will.

flakestat ingest 'reports/**/*.xml' \
  --commit "$GIT_SHA" --branch "$BRANCH"

Commit and branch default to the current git checkout, so in most CI setups you can omit them. Recording the commit is what lets flakestat tell genuine flakiness, same code and different result, from a regression someone later fixed.

History lives in .flakestat/runs.ndjson, one JSON object per line. Because it is append-only NDJSON, results from parallel CI shards concatenate with cat and no merge step.

Re-ingesting is safe#

Every observation carries the identity of the execution it describes, so an artifact uploaded twice, a re-run aggregation step, or a shard collected by two jobs is counted once, while a genuine retry, which really did run the tests again, still counts.

Duplicates are not harmless A duplicate carries its original's timestamp, sorts next to it, and always agrees with itself, so uncounted duplicates make a flaky test look stable. Measured on a test failing 4 of 12 runs, ingesting the same reports twice moved the score from 0.64 to 0.30. The error runs towards false negatives, which is the direction that loses tests quietly.

Copies are ignored on read, so nothing is required of you. flakestat compact removes them from the file as well, which is worth doing when the file is the record you keep.

Where to keep the history#

Commit it

Simplest. The file goes in the repo and everyone shares one history. Downside: every CI run wants to write to it, so telemetry lands in your development history.

A CI artifact

Zero setup, but nothing accumulates. Each run sees only itself, so you never build the long history that makes the tool accurate.

Recommended

A dedicated branch

What flakestat uses for itself. Keeps telemetry out of development history, avoids a bot commit retriggering your workflow, and gives one place to serialize writers.

the arrangement
matrix jobs → per-job NDJSON artifacts → aggregate job
  → fetch history branch → merge + compact → analyze
  → commit back to the history branch
.github/workflows/ci.yml
concurrency:
  group: flakestat-history-writer
  cancel-in-progress: false
Not a CI cache Cache eviction should cost you time, not statistical history. Keep the record in durable storage and let the cache stay an optimization.

flakestat's own CI workflow implements this end to end, including re-merging a push that lost a race against another run.

Trimming the file#

flakestat compact             # drop duplicate executions
flakestat compact --dry-run   # report what would go, change nothing

compact refuses to rewrite a log containing unreadable lines, since reading skips those and rewriting would delete evidence you might still recover.

Recording where tests ran#

Attach context to observations and flakestat will tell you where failures concentrate.

flakestat ingest 'reports/**/*.xml' \
  --dimension os=windows \
  --dimension runtime.version=3.13 \
  --dimension database=postgres-17
flakestat explain
Where the failures concentrate

  os=windows
    failures here:  12 / 12 (100.0%)
    elsewhere:      0 / 24 (0.0%)
    difference:     +100.0 pp

Two rules that keep it honest#

  • Nothing is scraped. Only a whitelist of known CI variables is read, and only JUnit <property> names flakestat recognizes. The process environment is never walked, so secrets, tokens and build ids cannot end up in your history.
  • Host details are recorded only when flakestat ran the tests. If one job downloads other jobs' artifacts and ingests them centrally, pass --no-host: otherwise the aggregator's platform is stamped onto results from everywhere else, and analysis can conclude the exact opposite of the truth.

Canonical keys#

KeyMeaning
os, archPlatform the tests ran on
runtime.name, runtime.versionLanguage runtime under test
ci.providergithub, gitlab, circleci, buildkite, jenkins, azure
ci.run_id, ci.job_idProvenance. Recorded, never analysed, since every failure happened during some run
ci.attemptSeparates a retry from a duplicate. Never analysed: retries happen because of failures
ci.shard, ci.workerWhich shard or worker executed the run

Anything else you pass is a user dimension and is analysed normally: database version, browser, feature flag, region.

Correlation, never causation#

flakestat says failures cluster on Windows. It will not claim Windows is why. Observational data cannot distinguish a cause from anything perfectly correlated with it, and when several dimensions vary together, as os and arch usually do on a CI matrix, it reports all of them and says the observations cannot tell which one matters.

Three guards stop it producing confident nonsense over sparse data: an evidence floor, an effect-size floor, and a Benjamini–Hochberg correction for how many dimensions were tested. Testing os, arch, runtime, browser and database will eventually turn up something that looks significant purely by chance.

Gating CI without a permanently red build#

Accept today's flakiness, then fail only on what is new or measurably worse.

Most repos already have flaky tests when they adopt a tool like this. Failing on all of them means a red build on day one, and a deleted gate by day three. So check is a ratchet rather than a threshold.

flakestat check --update-baseline    # once; commit .flakestat/baseline.json
flakestat check --fail-on-new        # in CI from then on
Exit codeMeaning
0Nothing new
1A test became flaky that wasn't in the baseline
2An accepted test got measurably worse (--fail-on-regression)

Keeping jitter out of your builds#

Scores move a little as evidence accumulates. --regression-delta (default 0.1) is how much an accepted test has to worsen before it counts as a regression, so ordinary drift doesn't fail builds.

Tightening the ratchet#

Tests that stop being flaky are reported as FIXED. That is your cue to re-run --update-baseline so the newly clean test can never silently regress.

flakestat check
FIXED   test_api::test_timeout      was 0.31, now 0.00
NEW     test_cart::test_checkout    0.42 (high)

1 newly flaky test. Baseline has 4 accepted.
Order matters in CI Ingest before you gate, and let the test step continue on error. A failed suite is exactly the run you most want recorded. See GitHub Actions for the full sequence.

Unblocking the pipeline#

Emit a skip list your runner already understands, so merges are unblocked while you fix things.

quarantine
flakestat quarantine --format pytest -o quarantine.txt
pytest @quarantine.txt
FlagEffect
--formatpytest, go, jest, yaml (default) or json
--include-brokenAlso list consistently failing tests
-oWrite to a file instead of stdout
--quietSuppress the summary line on stderr
A tourniquet, not a cure Everything in the file is something to fix. Quarantine buys you a working pipeline while you work the list down; it does not make the tests correct. Keep the file in review so it cannot grow quietly.

A workable rhythm#

  1. Quarantine what is blocking merges today.
  2. Commit a baseline so nothing new gets in.
  3. Fix the worst-ranked test, remove it from quarantine, update the baseline.
  4. Repeat. The list only shrinks, because the gate stops it growing.

Explaining a verdict#

Every verdict can be interrogated. Nothing is asserted that the evidence does not show.

flakestat explain test_checkout_flow
tests/test_checkout.py::test_checkout_flow
  id 4c1f9a2e77b3d810

  Classification: flaky
  Score:          0.55
  Confidence:     high  (lower bound 0.402 over 41 transition(s))

  Observations:   42
  Passed:         27
  Failed:         15
  Failure rate:   35.7%

  Transitions:               41
  Same-commit disagreements: 9
  Seen across:               6 commit(s), 2 branch(es)

  History  (last 20 of 42, oldest first)

  P F P P F P F F P P P F P P F P P F P P
    ^ ^   ^ ^ ^   ^     ^ ^   ^ ^ ^   ^

  P pass   F fail   -  skip   ^ flip   ! flip on identical code

Reading the strip#

SymbolMeaning
P / FPassed / failed
-Skipped. Carries no signal, so it is shown but never counted as an outcome
^The outcome changed from the previous comparable run
!It changed on identical code, the strongest single piece of evidence a test is flaky

Markers appear only between observations that are actually comparable: same branch, same platform, same runtime. A test that always passes on Linux and always fails on Windows is deterministic, so its strip carries no flip markers even though the outcomes differ.

Options#

flakestat explain test_name --json        # the same evidence, for tooling
flakestat explain test_name --history 80  # widen the strip
flakestat explain 4c1f9a2e77b3d810        # by id, when names are ambiguous

If a name matches several tests, flakestat lists the candidates with their ids rather than guessing which you meant.

GitHub Actions#

Record every run, comment on the pull request, and gate on regressions rather than on flakiness itself.

.github/workflows/ci.yml
- name: Run tests
  run: pytest --junitxml=reports/junit.xml
  continue-on-error: true

- uses: rowhitswami/flakestat@v0.2.1
  with:
    args: ingest 'reports/**/*.xml'
    comment: true          # post/update a PR comment
    annotations: true      # annotate new flakes in the diff

That records the run, writes a report to the job summary, and posts it as a pull request comment. A re-run updates the same comment rather than adding another.

The intended sequence#

- run: pytest --junitxml=reports/junit.xml
  continue-on-error: true          # record the run even when it fails

- uses: rowhitswami/flakestat@v0.2.1
  with: { args: "ingest 'reports/**/*.xml'" }

- uses: rowhitswami/flakestat@v0.2.1
  with: { args: "check --fail-on-new", comment: true }
continue-on-error matters A failed suite is exactly the run you most want in the history. Without it, the job stops before ingest and you record only the runs that passed, which is the one sample guaranteed to hide flakiness.

Inputs#

InputDefaultDescription
argsNoneArguments passed to flakestat
versionlatestRelease tag to install, e.g. v0.2.1
install-onlyfalsePut the binary on PATH without running it
summarytrueAppend the report to the job summary
commentfalsePost/update a PR comment (needs pull-requests: write)
annotationsfalseAnnotate new and regressed tests in the diff
max-rows20Cap the highlight table on large suites
working-directory.Directory to run in

Outputs: flaky-count, new-flaky-count, report-file.

Permissions#

permissions:
  contents: read
  pull-requests: write   # only if comment: true

Matrix builds#

Let each matrix job ingest its own results, then merge the NDJSON in an aggregate job. That way every observation records the platform it actually ran on, rather than the aggregator's.

- uses: rowhitswami/flakestat@v0.2.1
  with:
    args: >-
      ingest 'reports/**/*.xml'
      --dimension os=${{ matrix.os }}

- uses: actions/upload-artifact@v4
  with:
    name: observations-${{ matrix.os }}
    path: .flakestat/runs.ndjson

See recording where tests ran for why this matters, and tracking over time for where to keep the merged history.

GitLab, CircleCI and others#

Nothing is GitHub-specific. Install the binary and call it.

Provider context (run id, job id, attempt and shard) is detected automatically for GitLab CI, CircleCI, Buildkite, Jenkins and Azure Pipelines.

test:
  script:
    - pytest --junitxml=reports/junit.xml || true
    - curl -sSfL https://raw.githubusercontent.com/rowhitswami/flakestat/main/scripts/install.sh
        | sh -s -- -b /usr/local/bin
    - flakestat ingest 'reports/**/*.xml'
    - flakestat check --fail-on-new
  artifacts:
    paths: [.flakestat/runs.ndjson]
    when: always
Always record, even on failure Every example above lets the test step fail without stopping the job. Recording only successful runs is the one sample guaranteed to hide flakiness.

Sharded pipelines#

Let each shard ingest its own results and concatenate the NDJSON afterwards. Each shard then records the context it actually ran in, rather than the aggregator's.

# in each shard
flakestat ingest 'reports/**/*.xml' --dimension ci.shard="$CI_NODE_INDEX"

# in the aggregate job
cat shard-*/runs.ndjson > .flakestat/runs.ndjson
flakestat compact
flakestat report --top 20

No CI at all#

flakestat is just a binary reading local files. Point it at any directory of JUnit XML you have, from any source.

flakestat ingest 'archive/2026-*/junit.xml'
flakestat report

Command reference#

Nine commands. Run flakestat <command> -h for the full flag list of any of them.

CommandWhat it does
initDetect the project and write .flakestat.json
huntRun a test command N times and detect disagreement
ingestLoad JUnit XML from CI into the history
reportScore recorded history and print a report
explainShow why one test received its verdict
checkFail CI when flakiness gets worse, not when it exists
quarantineEmit a skip list your test runner accepts
ci-reportRender a report for job summaries and PR comments
compactDrop observations duplicating an already-recorded execution

Scoring flags#

Accepted by hunt, report, explain, check and quarantine. Defaults are calibrated against a ground-truth corpus, not chosen by intuition. See how scoring works before changing them.

FlagDefaultEffect
--threshold0.10Score at or above which a test is called flaky
--suspect-threshold0.05Score at or above which a test is called suspect
--min-runs5Scored runs required before any verdict
--same-commit-weight3How much more a same-commit disagreement counts
--branch-weight0.5Weight for disagreement off the default branch
--default-branchmainBranch whose results are trusted fully
--alpha0.3Recency decay; higher weights recent runs more

Output flags#

FlagDefaultEffect
--formattabletable, json or markdown
--allfalseInclude stable and unscored tests
--top N0Show only the worst N tests (0 = all)
--no-colorfalseDisable colour, for logs and pipes
--dir.flakestatState directory

Command-specific flags#

hunt#

FlagEffect
--runs NHow many times to run the command
--junit PATHWhere each run writes its report; supports {run}
--parallel NRun N copies concurrently. Read the warning in hunting
--historyScore all recorded history, not just this burst
--dimension k=vAttach context; repeatable

ingest#

FlagEffect
--commit SHACommit these results belong to (default: current git HEAD)
--branch NAMEBranch name (default: current git branch)
--dimension k=vAttach context; repeatable
--no-hostNever record this machine's platform, even in CI
--run-id IDIdentifier for this run (default: generated)

check#

FlagEffect
--update-baselineAccept current flakiness as the baseline
--fail-on-newExit 1 when a test is newly flaky (default true)
--fail-on-regressionExit 2 when an accepted test worsens
--regression-deltaScore increase before that counts (default 0.1)
--baseline PATHBaseline file (default <dir>/baseline.json)

Configuration#

Write the command and report path once, then run a single word.

flakestat init            # detects your project
flakestat init --project pytest   # or skip detection
flakestat init --force    # overwrite an existing config
.flakestat.json
{{
  "command": ["pytest", "--junitxml={junit}"],
  "junit": "reports/junit-{run}.xml",
  "runs": 20,
  "threshold": 0.10
}}

With that file in place, the whole invocation becomes:

flakestat hunt

Precedence#

Flags always win over the file, so a one-off flakestat hunt --runs 50 works without editing anything. Nothing in the file is required, and every key has a default.

KeyMeaning
commandYour test command as an argv array. {junit} is substituted.
junitWhere each run writes its report. {run} is substituted.
runsDefault run count for hunt
thresholdFlaky threshold
suspect_thresholdSuspect threshold
min_runsScored runs before a verdict
default_branchBranch whose results are trusted fully
Commit it The config belongs in the repo. It is how everyone on the team, and CI, runs the same thing.

What gets written where#

PathWhat it isCommit it?
.flakestat.jsonConfigurationYes
.flakestat/runs.ndjsonObservation historyDepends
.flakestat/baseline.jsonAccepted flakiness for checkYes

How scoring works#

Five steps, and a live demo you can poke at to see each of them.

Try it#

Click any run to cycle it between pass, fail and skip. The verdict below is computed with the same rules the binary uses.

Outcome history, oldest first, in one execution context on one commit.

Verdict·
Score·
Lower bound·
Pass / fail·
Comparisons·

The demo covers one execution context on one commit, which is where the interesting behaviour is. The binary additionally weights same-commit and cross-branch evidence, and decays by age, in steps 3 and 4 below.

The five steps#

  1. Group by execution context. Two outcomes are only comparable if branch, os, arch and runtime were the same. Without this, interleaving platforms makes a test that always fails on Windows and always passes elsewhere read as constant disagreement, a phantom signal that has caught this project twice.
  2. Count transitions. Every adjacent pair within a context that disagreed is a flip. Failure rate is deliberately not used, which is why an always-failing test scores zero.
  3. Weight them. A flip on the same commit counts three times a cross-commit one, because the latter may be a regression someone fixed. Disagreement off the default branch counts for half, because failures there are expected.
  4. Decay by age. Age is measured in commits, not observations, so a 200-run burst on one commit uses all of its evidence instead of only the tail.
  5. Take a lower bound. Classification uses a Wilson score lower bound with an effective sample size, so a verdict requires evidence rather than a lucky flip in a short run.

Skips are not outcomes#

pass    -> pass evidence
failure -> fail evidence
error   -> fail evidence
skip    -> neither

A skipped run is not evidence either way, so it never becomes an outcome a neighbour can disagree with, and never enters the failure rate. P S P is one comparison between two passes, not two disagreements.

Where failures concentrate#

Association analysis is separate and never touches the score. A test is flaky because its outcomes demonstrate flakiness; associations only say where that flakiness concentrates. Findings must clear an evidence floor, an effect-size floor, and a Benjamini–Hochberg correction for how many dimensions were tested. See recording where tests ran.

Going deeper#

The design notes record the reasoning in full, including the mistakes that shaped it: a recency weight that made 100 runs no better than 10, a parallel mode that manufactured the flakiness it reported, and three separate occasions where incomparable observations were compared. The validation record covers whether any of it holds up against somebody else’s bugs.

Supported test runners#

Anything that writes JUnit XML, which in practice is everything.

pytestJestVitest go testJUnit 5TestNG SurefireRSpecPHPUnit MochaPlaywrightCypress xUnitNUnitcargo test

LanguageRunnerHow to emit JUnit XML
Pythonpytestpytest --junitxml=reports/junit.xml
Pythonunittestunittest-xml-reporting
JS / TSJestjest-junit reporter
JS / TSVitest--reporter=junit
JS / TSMochamocha-junit-reporter
JS / TSPlaywright--reporter=junit
JS / TSCypresscypress-multi-reporters
Gogo testgotestsum --junitfile reports/junit.xml
JavaMaven SurefireWritten by default to target/surefire-reports/
JavaGradle / JUnit 5Written by default to build/test-results/
RubyRSpecrspec_junit_formatter
PHPPHPUnit--log-junit reports/junit.xml
.NETxUnit / NUnitdotnet test --logger junit
Rustcargo testcargo2junit or cargo-nextest
ElixirExUnitjunit_formatter

Dialect tolerance#

JUnit XML has no official schema, so every framework writes it slightly differently. The parser handles both root elements, nested suites, locale-formatted durations like 4,521, bytes that are illegal in XML 1.0, and Surefire's <flakyFailure> markers, which are a direct flakiness signal and are read as one.

Playwright emits <error> for some failures and <failure> for others; both count as failures. A <skipped> test counts as neither.

No JUnit XML?#

flakestat falls back to exit codes and reports suite-level flakiness. That is less precise, because you learn the suite is flaky rather than which test, but it works with anything that returns a status code.

flakestat hunt --runs 20 -- ./run-tests.sh

Frequently asked questions#

Short answers. Each links to the longer explanation.

Does my test data leave my machine?#

No. flakestat is a binary that reads local files and writes a local file. There is no network call, no account and no telemetry.

My test failed 100% of the time. Why is the score zero?#

Because it isn't flaky, it's broken. flakestat scores how often a test disagrees with itself, not how often it fails, so a consistently failing test scores zero and appears as consistently-failing, a different problem with a different fix. See reading a report.

How many runs do I need?#

Depends on the flake rate. ~25% is reliably caught within 20 runs; 10% usually needs 50 or more; below about 5% a local burst is the wrong instrument and CI history is right. insufficient-data means exactly that. There is not enough evidence yet.

Can I use it with a monorepo or sharded CI?#

Yes. Let each shard ingest its own results and concatenate the NDJSON. It is append-only and one observation per line specifically so that works with cat and no merge step.

Does re-running a job double-count?#

No. Each observation carries the identity of the execution it describes, so the same artifact ingested twice counts once, while a genuine retry, which really did run the tests again, counts as the second execution it is.

Will it slow down my CI?#

ingest parses XML files you already produce; it is milliseconds. hunt runs your suite N times and costs exactly that, which is why it is a local tool rather than something on every build.

What if my runner doesn't write JUnit XML?#

Almost all of them can, with one flag or one reporter package. See supported runners. Failing that, exit-code mode still gives suite-level results.

Is it free?#

Yes. MIT licensed, free at any volume, and no per-seat or per-run pricing, because it runs on your own machines, so there is nothing to meter.

How is this different from just retrying failed tests?#

Retries hide flakiness; flakestat measures it. A retried test still costs time and still fails in ways that matter. Retry to keep the pipeline moving, and measure so the list actually shrinks. Gating on regressions is how you stop it growing.

Why not just use a hosted service?#

Use one if you want dashboards, org-wide rollups and alerting. They do that well. flakestat is for the case where you want detection to be local, free and yours. See how it compares.