Handling flaky tests in a merge queue Jump to heading
On a pull request, a flaky test costs its author one re-run. In a merge queue it costs a whole batch: the candidate tree goes red, an innocent entry is ejected, the batch is rebuilt, and everyone behind it waits. The same flake rate that was an annoyance becomes a throughput ceiling, and the usual reaction β retry everything automatically β quietly converts the gate into decoration. This recipe keeps the gate honest, building on merge queues and required checks.
When to use this approach Jump to heading
- Entries are being ejected for failures that pass on the next run.
- Your queue throughput is lower than your pipeline duration suggests it should be.
- You already know which tests are unreliable but have nowhere to put them.
- Someone has proposed βjust retry the whole job three timesβ and you would like a better answer.
- If your suite is genuinely deterministic, skip this and spend the effort on batching pull requests to cut CI cost.
Step 1 β Measure the flake rate before changing anything Jump to heading
A flake rate is the probability that a run fails for reasons unrelated to the change. You can estimate it cheaply by running the suite repeatedly against a commit that is known good β no code changes, so every failure is noise.
# Re-run the suite ten times against an unchanged tree
for i in $(seq 1 10); do
npm test >/dev/null 2>&1 && echo "run $i pass" || echo "run $i FAIL"
done # The same measurement in CI, where the environment differs from a laptop
gh workflow run ci.yml --ref main
gh run list --workflow ci.yml --branch main --limit 20 \
--json conclusion --jq 'group_by(.conclusion)[] | {result: .[0].conclusion, count: length}' What this gives you: a number to reason with. A 2% per-run flake rate sounds tolerable until you notice a batch of five inherits roughly a 10% chance of going red for nothing.
Step 2 β Quarantine rather than delete Jump to heading
A test that fails at random is still testing something. Move it out of the blocking path into a quarantined suite that runs on the same commit, reports separately, and is visible enough that the quarantine list does not become a graveyard.
# Tag the offender so the runner can exclude it from the gate
# (Jest: a custom test path pattern; pytest: a marker; go: a build tag)
npx jest --testPathIgnorePatterns 'quarantine/' --ci
npx jest --testPathPattern 'quarantine/' --ci || true # reported, not enforced # .github/workflows/ci.yml β the quarantined run informs without gating.
quarantine:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx jest --testPathPattern 'quarantine/' --ci # Verification: the gate no longer includes quarantined tests
npx jest --testPathIgnorePatterns 'quarantine/' --listTests | wc -l SAFETY WARNING β quarantine is a loan, not a write-off. A quarantined test covers behaviour that is now unguarded, so every entry needs an owner and a date. A quarantine list with no expiry is indistinguishable from deleting the tests, except that it still costs machine time.
Step 3 β Retry at the test level, never the job level Jump to heading
Retrying a whole job hides which test was unreliable and re-runs everything to mask one failure. Retrying a single test preserves the signal: the run is green, and the retry is recorded so the flake rate stays measurable.
# Most runners support a bounded per-test retry
npx jest --retryTimes 2 --ci # Jest
pytest --reruns 2 --reruns-delay 1 # pytest-rerunfailures
go test -count=1 ./... # Go: no retry β fix or skip explicitly Cap retries at two. Three or more means the test is not flaky, it is broken, and the number is being chosen to make it pass.
Step 4 β Make the queue eject the right entry Jump to heading
When a batch is red, the queue must attribute the failure. Most implementations bisect by rebuilding smaller batches; you can make that far cheaper by ensuring test output names the failing test in a machine-readable form.
- run: npx jest --ci --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_FILE: reports/junit.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: junit, path: reports/junit.xml } # Verification: the failing test name is extractable without reading logs
xmllint --xpath '//testcase[failure]/@name' reports/junit.xml 2>/dev/null What changed: an ejection now comes with the name of the test that caused it, which is the difference between an author fixing something and an author pressing re-enqueue.
Step 5 β Report the flake rate where the team sees it Jump to heading
Flakiness is a budget. Publish it next to the pipeline duration, because the two together decide the maximum sensible batch size and, eventually, whether the queue is worth running at all.
# Weekly: how many merge-group runs failed and were re-run green unchanged
gh run list --event merge_group --limit 200 --json conclusion,attempt \
--jq '{runs: length,
failed: ([.[] | select(.conclusion=="failure")] | length),
retried: ([.[] | select(.attempt > 1)] | length)}' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not retry the job automatically and move on? Jump to heading
Because the gate then reports βeventually greenβ, which is true of a broken build as well as a flaky one. Every genuine regression that fails intermittently β a race condition, a timezone bug, a resource leak under load β is exactly the class of defect that a blanket retry converts into a silent merge.
How long should a test stay in quarantine? Jump to heading
Two weeks is a workable default: long enough to schedule the fix, short enough that the list cannot grow unnoticed. When the date passes, the choice is deliberate β fix it, or delete it and accept that the behaviour is untested. Letting it sit is the option that quietly loses coverage.
Does a merge queue make flakiness worse? Jump to heading
It makes the cost visible rather than larger. The same tests failed at the same rate before; the failures were absorbed by individual authors instead of by the shared queue. That visibility is useful, because it converts a chronic annoyance into a number someone can be asked to reduce.
Related Jump to heading
- Merge Queues & Required Checks β the parent topic and its throughput arithmetic.
- Batching Pull Requests to Cut CI Cost β why the flake rate caps the batch size.
- Running a Fast Test Subset in a Pre-Push Hook β catching deterministic failures before they reach the queue.