Test Retry Strategies Without Masking Flakiness
AI generated
PASS
expect()
Testing · Flaky Tests · Cypress · Playwright
Test Retry Strategies Without Masking Flakiness
Retry as a signal, not a hiding spot

Letting tests rerun until they pass quietly pushes real bugs and race conditions out of sight and slowly erodes trust in the test suite itself. This article shows how to configure Cypress and Playwright retries correctly and how teams can turn repeated test runs into an early warning system instead of a convenient free pass.

13 min. read Cypress · Playwright · CI/CD Flaky Tests · Quality Engineering

1. What test flakiness is and why blind retry-until-pass is dangerous

A flaky test is a test that passes sometimes and fails sometimes with no change to the code or the application's behavior. Causes range from genuine race conditions in the application, through network latency and animations, to test cases that interfere with each other via shared state. In larger E2E suites with hundreds of tests, some baseline level of flakiness is nearly unavoidable, but what actually matters is how a team responds to it.

The most obvious, and also the most dangerous, reaction is to raise the retry count globally, for example setting retries: 3 for the entire suite, without anyone checking which tests are actually affected. This automatically turns every intermittent failure into a permanently green status, without the underlying cause ever being investigated. This blind retry-until-pass approach is exactly the core of the problem: it doesn't eliminate flakiness, it just makes it invisible to everyone involved.

2. How retry-until-pass masks real bugs and erodes trust in the suite

A retry mechanism has no way to distinguish between a test that fails because of a brief network hiccup and one that is exposing a genuine race condition in the checkout code. Both get rerun using the exact same pattern until a green run eventually happens. For the application itself, this means a bug that occurs under load or under specific timing conditions stays in the code but is no longer reported by the test suite, simply papered over by repetition.

Over time, this undermines the credibility of the entire suite. Developers quickly learn that a single red run means nothing because it will turn green on the next attempt anyway, and start reflexively rerunning failed pipelines instead of investigating them. This behavior, often compared to the broken windows effect in quality engineering, tends to spread: once a team accepts that red doesn't necessarily mean a failure, the whole suite loses its function as a reliable signal.

3. Retry as a signal and triage tool, not a permanent fix

The decisive shift in perspective: retry is not a fix, it's a diagnostic tool. A test that only passes on the second or third attempt is providing valuable information, namely that something at that point is not deterministic, whether in the application or in the test infrastructure itself. That information must not be silently discarded just because the run ended up green. Instead, every retried-but-passed test should be automatically logged and flagged for later investigation.

In practice, this means retry results are captured separately, for example through a custom reporter or a test fixture that records the number of attempts per test. That data feeds into a dashboard or an automatically generated ticket list showing the responsible team which tests repeatedly only barely passed. Retry buys time for the pipeline, but it never buys relief from the actual investigation.


// Custom fixture: log when a test only passed after a retry
import { test as base, expect } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use, testInfo) => {
    await use(page);

    // testInfo.retry is 0 on the first attempt, 1+ on retries
    if (testInfo.retry > 0 && testInfo.status === 'passed') {
      console.warn(
        `[FLAKY-SIGNAL] "${testInfo.title}" passed only after ` +
        `${testInfo.retry} retry(s). Flagging for investigation.`
      );

      // Persist to a triage attachment instead of silently accepting the pass
      await testInfo.attach('flaky-signal', {
        body: JSON.stringify({
          test: testInfo.title,
          file: testInfo.file,
          retries: testInfo.retry,
          project: testInfo.project.name,
        }),
        contentType: 'application/json',
      });
    }
  },
});

export { expect };

4. Retry configuration in Cypress: runMode vs. openMode

Cypress explicitly distinguishes between runMode, the headless CI run, and openMode, the interactive mode used during development, when it comes to retry configuration. This split is not an implementation detail, it's a deliberate design principle: in CI, one or two retries are often a reasonable compromise to absorb known infrastructure latency without turning the pipeline red over every small network stutter. In interactive mode, on the other hand, a developer should see every failure immediately, which is why openMode should almost always be set to 0.

It's important not to treat the retry configuration as a black box. The after:spec event lets you read out, for every test, how many attempts were actually needed before it passed. Logging that information directly during the test run, instead of letting it disappear into the Cypress Dashboard, is the simplest entry point into systematic flaky test tracking without any extra tooling.


const { defineConfig } = require('cypress');

module.exports = defineConfig({
  // Different retry strategy for CI (runMode) vs local debugging (openMode)
  retries: {
    // CI runs headless: absorb known environmental noise automatically
    runMode: 2,
    // Interactive mode: never hide a failure while a developer is watching
    openMode: 0,
  },

  e2e: {
    setupNodeEvents(on, config) {
      // Track retry attempts explicitly instead of letting them pass silently
      on('after:spec', (spec, results) => {
        const flaky = results.tests?.filter(
          (t) => t.attempts.length > 1 && t.state === 'passed'
        );

        if (flaky && flaky.length > 0) {
          flaky.forEach((t) => {
            console.warn(
              `[FLAKY-SIGNAL] ${spec.relative} > "${t.title.join(' > ')}" ` +
              `needed ${t.attempts.length} attempts to pass.`
            );
          });
        }
      });
    },
  },
});

5. Retry configuration in Playwright: global retries and test.describe.configure

Playwright handles retries globally through the retries option in the configuration file, typically enabled only for CI and left at 0 locally, so developers see failures immediately instead of having them retried away. On top of that, test.describe.configure() allows targeted exceptions for individual test blocks, for example a known-unstable checkout flow involving a third-party payment iframe, without raising the retry count for the entire suite.

The real value comes from a custom reporter that checks, on every onTestEnd call, whether result.retry is greater than 0 while the status is still passed. That exact combination marks a test as retried-but-passed and therefore a candidate for the triage list. Without such a reporter, the information about how many retries were needed vanishes irretrievably into Playwright's internal report the moment the pipeline as a whole turns green.


// playwright.config.js
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Global retry budget, only meaningful in CI
  retries: process.env.CI ? 2 : 0,
  reporter: [
    ['list'],
    ['./reporters/flaky-reporter.js'],
  ],

  projects: [
    {
      name: 'checkout-flow',
      // Known-unstable third-party payment iframe: slightly higher budget
      retries: process.env.CI ? 3 : 0,
    },
  ],
});

// reporters/flaky-reporter.js: flag tests that needed a retry to pass
export default class FlakyReporter {
  onTestEnd(test, result) {
    if (result.retry > 0 && result.status === 'passed') {
      console.warn(
        `[FLAKY-SIGNAL] "${test.title}" passed on retry ${result.retry}. ` +
        'Opening a triage ticket instead of treating this as fixed.'
      );
    }
  }
}

6. Distinguishing environmental flakiness from genuine application bugs

Not every flaky test points to a bug in the application. Environmental flakiness typically comes from network latency to external services, overloaded CI runners, insufficient explicit waits instead of robust selectors, or test-order dependencies caused by shared database state between tests. This class of failure can usually be brought under control with more robust selectors, isolated test data per run, and retry mechanisms with a clearly bounded budget.

Genuine application bugs, on the other hand, often show up as race conditions in the production code itself, for example when a cart update and a price calculation aren't properly synchronized. The most reliable way to tell the two apart: run the affected test in isolation, repeatedly, locally, while reviewing network logs, screenshots, and video, and check whether the failure correlates with a specific deployment. If the failure remains reproducible in isolation under stable conditions, it's almost always a genuine bug rather than an infrastructure hiccup.

7. Quarantine and known-flaky-test workflows

A pragmatic middle ground between fixing something immediately and ignoring it forever is quarantine: a test identified as flaky gets tagged, for example with @flaky, and removed from the blocking main pipeline run, while still continuing to run in a separate, non-blocking suite. This keeps the main pipeline meaningful while the test isn't forgotten, and it keeps producing data for diagnosis.

To stop quarantine from turning into a permanent dumping ground, the CI pipeline needs an automated gate that hard-stops the build once the flake rate crosses a defined threshold or once a certain number of quarantined tests is exceeded. Without that guardrail, quarantined tests pile up indefinitely and the original signal gets lost anyway, just one layer further down.


#!/usr/bin/env bash
# ci/check-flake-rate.sh - fail the build if flake rate exceeds threshold
set -euo pipefail

THRESHOLD=5.0
REPORT_FILE="reports/flaky-tests.json"

if [[ ! -f "$REPORT_FILE" ]]; then
  echo "No flaky-test report found, skipping flake-rate gate."
  exit 0
fi

FLAKE_RATE=$(jq '.flakeRatePercent' "$REPORT_FILE")

echo "Current flake rate: ${FLAKE_RATE}% (threshold: ${THRESHOLD}%)"

if (( $(echo "$FLAKE_RATE > $THRESHOLD" | bc -l) )); then
  echo "FAIL: flake rate ${FLAKE_RATE}% exceeds threshold of ${THRESHOLD}%."
  echo "Quarantine or fix the flagged tests before merging."
  exit 1
fi

echo "PASS: flake rate within acceptable range."

8. Metrics for flaky test management: retry rate and flake rate trend

Without measurement, flaky test management stays a matter of gut feeling. The most important metric at the test level is the retry rate per test: the share of runs in which a given test needed at least one retry to pass. A test with a retry rate of 40% is a far more urgent candidate for investigation than one that fails once a month because of a brief infrastructure blip.

At the suite level, it's worth tracking the flake rate as a trend over time rather than as a single point-in-time value. A gradual increase over several weeks often reveals creeping problems, such as growing test data volume or new shared fixtures, long before the pipeline as a whole becomes noticeably unstable. Both metrics can be extracted from Cypress and Playwright reporter data and consolidated into a simple JSON report that in turn feeds dashboards and automatic quarantine decisions.


{
  "generatedAt": "2026-07-10T06:15:00Z",
  "totalTests": 842,
  "flakeRatePercent": 3.1,
  "flaggedTests": [
    {
      "title": "checkout > applies discount code before payment",
      "file": "e2e/checkout.spec.js",
      "retryRate": 0.42,
      "lastPassedOnRetry": 1,
      "owner": "team-checkout",
      "status": "quarantined",
      "slaDueDate": "2026-07-24"
    },
    {
      "title": "search > shows autocomplete suggestions",
      "file": "e2e/search.spec.js",
      "retryRate": 0.18,
      "lastPassedOnRetry": 1,
      "owner": "team-search",
      "status": "investigating",
      "slaDueDate": "2026-07-19"
    }
  ]
}

9. Organizational process: owner assignment and SLA for flaky tests compared

Technical tooling alone doesn't solve the flakiness problem if nobody is actually responsible for the reported tests. A working process automatically assigns an owner to every test flagged as flaky, usually the team that owns the affected code or feature area, and sets a clear deadline, for example two weeks, to either fix the test or deliberately delete it. Without that deadline, quarantined tests pile up indefinitely because they no longer formally disrupt the pipeline.

The table below compares typical retry approaches with the recommended alternatives and shows exactly what matters at each step.

Approach Problem Recommended pattern Benefit
Global retries without analysis Permanently masks real bugs Retry as a signal only, with logging Visible flakiness instead of silent failures
Retries active in interactive mode too Developer misses failures while debugging Set openMode retries to 0 Immediate feedback during development
No tracking of retried tests Trend stays invisible, flakiness grows Custom reporter + dashboard Early warning before quality decays
Flaky test stays in the main run Repeatedly blocks the pipeline, team ignores red Quarantine tag with CI gate Pipeline stays meaningful
No owner for the flaky test Nobody feels responsible, ticket goes stale Automatic assignment + SLA deadline Clear ownership, faster fixes

Ultimately, this process pays off twice over: the pipeline stays credible as a quality signal, and the number of real, undetected bugs drops because flakiness gets consistently investigated instead of repeatedly retried away.

Mironsoft

E2E testing, test automation, and CI/CD quality engineering for Magento stores

Ready for stable E2E tests without the flakiness trap?

We analyze your Cypress and Playwright suites, identify the real root causes of flakiness, and set up retry strategies, quarantine workflows, and metrics so your pipeline delivers a reliable signal again instead of masking bugs.

Flaky test audit

Analyze retry rates, separate real bugs from infrastructure noise

Cypress & Playwright setup

Production-ready retry configuration, custom reporters, and CI gates

Process & monitoring

Quarantine workflows, dashboards, and owner SLAs for lasting test quality

10. Summary

Blind retry-until-pass doesn't solve a flakiness problem, it just moves it out of sight: real bugs stay in the code while the test suite loses its credibility and teams start reflexively rerunning red pipelines instead of investigating them. The sustainable approach treats retry as a diagnostic tool rather than a fix, explicitly logs every retried-but-passed test, and turns that into an assignment for investigation rather than a silent success.

Both Cypress, with its split between runMode and openMode, and Playwright, with global retries and targeted test.describe.configure overrides, provide the technical foundation for this. What actually matters, though, is the process wrapped around it: metrics like retry rate and flake rate trend, a quarantine workflow with a hard CI gate, and clear owner assignments with deadlines are what keep flakiness from turning into permanently ignored background noise.

Test Retry Strategies - The Essentials at a Glance

Retry as a signal, not a fix

Every retried-but-passed test gets logged and flagged instead of silently counting as a success.

Cypress: runMode vs. openMode

CI can absorb limited retries; in interactive mode openMode should be set to 0.

Playwright: retries + custom reporter

Global retries only for CI, targeted test.describe.configure overrides, reporter flags retried-but-passed.

Process: metrics, quarantine, ownership

Track retry rate and flake rate trend, quarantine with a CI gate, owner assignment with a deadline.

11. FAQ: Test Retry Strategies

1What is a flaky test and what causes flakiness?
A flaky test passes sometimes and fails sometimes with no code change. Causes: race conditions in the application, network latency, animations, or test-order dependencies from shared state.
2Why is global retry-until-pass dangerous?
It does not distinguish real bugs from infrastructure noise. Every intermittent failure automatically turns permanently green without the cause ever being investigated.
3What does using retry as a triage tool mean?
A test that only passes after a retry gets logged and flagged for investigation instead of silently counting as success. Retry buys time but never replaces root-cause analysis.
4How do I configure retries in Cypress correctly?
Via retries with separate values for runMode (CI, usually 1-2) and openMode (interactive, usually 0). after:spec logs the attempts needed per test.
5How do I configure retries in Playwright correctly?
Via the global retries option, usually only for CI, plus test.describe.configure for targeted exceptions. A custom reporter flags retried-but-passed tests on onTestEnd.
6How do I tell environmental flakiness apart from real bugs?
Run the test isolated and repeatedly locally, review network logs/screenshots/video. Reproducible under stable conditions almost always points to a genuine bug.
7What is a quarantine workflow for flaky tests?
Tests tagged @flaky keep running in a non-blocking suite. A CI gate stops the build once the flake rate or quarantine count exceeds a threshold.
8Which metrics should I track for flaky tests?
Retry rate per test and the suite's flake rate as a trend over time. Both can be extracted from Cypress and Playwright reporter data.
9Who is responsible once a test is flagged as flaky?
An automatically assigned owner, usually the responsible team, with a clear deadline, e.g. two weeks, to fix or deliberately delete the test.
10Should I just delete flaky tests?
Only if they no longer cover a relevant path or are redundant. Tests covering critical flows should be fixed rather than deleted.