Schedule icon
Get icon
WorkingDirectory icon
Commands icon
Docker icon
Script icon
Set icon
Log icon
If icon
Comment icon

Tell a flaky test apart from a genuinely broken one by flip rate

Kestra reruns a suite on one commit, scores each test by outcome flip rate across a KV history, then proposes which flaky tests to quarantine.

Categories
CoreInfrastructure

A red test suite tells you one bit. It cannot tell you whether a test is broken or merely unreliable. Those two need opposite responses. A broken test is a bug to fix. A flaky test is noise to quarantine. Treat the first as noise and a real defect ships. Treat the second as a bug and an engineer loses a day to a race nobody introduced.

This flow separates them with evidence. It runs your suite several times on one unchanged commit, parses every JUnit XML report, then records each test's outcome as one bit. The bits accumulate in a Kestra KV entry across executions. Each test is then scored on flip rate, the share of adjacent observations where the outcome changed:

  • FLAKE: the outcome changes while the code does not. Nondeterministic.
  • BROKEN: every observation failed. A real failure, so it is never proposed for quarantine.
  • REGRESSED: it passed, then failed, then kept failing. One flip at a known point, not noise.
  • STABLE: every observation passed.
  • NEW: fewer than two observations, so nothing can be said yet.

The demo suite makes the contrast visible in one run. test_flaky_clock and test_genuinely_broken both fail, both read red in CI. Only the first one flips.

This blueprint was created by zkasuran.

Why a window size sits next to every verdict

A short window under-reports flakes. It never over-reports them. A test that flips once every twenty runs will pass six reruns and be scored STABLE. That is a true statement about six observations rather than about the test. So every row prints the number of observations behind it. Anything under confidence_window is marked low confidence. The nightly trigger exists to widen those windows. The demo suite ships test_flaky_rare, which fails on every seventh run and is therefore scored STABLE at the default rerun_count of 6. Raise it to 14 and the same test turns up as a flake. That is the method's limit, in the product, on purpose.

How it works

  1. load_history (io.kestra.plugin.core.kv.Get) reads the outcome history. Missing on the first execution, which is handled.
  2. detect (io.kestra.plugin.core.flow.WorkingDirectory) gives the next two tasks one shared directory, so JUnit reports pass between them as files.
  3. rerun_suite (io.kestra.plugin.scripts.shell.Commands) runs setup_commands once, then runs test_command rerun_count times in the same container, exporting RUN_INDEX and JUNIT_REPORT per rerun. A failing rerun is expected, so a non-zero exit is logged rather than fatal. Zero reports written is fatal.
  4. score_flips (io.kestra.plugin.scripts.python.Script) parses every report, appends one bit per test, caps each history at history_window, forgets tests not seen for a whole window, then scores and ranks.
  5. save_history (io.kestra.plugin.core.kv.Set) persists the widened history with no ttl.
  6. report logs the full table. triage (io.kestra.plugin.core.flow.If) proposes a quarantine only when a flake was found. publish_tracker keeps one GitHub comment updated in place through updateTag.

Reading any JUnit dialect

  • root.iter('testsuite') matches the element itself, so the pytest <testsuites> wrapper and the bare <testsuite> root of surefire, jest-junit and Ant all parse with one path.
  • A pass is the absence of a failure, error or skipped child. pytest omits the type attribute on <failure> even though the schema marks it required. It files an exception thrown in a test body under <failure> while surefire files it under <error>. Reading either attribute or tag choice would misread one of the two.
  • A skipped row is dropped rather than recorded, so a conditionally skipped test does not read as a flip. Its window stays narrower than its neighbours, which the table shows.

Prerequisites

  • Docker available to the Kestra worker, since the suite runs in test_image.
  • A test command that writes JUnit XML. pytest --junit-xml, jest --reporters=jest-junit, Maven surefire, Gradle, Go gotestsum --junitfile all do.
  • GITHUB_TOKEN secret with issues: write, only when file_github_issue is true.

Quick start

  1. Run it with the defaults. The bundled demo suite runs 6 times and the log separates a flake from a real bug.
  2. Run it again. Every window widens from 6 observations to 12 because the history accumulated.
  3. Run it with rerun_count set to 14. test_flaky_rare stops reading STABLE.
  4. Point it at your own suite by setting setup_commands to clone your repo at a fixed commit and test_command to your runner. Leave the commit fixed, because the whole method rests on the code not changing between reruns.

Expected outputs

  • A verdict table in the logs: history string, window size, flip count, flip rate, verdict per test.
  • outputs.score_flips.vars.flake_count, broken_count, regressed_count, stable_count, new_count.
  • outputs.score_flips.vars.deselect: a ready pytest -k expression that skips the proposed flakes.
  • Every JUnit report from the execution, published as output files.
  • A KV entry under history_key holding the widened history.

On the demo suite, first execution: test_flaky_clock scores 101010, 5 flips, flip rate 1.000, FLAKE. test_genuinely_broken and test_broken_fixture score 000000, 0 flips, BROKEN. test_conditionally_skipped carries a window of 4 rather than 6, because two skips were dropped.

How to extend

  • Hold one history key per branch so a long-lived release branch keeps its own record.
  • Fail the execution when flake_count passes a budget, which turns flakiness into something a team has to pay down.
  • Open a GitHub issue per new flake with io.kestra.plugin.github.issues.Create, keeping this comment as the rollup.
  • Read reports from a CI provider instead of rerunning the suite. Downloading a GitHub Actions artifact needs a token even on a public repository, so that path cannot run without a secret.
  • Weight recent observations higher, so a flake that was fixed last week ages out faster than the window does.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.