Home / Services / Flaky test remediation

Flaky Playwright test remediation, so a red build means something again

Every spec that fails at random gets named, ranked and given a cause. Most of them come back fixed, one commit each, and never by raising a timeout or deleting an assertion; the rest are on a list with somebody's name against them.

Short answer

We take a Playwright suite that fails at random and make its red builds worth reading. You get the intermittent specs ranked by failure frequency, a named cause for each, the fixes as commits, and a quarantine list with an owner on every line. Engineers are billed hourly, from $50 an hour, minimum one full-time engineer for one month.

Nothing gets fixed in that first conversation. It is where we find out which failures your team has already stopped believing, and whether the evidence for them still exists. You leave it knowing what we would measure first and what that measurement needs from your pipeline.

Who this is for

Leads who own a Playwright suite nobody in the room will vouch for. It runs on every pull request, it has been right about a real bug at some point, and it has been wrong often enough that nobody reads it any more.

  • A red build whose first treatment is a re-run, and a second re-run when that one is red too.
  • retries: 2 in playwright.config.ts, raised during a release week and never lowered, so a green light now certifies that the suite passed within three attempts.
  • Specs commented out or wrapped in test.skip behind a // TODO from March, with no list of them and nobody carrying them.

Teams rarely call because they cannot guess which specs are bad; they call because nobody can be spared to prove it. The reading and the fixing sit with an engineer of ours full time, and your side of it is access at the start and commits to review as they arrive.

Your suite stays in the language it is already written in. Playwright ships four official bindings — TypeScript and JavaScript, Python, Java and .NET — and all four are delivered here, while the samples further down are TypeScript because that is what this site writes in.

A suite of forty specs with two known-bad tests does not need a contract. If you can already name both of them, read why Playwright tests flake and spend an afternoon on it — the same reading is what our engineers start from, and none of it is behind a paywall.

What you get

Everything below lands in your repository and stays there after we have gone.

The flake register

A file with one row per spec that failed and then passed on a commit where nothing had changed. Rows are ordered by how often each spec did it, over a stated number of runs, so the work starts at the top of it. Every row carries a cause from four — locator strategy, a real race in the product, test data, or environment — and a disposition from three: fixed, quarantined, or referred to the product team.

The fixed suite, as reviewable commits

One commit per fix, with the trace of the failure it closes attached and the register row named in the message. A fix here is never a longer timeout, a waitForTimeout, or an assertion quietly removed. Your reviewers see the failure, the cause and the change together, which is what keeps your team able to maintain the suite once we are gone.

The quarantine list, with an owner and a way out

Every spec pulled out of the default run, the reason it went, the person who owns it and the condition that puts it back. Quarantine is a tag excluded from the run or a test.fixme, and the specs stay in the report where the count is visible. A skip with nothing written down is a test that has gone.

How it works

  1. Measure

    The suite runs as it stands, unchanged, on commits where the product code did not move, with traces captured on retry and a report kept from every shard. The register comes out of those runs. It does not come out of a meeting about which tests annoy people most, and the two lists overlap less than teams expect. From your side this needs repository access, whatever CI history is still retained, a straight answer about which environments the suite shares and who else deploys to them mid-run, and one engineer who can say why a given spec exists at all.

  2. Diagnose and fix

    An engineer takes the register from the top, opens the trace for each spec, writes the cause into the row and commits the fix on its own. Each commit goes through your normal review as it is written, so the fixes that have merged are in the repository whether or not the engagement runs to the end. Where the failure turns out to be your application being intermittently wrong, nothing in the spec gets touched. It comes back as a reproduction, with the trace, the run it failed on and the request sequence that produced it.

  3. Hand over

    The quarantine list goes across with an owner and an exit condition on every line. The configuration that produced the first measurement stays in your pipeline, so the closing count is taken the same way as the opening one, on your runners, against your commits. That is also how you check the suite again in November without asking us anything: the measurement is yours and it does not leave with the engineer.

No phase above carries a date. The measure phase is what produces one, and it produces it by counting on your runners for a week rather than by estimating from a suite it has not seen. What gets counted is in the questions at the foot of this page.

What this looks like in your repository

None of the code below came out of a client repository. It is written for this page, and it is the shape a flaky suite tends to arrive in. Each sample is followed by what it tells us when we find it in a suite, and by what the register does with it.

A sleep, and an assertion that only looks once

This spec passes most mornings:

import { test, expect } from '@playwright/test';

test('saves the profile', async ({ page }) => {
  await page.goto('https://example.com/settings/profile');
  await page.getByLabel('Display name').fill('Ada');
  await page.getByRole('button', { name: 'Save' }).click();

  await page.waitForTimeout(1000);

  const status = await page.getByRole('status').textContent();
  expect(status).toBe('Saved');
});
Every sample on this page is written against Playwright 1.62.

The version it becomes is shorter, and the sleep is deleted rather than raised:

import { test, expect } from '@playwright/test';

test('saves the profile', async ({ page }) => {
  await page.goto('https://example.com/settings/profile');
  await page.getByLabel('Display name').fill('Ada');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByRole('status')).toHaveText('Saved');
});

The one-second sleep is a guess, and the assertion under it reads the DOM once and stops looking. Playwright's documented timeout defaults say what the guess is being made against: an expect assertion retries for five seconds and a test gets thirty, while an action and a navigation have no timeout at all unless one is set. A spec built this way fails on the days CI is busy and passes on the days it is not, and the failure is in the test rather than in the runners, so the row closes inside the test directory. It is also the cheapest kind of row to close, though the ranking still decides when it gets closed: the register is ordered by how often a spec fails, so your build history sets the order the work happens in.

The configuration that decides whether there is any evidence

Before anything can be diagnosed, the failure has to leave something behind:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  retries: process.env.CI ? 2 : 0,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'https://staging.example.com',
    trace: 'on-first-retry',
  },
});

With trace set that way, a test that fails once and passes on the retry leaves a trace file behind, and npx playwright show-trace opens it. Our reading order is the same every time, and the pane names in it are the trace viewer's own. Actions first, walked to the call that failed; then the Before and After snapshots of the action above it, which is where a locator that resolved to the wrong element stops being a theory; then Network, for a request that arrived after the assertion had given up; then Console and Errors. We come out of it with a sentence naming one of the four causes, and that sentence is the content of a register row.

A suite where trace is 'off', or where CI keeps the last handful of runs, is a suite where the first days go on producing the evidence before any of it can be read. That changes the schedule and the bill, so your config is the first thing we ask to see.

The retry count, and what it does to the meaning of green

Somewhere in a suite that has stopped being believed there is a line like this one, or the --retries=3 flag doing the same job from the command line:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  retries: 3,
});

A retry does not repeat the failure. The argument here is ours, assembled out of what the runner documents itself doing. When a test fails, the runner discards that worker process and its browser and starts a new one, and the retry runs there with beforeAll executed again. A spec that only passes on the second attempt may be passing because it was handed clean state, which means the thing it depends on is state it does not own. Playwright reports those runs as flaky, its own category for a test that failed first time and passed when retried, and the dot reporter prints a ± beside each one. Three retries are three attempts after the first one, so a suite configured like that goes green while the green quietly comes to mean "passed on one of four runs". Nobody agrees to that in a meeting; it accumulates.

Playwright 1.62 added a retryStrategy option, which controls when a failed test is retried. The test configuration reference gives its default as 'immediate' and its other value as 'isolated'. That an option exists for when a retry happens is the framework saying a retry is not a neutral repetition of the test.

Quarantine, written so the count stays visible

A spec that cannot be fixed this month leaves the default run with a tag on it:

import { test, expect } from '@playwright/test';

test('exports the ledger as CSV @quarantine', async ({ page }) => {
  await page.goto('https://example.com/ledger');
  await page.getByRole('button', { name: 'Export CSV' }).click();

  await expect(page.getByText('Export ready')).toBeVisible();
});

The pull request excludes it with npx playwright test --grep-invert @quarantine, and the annotations documentation has a tag as something you filter the test report by, so the number of quarantined specs is a number your team can argue about. test.fixme does the same job where a whole file is going out at once. Both of them are mechanisms, and neither is the deliverable: the deliverable is the line in the quarantine list that says who owns this spec and what has to be true for the tag to come off again.

Where this stops

Some of what a suite reports as flaky is not a defect in the suite, and no amount of work inside the test directory closes it. Those rows are already in the register with a cause on them by the time we get here.

  • A real race in the product. When the application itself is intermittently wrong, the test is right to go red and there is nothing to repair in the spec. What you get for that row is a reproduction: the trace, the run it failed on, the sequence of requests, and the conditions under which the application misbehaves. The change is in the application, so it is made by the people who own the application. We do not sell application development.
  • CI capacity and shared environments. A suite that goes red because the runners are saturated, or because somebody deploys to shared staging mid-run, has an infrastructure problem wearing a test failure's clothes. We name it and prove it with timestamps out of the measurement runs, and we stop there. Playwright is not k6, JMeter or Gatling, and load and performance testing is not something this company sells in any framework.
  • Native mobile apps. Specs that drive an iOS or Android build through Appium sit outside the framework and outside us. Playwright drives browsers. It emulates a phone-sized browser with a phone's user agent and touch input, and it cannot open the build your users installed from a store.
  • Safari on a real iOS device. A failure that reproduces only on a physical iPhone cannot be chased here. Playwright's WebKit build is not Safari on an iPhone: the browser documentation says Playwright does not work with the branded version of Safari, because it relies on patches. A spec that goes red on a device cloud and green in the webkit project has not been explained by anything we can run.
  • Security and penetration testing. Not offered here, in any framework. Nor is Internet Explorer, which the framework does not support.

What it costs

The work here is one engineer reading traces, so the bill is engineer hours and nothing else. Engineers are billed hourly, from $50 an hour, depending on where the engineer sits. The minimum engagement is one full-time engineer for one month.

On a remediation the hours depend on the spec count, how many of those specs have ever failed non-deterministically, how many environments and pipelines the suite runs against, whether traces are already being captured on retry, and how much CI history your runners still hold. A team whose runners keep months of results has a ranked register early, because the failures have already happened and been recorded. A team whose CI keeps the last twenty builds is paying for the same suite to be watched before anything about it can be ranked.

Read first

Questions

Can you fix flaky tests without changing our application?

Most of them, yes. A locator that matches two elements, a spec that depends on data another spec created, a fixed sleep standing in for a condition: those live in the test directory and the fix ships nothing to your customers. The exception is a spec that flakes because the application is intermittently wrong, and that one comes back as a reproduction with the trace and the failing run attached rather than as a test we edited until it went green. It gets fixed by the people who own the application, because the change is in the application.

What happens to the tests you cannot fix?

They go on the quarantine list, which is a list somebody reads. Each line names the spec, why it is out of the default run, who owns it, and the condition that puts it back. The specs stay tagged in the repository and the tag shows in the test report, so the number of quarantined tests is visible to your team every week instead of disappearing into a skip nobody remembers writing.

Why not just turn up the retry count?

Because a retry does not repeat the failure. When a test fails, Playwright throws away the worker process and the browser and starts a new one, and the retry runs there with beforeAll executed again, so a spec that only passes on the second attempt may be passing because it was handed clean state. Raising the number turns the build green and changes what green certifies: not that the suite passed, but that it passed inside however many attempts the config allows. Playwright already reports those runs as flaky and the dot reporter prints a plus-minus mark beside each one, so the count is in your report whether or not anybody reads it.

How long does this take?

Ask us again after the first week and the answer is a real one. Before that, the length is set by the spec count, how many of those specs have ever failed non-deterministically, how much CI history is still retained, whether traces are captured on retry today, and how many environments the suite runs against. The measure phase puts a number on each of those, and the schedule comes out of what it finds.

Do we need the audit before you start?

No, though the case for one is stronger here than on most of what we sell: a team that cannot say how many of its tests are flaky has a good reason to find out before committing to a month. The audit is there for a team that wants to know what it has before it picks a direction. Both routes are open: the first phase of this engagement measures the suite, so a team that already knows it wants the work gets the measurement inside it, and a team that wants to look before deciding buys the audit on its own. It is optional, it is billed by the hour, and how long it takes depends on the project. The minimum of one full-time engineer for one month does not apply to it.

How much of the failing is still on record?

Two things scope this: how many specs are in the suite, and how many past runs your CI still holds. Twenty retained builds and six months of them are different engagements. If you can attach one run that went red and then green on the same commit, that is the first row of the register and we can start reading it. None of it waits on an audit. After we go, keeping the register empty is your team's or an embedded engineer's.