On this page
End-to-end test suites tend to follow the same arc. They start small and useful, grow to a few hundred tests, and then one day the team notices that nobody believes a red build anymore. Someone clicks "re-run failed jobs" out of habit, a real regression slips through with the flaky noise, and the suite becomes something to route around rather than rely on.
Playwright gives you better tools than most frameworks to avoid that outcome: auto-waiting locators, web-first assertions, isolated browser contexts, built-in tracing and first-class sharding. But tools do not make a strategy. This article describes the approach we use on client projects to keep Playwright suites fast and trusted: what to test end to end and what not to, how we structure fixtures and authentication, when to mock the network, how we run the suite in CI and how we manage flakiness with an explicit budget.
Decide what belongs in end-to-end tests
The fastest way to a flaky suite is to test everything through the browser. End-to-end tests are the slowest and most environment-dependent tests you have, so reserve them for what only they can prove.
| Layer | What it covers | Typical count | Runs |
|---|---|---|---|
| Unit | Pure logic, formatting, validation, reducers | Hundreds to thousands | Every save, every push |
| Component / integration | A component with its hooks, an API route with its database | Hundreds | Every push |
| Playwright with mocked backend | UI flows, error states, edge cases | Dozens to low hundreds | Every pull request |
| Playwright full stack | Critical journeys against a real deployment | 10–40 | Every pull request and after deploy |
Our rule of thumb: a full-stack Playwright test should correspond to a journey that would make someone phone you if it broke. Sign-up, log-in, checkout, the core create-edit-share flow, password reset. Everything else, such as validation messages for every field, sorting options and empty states, is cheaper and more reliable one level down.
Write tests that survive UI changes
Most flakiness we investigate traces back to selectors or timing. Playwright solves much of the timing problem if you let it.
- Use role-based locators.
getByRole,getByLabelandgetByTextmatch what users see and survive markup refactors. Adddata-testidonly where there is no accessible name. - Use web-first assertions.
await expect(locator).toBeVisible()retries until a timeout.expect(await locator.isVisible()).toBe(true)checks once and fails randomly. - Never use fixed sleeps.
page.waitForTimeout(2000)is either too long (slow suite) or too short (flaky suite). Wait for a condition instead. - Keep tests independent. Each test creates what it needs. Tests that depend on the order of other tests fail unpredictably under parallelism.
import { test, expect } from "@playwright/test";
test("user can rename a project", async ({ page }) => {
await page.goto("/projects");
await page.getByRole("link", { name: "Quarterly report" }).click();
await page.getByRole("button", { name: "Rename" }).click();
await page.getByLabel("Project name").fill("Q3 report");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("heading", { name: "Q3 report" })).toBeVisible();
});Fixtures for setup, not beforeEach sprawl
As suites grow, beforeEach hooks accumulate setup that half the tests do not need. Playwright's fixtures are a better model: each test declares what it needs, and fixtures handle creation and teardown.
We use fixtures to create test data through the API rather than the UI. Creating a project via a POST request takes milliseconds; clicking through a form takes seconds and adds another place to fail.
// tests/fixtures.ts
import { test as base, expect } from "@playwright/test";
type Project = { id: string; name: string };
export const test = base.extend<{ project: Project }>({
project: async ({ request }, use) => {
const res = await request.post("/api/projects", {
data: { name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` },
});
expect(res.ok()).toBeTruthy();
const project: Project = await res.json();
await use(project);
await request.delete(`/api/projects/${project.id}`);
},
});
export { expect };Unique names matter. When tests run in parallel against a shared environment, two tests creating "Test project" will eventually collide.
Log in once, reuse the session
Logging in through the UI in every test is slow and a common source of flakes, especially with rate limits or third-party identity providers. Playwright's recommended pattern is a setup project that logs in once and saves the browser storage state, which other projects then reuse.
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.E2E_USER_EMAIL!);
await page.getByLabel("Password").fill(process.env.E2E_USER_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("navigation")).toContainText("Projects");
await page.context().storageState({ path: authFile });
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "blob" : "html",
use: {
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
],
});Add playwright/.auth to .gitignore. For tests that modify account-level state, such as changing a password or deleting the account, use a dedicated user per test or per worker so they do not interfere with everyone else's session.
Mock the network deliberately
Network mocking is the most useful and the most abused Playwright feature. Used well, it lets you test error states, slow responses and edge cases that are hard to reproduce against a real backend. Used everywhere, it produces a green suite that says nothing about whether the frontend and backend still agree.
We split tests into two groups by folder and project: full-stack tests that hit a real API, and UI tests where the backend is mocked. Mocked tests cover the states that are painful to set up for real.
test("shows a retry banner when the projects API fails", async ({ page }) => {
await page.route("**/api/projects", (route) =>
route.fulfill({ status: 503, json: { error: "unavailable" } })
);
await page.goto("/projects");
await expect(page.getByRole("alert")).toContainText("Couldn't load projects");
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});Third-party services (payment providers, maps, analytics, chat widgets) are the other place we mock by default. Their sandboxes are slow and occasionally down, and their failures are not your regressions. We keep one or two full-stack tests against the real sandbox for the payment path, run them on a schedule, and mock it elsewhere.
To stop mocks drifting from reality, we generate mock payloads from the same TypeScript types or OpenAPI schema the backend uses. When the contract changes, the mocks fail to compile.
Run it fast in CI
A suite that takes 40 minutes gets skipped. Playwright parallelises across workers within a machine and shards across machines.
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
retention-days: 7A follow-up job downloads the blob reports and runs npx playwright merge-reports --reporter html ./all-blob-reports to produce one HTML report with traces for every failure. With trace: "on-first-retry", any test that needed a retry has a trace attached: DOM snapshots, network log and console output for each step. That trace is usually enough to diagnose a flake without reproducing it locally.
Other settings that pay off: install only the browsers you test, cache node_modules, and on pull requests run Chromium only, with Firefox and WebKit nightly unless your users are heavily on Safari.
Manage flakiness with a budget
Retries hide flakiness; they do not fix it. We allow two retries in CI so a single environmental blip does not block a merge, but we treat every test that passed only on retry as a defect to be tracked.
The policy we agree with client teams:
- Measure. Playwright reports tests that passed on retry as "flaky". We export results to a dashboard and track the flaky rate per test over time.
- Set a budget. For example, fewer than 1% of test runs flaky over a rolling two-week window. Crossing it takes priority over new tests.
- Quarantine quickly. A test that flakes repeatedly is tagged (for example
@quarantine) and excluded from the blocking run with--grep-invert @quarantine, with an owner and a ticket. Quarantined tests still run nightly so we know when they are fixed. - Fix root causes. In our experience, most flakes come from shared test data, missing awaits, animations, or tests that assume a particular order of network responses.
On a B2B SaaS project, applying this policy took the suite from a flaky rate around 6% to under 1% in five weeks, mainly by moving data setup to API fixtures and replacing two dozen waitForTimeout calls with assertions.
Summary checklist
- Full-stack Playwright tests cover only critical journeys; everything else is tested lower down.
- Locators are role-based; assertions are web-first; there are no fixed sleeps.
- Test data is created through the API in fixtures, with unique names and cleanup.
- Authentication happens once in a setup project and is reused via storage state.
- Mocked UI tests and full-stack tests are separated; mocks are generated from shared types.
- CI shards the suite, merges blob reports and keeps traces for retried tests.
- Flaky tests are measured against a budget, quarantined with an owner, and fixed.
A trusted suite of 150 tests is worth far more than an ignored suite of 1,500. The goal is a red build that everyone takes seriously.



