Run new and changed tests many times before they can block merges
When a pull request adds or edits tests, run just those tests 20 to 50 times with retries off, report the result as its own check, and fix any failure before the tests join the suite everyone waits on.
Saves time and minuteslow risk
Signals in your baseline
- Most tests that your history or quarantine list marks as flaky were added or edited in the last few weeks.
- Pull requests add test files that run once before merge and go straight into a required job.
- New end-to-end or integration tests start failing on other people's pull requests soon after they land.
Why it works
A flaky test is cheapest to fix before it merges, while its author still has the context and before it has blocked anyone else. One green run in review proves little, because a test that fails one run in 50 will usually pass it. Running only the tests a pull request adds or edits, many times in a row, costs minutes rather than a full suite, and it catches the timing, ordering and shared-state bugs that new tests often carry.
Teams that do this put the check where the author will see it. Mozilla runs each new or modified test 10 times, then 5 more times in a new browser instance each time, then repeats the sequence in chaos mode, within about an hour per task. Spotify's Flakybot lets an engineer run a pull request's tests and get a flakiness report before merging. Shopify's mobile team admits a new end-to-end test to blocking CI only after a dedicated pipeline has run it multiple times under a failure threshold. Uber ran a repaired integration test 50 times with Bazel's --runs_per_test before merging the fix. Repetition finds the frequent flakes. Rare ones still slip through, so keep narrow retries and quarantine as the backstop.
Apply it
Add a workflow that lists the test files a pull request adds or edits and runs them repeatedly with retries off:
name: Changed test stability
on:
pull_request:
permissions:
contents: read
jobs:
repeat-changed-tests:
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
- name: List added or edited test files
id: changed
run: |
files=$(git diff --name-only --diff-filter=AM HEAD^1 HEAD -- tests/ \
| grep -E '(^|/)test_[^/]*\.py$' | head -n 20 | tr '\n' ' ')
echo "files=$files" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@v7
if: steps.changed.outputs.files != ''
with:
python-version: "3.13"
- name: Install dependencies
if: steps.changed.outputs.files != ''
run: pip install -r requirements-dev.txt
- name: Run each changed test 30 times, retries off
if: steps.changed.outputs.files != ''
env:
FILES: ${{ steps.changed.outputs.files }}
run: pytest -p no:rerunfailures --count=30 -x $FILES
- In a
pull_requestrun,HEADis GitHub's merge commit andHEAD^1is the base branch, sofetch-depth: 2is enough for the diff.--diff-filter=AMkeeps added and modified files and skips deleted ones. head -n 20caps the work when a pull request rewrites many tests at once.--count=30comes frompytest-repeat, which belongs inrequirements-dev.txt.-xstops at the first failure, and-p no:rerunfailuresturns off retries that would hide the flake you are looking for.- File names come from the pull request, so they reach the shell through
env, not through${{ }}inside the script. - The step-level
ifconditions keep the job green when no tests changed.
Go
Run every test in the packages whose _test.go files changed, in random order and with the race detector:
pkgs=$(git diff --name-only --diff-filter=AM HEAD^1 HEAD -- '*_test.go' | xargs -r -n1 dirname | sort -u | sed 's|^|./|')
if [ -n "$pkgs" ]; then go test -count=20 -shuffle=on -race $pkgs; fi
Playwright
With the same fetch-depth: 2 checkout, npx playwright test --only-changed=HEAD^1 --repeat-each=20 --retries=0 runs the test files changed against the base branch 20 times each. --retries=0 overrides the retries in your config.
Start the job as an optional check. After two weeks without false alarms, add it to the required checks, so the pull request that adds a flaky test is the one it blocks.
Keep it safe
- Required checks. Once required, the job must report on every pull request. Keep the step-level
ifconditions rather than a trigger-levelpathsfilter, which would leave the check pending forever on pull requests without test changes. Renaming the job later breaks branch protection. - Security. Use
pull_request, neverpull_request_target: this job runs the pull request's own code, so it gets a read-only token and no secrets. Never interpolate file names intorunwith${{ }}, where a crafted name could inject shell commands. - Cost. Repeats multiply runtime. Keep the file cap and
timeout-minutes. Tests that call real services 30 times can hit rate limits; fake those services first (fake slow services). - Blind spots. Repeating a test in one process can miss failures that depend on other test files or on order. Shuffle where the runner supports it, and see isolate test state.
pytest-repeatrunsunittest.TestCaseclasses only once, with a warning. - Coverage. This job adds a check. The normal test job still runs every test.
Verify
- Open a throwaway pull request that adds a test failing about one run in five, such as
assert random.random() > 0.2. The new check should fail. Close the pull request. - Over the next month, look at where new quarantine entries come from. Fewer of them should be tests added in the last few weeks.
- In the baseline, the new job should only take minutes, and only on pull requests that touch tests.
- The main test job should run the same number of tests as before, and every required check should report under its old name.
Sources
Mozilla
Firefox source docs: run each new or modified test repeatedly, in fresh browser instances and then in chaos modeSpotify
Spotify Engineering: Flakybot runs a pull request's tests and reports whether they are flaky before mergeShopify
Shopify Engineering: admit a new mobile end-to-end test to blocking CI only after a dedicated pipeline runs it repeatedly under a failure thresholdUber
submitqueue: run a repaired integration test 50 times with --runs_per_test before merging the fix
BerryBox