Playwright Practices That Survive Growth

Most test suites rot over time. Tests become flaky, slow, and ignored. Learn the practices that keep Playwright tests valuable as your team grows: ownership, tagging, smoke vs regression, locator.

This post is part of the Playwright Essentials series. If you missed the previous post, read Part 9 — Debugging Failed Playwright Tests Like a Pro first.


Introduction — Growth Is the Test of Quality

A test suite that works well with 50 tests and 3 developers will not automatically work well with 500 tests and 30 developers. Growth exposes every flaw in the system:

  • Tests that were “fast enough” at 5 minutes now take 45 minutes
  • Tests that were “flaky occasionally” are now flaky constantly
  • Tests that “everyone knows how to fix” now have no owner
  • Tests that “cover the important stuff” no longer cover what’s important

Most teams respond to this by ignoring the tests. Failing tests are skipped. Flaky tests are retried until they pass. CI is treated as advisory, not blocking. The test suite becomes a tax everyone resents paying.

This doesn’t have to happen. The practices in this post will keep your Playwright suite valuable as your team and codebase grow. These are the lessons learned from teams that maintained high-quality test suites for years, not months.


1. Ownership: Every Test Has an Owner

The single most predictive factor for test suite health is ownership. If nobody owns a test, nobody maintains it. If nobody maintains it, it rots.

How to Establish Ownership

Tag tests by team or domain:

test('user can checkout', {
  annotation: { type: 'owner', description: 'payments-team' },
}, async ({ page }) => {
  // test logic
});

Or use file-based ownership: tests/payments/ owned by payments-team, tests/auth/ owned by auth-team.

Make ownership visible in reports: If a test fails, the CI report should show who owns it. Use custom reporters or annotations to surface this.

Define ownership SLAs:

  • If a test fails, the owner must investigate within 24 hours
  • If a test is flaky > 5% of the time, the owner must fix or disable it within 1 week
  • If a test is disabled, the owner must fix or delete it within 1 sprint

Without SLAs, ownership is performative. With SLAs, ownership is accountability.


2. Tagging: Run the Right Tests at the Right Time

Not all tests are equally important. Running your entire suite on every commit is slow and wasteful. Tagging lets you run smoke tests on every commit, regression tests nightly, and performance tests on-demand.

Tagging Strategy

// Smoke test: critical user journeys, runs on every commit
test('user can log in', {
  annotation: { type: 'smoke' },
}, async ({ page }) => {
  // test logic
});

// Regression test: full coverage, runs nightly
test('user can reset password', {
  annotation: { type: 'regression' },
}, async ({ page }) => {
  // test logic
});

// Performance test: load/stress testing, runs on-demand
test('checkout handles 100 concurrent users', {
  annotation: { type: 'performance' },
}, async ({ page }) => {
  // test logic
});

Run tests by tag:

npx playwright test --grep @smoke       # Fast feedback on every commit
npx playwright test --grep @regression  # Full suite nightly
npx playwright test --grep @performance # On-demand before releases

Smoke Test Guidelines

Smoke tests should be:

  • Fast — < 10 minutes total runtime
  • Critical — If these fail, the app is broken
  • Stable — < 1% flake rate

If a smoke test is flaky or slow, demote it to regression or fix it. Flaky smoke tests erode trust.


3. Locator Policy: Consistency Reduces Maintenance

Locators are the most fragile part of E2E tests. A change to the DOM structure can break dozens of tests. A consistent locator strategy reduces maintenance cost.

1. data-testid attributes (highest priority)

await page.click('[data-testid="checkout-button"]');

Pros: Stable, explicit test contract with developers. Changes to classes or text don’t break tests.

Cons: Requires developer cooperation to add attributes.

2. ARIA roles and labels

await page.click('button[name="checkout"]');
await page.getByRole('button', { name: 'Checkout' }).click();

Pros: Encourages accessible markup. No extra attributes needed.

Cons: Accessibility markup changes (e.g., label rewording) can break tests.

3. Text content (use sparingly)

await page.click('button:has-text("Checkout")');

Pros: Easy to write, no extra markup.

Cons: Breaks when text changes (e.g., internationalization, A/B testing).

4. CSS classes or IDs (last resort)

await page.click('.btn-primary');

Pros: Works when nothing else is available.

Cons: Fragile. Styling changes break tests.

Enforce the Policy

Add a linting rule to your test suite:

// tests/locator-lint.ts
// Fail CI if tests use CSS classes without data-testid

Or use code review: reject PRs that use fragile locators when stable alternatives exist.


4. Flake Budget: Measure and Enforce Stability

Flaky tests are the silent killer of test suites. One flaky test is annoying. Ten flaky tests mean nobody trusts the CI. Set a flake budget and enforce it.

How to Measure Flakiness

Track flake rate per test:

Flake rate = (number of runs that required a retry) / (total number of runs)

A test that passes on the first try 95 times out of 100 and requires a retry 5 times has a 5% flake rate.

Setting a Flake Budget

Recommended budget:

  • Smoke tests: < 1% flake rate
  • Regression tests: < 5% flake rate
  • Experimental tests: < 10% flake rate (or disable them)

If a test exceeds its budget, disable it until it’s fixed. A flaky test is worse than no test because it wastes time and erodes trust.

Tracking Flakiness in CI

Use Playwright’s built-in flake tracking:

export default defineConfig({
  retries: 2,
  reporter: [
    ['list'],
    ['json', { outputFile: 'test-results.json' }],
  ],
});

Parse test-results.json in CI to track:

  • Total test count
  • Pass rate
  • Flake rate (tests that passed after retry)
  • Slowest tests

Publish these metrics to a dashboard and review them weekly.


5. Test Data Strategy: Isolation or Cleanup

Tests that share data are fragile. If Test A creates a user and Test B deletes that user, and they run in parallel, both tests fail unpredictably.

Each test gets its own unique data:

test('user can log in', async ({ page }) => {
  const testUser = `test-${Date.now()}@example.com`;
  await createUser(testUser, 'password123');

  await page.goto('/login');
  await page.fill('input[name="email"]', testUser);
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
});

Pros: Tests are independent, can run in any order.

Cons: Test data accumulates. Requires periodic cleanup.

Option 2: Test Data Cleanup

Each test cleans up after itself:

test('user can log in', async ({ page, request }) => {
  const testUser = 'test@example.com';
  await createUser(testUser, 'password123');

  await page.goto('/login');
  await page.fill('input[name="email"]', testUser);
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');

  // Cleanup
  await request.delete('/api/users/test@example.com');
});

Pros: No data accumulation.

Cons: If cleanup fails (test crashes, CI timeout), data persists and pollutes future runs.

Use isolation for most tests. Add a scheduled job to clean up test data older than 24 hours. This gives you isolation’s benefits (parallel execution, no shared state) without unbounded data growth.


6. Documentation: The Test Suite as a Living Document

Your test suite is documentation. If a developer wants to know “how does checkout work?” they should be able to read the tests and understand the flow.

Writing Self-Documenting Tests

Bad test:

test('test 1', async ({ page }) => {
  await page.goto('/');
  await page.click('button');
  await expect(page.locator('div')).toHaveText('Success');
});

What does this test do? What is it verifying?

Good test:

test('guest user can complete checkout without creating an account', async ({ page }) => {
  await addItemToCart(page, 'Red T-Shirt');
  await page.goto('/checkout');

  await page.fill('input[name="email"]', 'guest@example.com');
  await page.fill('input[name="cardNumber"]', '4242424242424242');
  await page.click('button:has-text("Place Order")');

  await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
  await expect(page.locator('[data-testid="order-number"]')).toContainText(/ORDER-\d{6}/);
});

This test tells you:

  • Who: Guest user (not logged in)
  • What: Complete checkout
  • How: Add item, fill details, submit
  • Expected outcome: Order confirmation with order number

README for the Test Suite

Add a tests/README.md:

# E2E Test Suite

## Running Tests

- `npm test` — Run all tests
- `npm run test:smoke` — Run smoke tests only
- `npm run test:headed` — Run with visible browser

## Test Organization

- `tests/auth/` — Authentication and authorization tests (owner: auth-team)
- `tests/checkout/` — Checkout and payment tests (owner: payments-team)
- `tests/admin/` — Admin panel tests (owner: platform-team)

## Writing New Tests

- Use `data-testid` attributes for locators
- Tag smoke tests with `@smoke`
- Clean up test data after each test

## Debugging Failed Tests

1. Download the trace from CI
2. Run `npx playwright show-trace trace.zip`
3. Inspect the action timeline and network requests

This saves hours of onboarding time for new team members.


7. Progressive Refactoring: Improve Over Time

Test suites don’t need to be perfect on day one. They need to improve over time. Adopt a progressive refactoring approach:

Monthly Test Health Review

Every month, review:

  • Flakiest tests — Fix or disable the top 5
  • Slowest tests — Optimize or shard the slowest 10
  • Disabled tests — Re-enable or delete tests that have been disabled > 1 month

Refactoring Principles

  • Make it work, then make it fast — A slow test is better than no test
  • Refactor with confidence — Use test traces to verify refactorings didn’t change behaviour
  • Small, incremental changes — Refactor one test file per sprint, not the entire suite at once

Conclusion: This Is the End of the Beginning

This series covered the technical skills: writing tests, debugging failures, configuring CI. But keeping a test suite valuable over years is not a technical problem — it’s an organisational problem.

The practices in this post are the difference between a test suite that rots and a test suite that grows with your company:

  • Ownership — Every test has a named owner
  • Tagging — Run smoke tests on every commit, regression tests nightly
  • Locator policy — Use data-testid, not CSS classes
  • Flake budget — Measure and enforce stability
  • Test data isolation — Tests don’t interfere with each other
  • Documentation — Tests are readable and onboarding is smooth
  • Progressive refactoring — Improve the suite incrementally, not all at once

:::tip[Start Small, Scale Gradually] Don’t try to implement all these practices on day one. Start with ownership and tagging. Add locator policy next sprint. Introduce flake budgets when the suite is large enough to justify the overhead. Build the system that fits your current team size, then adapt as you grow. :::

Action for this week: Pick one practice from this post and implement it. If your suite has no ownership, assign owners to the top 10 most critical tests. If you have no tagging, tag your 5 most important tests as @smoke and set up a CI job to run them on every commit. Small changes compound.


This concludes the Playwright Essentials series. You now have the tools to write fast, reliable, maintainable end-to-end tests and the practices to keep them valuable as your team grows.

If you’re ready to think more strategically about when to automate (not just how), continue with the Automation Strategy series starting with When to Automate — and When to Walk Away.


End of Playwright Essentials series.