All posts

15 Days of Playwright - Day 15: Building a Scalable Playwright Test Suite

The final day. Pull together everything from the series — locators, fixtures, POM, CI, visual testing, and architecture — into a single coherent strategy for a production-grade Playwright test suite.

Jhonatas Matos

Jhonatas Matos

15 Days of Playwright - Day 15: Building a Scalable Playwright Test Suite

You made it. Fifteen days, fifteen articles, one complete journey from npm init playwright@latest to production-grade test suites. Today we close the loop — not with a new technique, but with a strategy for putting everything together.

What We Covered

Before we look forward, let's map what we built:

| Day | Topic | Key Takeaway | |-----|-------|-------------| | 1 | Getting Started | Install Playwright, write the first test | | 2 | Interactions & Assertions | Semantic locators (getByRole, getByText), expect | | 3 | Smart Waits | Auto-waiting, waitForURL, never waitForTimeout | | 4 | API Mocking | page.route(), deterministic test scenarios | | 5 | File Handling | Uploads, downloads, in-memory buffers | | 6 | Authentication | storageState, authenticate once, reuse everywhere | | 7 | Fixtures & Hooks | Custom fixtures, composable test setup | | 8 | Page Object Model | Encapsulate selectors and actions per page | | 9 | Configuration & Parallelism | playwright.config.js, workers, multi-browser | | 10 | Hybrid API/UI Testing | request fixture, API setup + UI validation | | 11 | Trace Viewer & UI Mode | Post-mortem debugging, interactive development | | 12 | Visual Testing | toHaveScreenshot, baselines, masking | | 13 | CI/CD & Sharding | GitHub Actions, retries, --shard, reporters | | 14 | Test Architecture | Layered fixtures, data factories, environment config | | 15 | Scalable Suite | Everything together |

The Scalability Checklist

A Playwright suite is scalable when you can answer "yes" to all of these:

  • [ ] New team members can write tests without reading implementation code
  • [ ] A UI change requires updates in one place (the page object), not dozens of test files
  • [ ] Tests run in under 5 minutes in CI (through parallelism and sharding)
  • [ ] Flaky tests are identified automatically (retry + flaky tracking)
  • [ ] Every PR gets a test report with failure details and trace links
  • [ ] Credentials and URLs are in environment variables, not in test code
  • [ ] Tests create and destroy their own data — no shared fixtures between tests
  • [ ] Visual regressions are caught before deploy

The Complete Architecture

Here is how all the pieces connect in a mature project:

playwright.config.ts          ← Global config: browsers, retries, reporters, base URL
.github/workflows/
  playwright.yml              ← CI pipeline: matrix sharding, artifact upload
tests/
  fixtures/
    index.ts                  ← Composed test object (extends base test)
    auth.fixture.ts           ← Authentication state management
    data.fixture.ts           ← Test data creation + cleanup
  pages/
    BasePage.ts               ← Shared navigation and utility methods
    LoginPage.ts
    DashboardPage.ts
    RegisterPage.ts
    components/
      HeaderComponent.ts
      ModalComponent.ts
  data/
    factories.ts              ← buildUser(), buildAccount(), etc.
  helpers/
    api.ts                    ← Typed API wrapper using request fixture
    assertions.ts             ← Shared assertion helpers
    config.ts                 ← Environment-aware configuration
  specs/
    auth/
      login.spec.ts           ← @smoke @auth
      register.spec.ts        ← @smoke @auth
    banking/
      transfer.spec.ts        ← @regression @banking
      statement.spec.ts       ← @regression @banking
    visual/
      homepage.visual.ts      ← @visual
      dashboard.visual.ts     ← @visual
  __snapshots__/              ← Visual baselines committed to Git

The Maintainability Rules

After building many test suites, these rules prevent the most common forms of decay:

Rule 1: Selectors live in one place

Every selector for a page lives in that page's class. If you find yourself writing page.locator('.submit-btn') in a test file, move it to the page object.

Rule 2: Tests own their data

Every test that creates data cleans it up in a fixture teardown, even on failure. No test should depend on another test having run first.

testUser: async ({ request }, use) => {
  const user = await createUser(request, buildUser());
  await use(user);
  await deleteUser(request, user.id); // Runs even when test fails
},

Rule 3: No waitForTimeout anywhere

waitForTimeout is a symptom of a missing explicit wait or a missing assertion. Every occurrence should be replaced with waitForURL, waitForResponse, locator.waitFor(), or an expect assertion that auto-retries.

Rule 4: CI is the source of truth

A test that only passes locally is not passing. Run the full suite in CI on every PR. If it's too slow, shard it — don't skip it.

Rule 5: Flaky tests are bugs

When Playwright marks a test "flaky" (failed once, passed on retry), treat it with the same urgency as a production bug. A flaky test erodes trust in the entire suite.

A Complete Working Example

Here is what a spec file looks like when the architecture is in place:

// tests/specs/banking/transfer.spec.ts
import { test, expect } from '../../fixtures';
import { expectToastMessage } from '../../helpers/assertions';

test.describe('@regression @banking Transfer funds', () => {
  test('successful transfer reduces sender balance', async ({
    authenticatedPage,
    dashboardPage,
    testUser,
    testRecipient,
  }) => {
    await dashboardPage.goto();
    const initialBalance = await dashboardPage.getBalance();

    await dashboardPage.initiateTransfer({
      to: testRecipient.email,
      amount: 100,
    });

    await expectToastMessage(dashboardPage.page, 'Transfer completed');
    await expect(dashboardPage.balanceAmount).toHaveText(
      `$${initialBalance - 100}.00`
    );
  });

  test('transfer to unknown account shows error', async ({
    authenticatedPage,
    dashboardPage,
  }) => {
    await dashboardPage.goto();
    await dashboardPage.initiateTransfer({
      to: 'nobody@nowhere.com',
      amount: 50,
    });

    await expect(dashboardPage.errorMessage).toHaveText('Recipient not found');
  });

  test('transfer with insufficient funds shows error', async ({
    authenticatedPage,
    dashboardPage,
    testUser,
    testRecipient,
  }) => {
    await dashboardPage.goto();
    await dashboardPage.initiateTransfer({
      to: testRecipient.email,
      amount: 999999,
    });

    await expect(dashboardPage.errorMessage).toHaveText('Insufficient funds');
  });
});

No selectors. No setup. No page.goto. No credentials. The test reads like a specification — and that is the goal.

How to Grow a Suite Incrementally

You do not need all of this from day one. Here is a practical growth path:

Weeks 1–2: Foundation

  • Install Playwright, write your first tests
  • Use getByRole, getByLabel, getByText — avoid CSS selectors
  • Enable traces in CI from the beginning

Weeks 3–4: Page Objects

  • Create a page object for each major page you're testing
  • Move all locators and actions into the page classes
  • Tests import page objects, never raw locators

Month 2: Fixtures and Data

  • Create a custom test object with your app's fixtures
  • Build data factories for the entities your tests need
  • Every test creates and owns its data

Month 3: CI and Sharding

  • Wire up GitHub Actions (or your CI platform)
  • Add sharding once the suite takes more than 5 minutes
  • Enable retries with trace on first retry

Month 4+: Visual and Advanced

  • Add visual tests for critical pages
  • Add environment-aware configuration
  • Tag tests for smoke / regression / E2E tiers

Resources to Keep Learning

These are the official resources I return to most often:

Full series repository on GitHubGitHub Octocat

Conclusion

Fifteen days ago we installed Playwright and wrote a test that opened a browser and took a screenshot. Today you have the complete toolkit:

  • Reliable locators that survive UI refactors
  • Smart waits that eliminate flakiness without fixed timeouts
  • Page Object Model that keeps tests maintainable at scale
  • API mocking and hybrid testing for comprehensive coverage
  • Authentication management that makes login a one-time cost
  • Composable fixtures that eliminate setup repetition
  • Trace Viewer and UI Mode that make debugging fast
  • Visual testing that catches regressions functional tests miss
  • CI/CD with sharding that keeps feedback fast regardless of suite size
  • Test architecture that makes the suite survive the growth of the application

The tools are ready. The patterns are clear. What comes next is practice — writing real tests for real applications, learning what breaks, and applying what you know to fix it.

Thank you for following along through all 15 days. Keep testing, keep improving, and remember: a test suite is never finished — it grows with the product it protects.


Thank you for reading! See you in the next series! ☕💜