All posts

15 Days of Playwright - Day 14: Test Architecture for Real Projects

A collection of test scripts is not a test suite. Learn how to structure fixtures, helpers, data factories, and environment config so your Playwright tests scale gracefully as the application grows.

Jhonatas Matos

Jhonatas Matos

15 Days of Playwright - Day 14: Test Architecture for Real Projects

By now you know how to write Playwright tests. The harder question is: how do you organise 200 of them so the suite stays fast, readable, and maintainable six months from now?

Today we step back from individual test techniques and look at the architecture that makes a test suite survive the growth of a real product.

The Problem with a Flat Test Suite

Most test suites start flat:

tests/
  login.test.js
  register.test.js
  transfer.test.js
  ...

Within a few months you have 50 files, repeated login setups in every file, selectors copy-pasted everywhere, and a 15-minute run time. Every UI refactor breaks dozens of tests in unpredictable places.

The fix is architectural, not technical. The same patterns you use for production code — separation of concerns, single responsibility, dependency injection — apply directly to test code.

Recommended Project Structure

tests/
  fixtures/              # Custom Playwright fixtures (extended test objects)
    auth.fixture.ts
    database.fixture.ts
    index.ts             # Re-exports all fixtures as a single 'test' object
  pages/                 # Page Object Model classes
    LoginPage.ts
    RegisterPage.ts
    DashboardPage.ts
    components/
      HeaderComponent.ts
      ModalComponent.ts
  data/                  # Test data factories
    users.ts
    accounts.ts
  helpers/               # Shared utilities
    api.ts
    assertions.ts
  specs/                 # Actual test files (thin — only assertions and intent)
    auth/
      login.spec.ts
      register.spec.ts
    banking/
      transfer.spec.ts
      statement.spec.ts
playwright.config.ts

The specs directory is intentionally thin. Each test file should read like a scenario description, not an implementation manual. All the "how" lives in fixtures, pages, and helpers.

Layered Architecture

Think of a test suite as having three layers, each with a single job:

┌──────────────────────────────────────┐
│  Spec Layer (specs/)                 │
│  "What does this feature do?"        │
│  Tests, describe blocks, assertions  │
├──────────────────────────────────────┤
│  Service Layer (pages/, helpers/)    │
│  "How do we interact with the UI?"   │
│  Page Objects, component wrappers    │
├──────────────────────────────────────┤
│  Infrastructure Layer (fixtures/)    │
│  "What does this test need to run?"  │
│  Auth state, DB connections, config  │
└──────────────────────────────────────┘

Violations of this layering — assertions in Page Objects, navigation in fixtures, raw selectors in specs — are where maintenance debt accumulates.

Building a Composable Fixture Layer

Day 7 introduced custom fixtures. In a real project, you build a hierarchy of fixtures that compose together:

// tests/fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { createUser, deleteUser } from '../helpers/api';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: void;         // Sets up auth state before the test
  testUser: { email: string; password: string; id: string };
};

export const test = base.extend<AppFixtures>({
  // Page Object fixtures — automatically instantiated
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  // Test data fixture — creates a fresh user and cleans up after
  testUser: async ({ request }, use) => {
    const user = await createUser(request, {
      email: `test-${Date.now()}@example.com`,
      password: 'TestPassword123!',
    });

    await use(user); // Provide the user to the test

    await deleteUser(request, user.id); // Clean up after test, even on failure
  },

  // Auth fixture — logs in using testUser via API (fast)
  authenticatedPage: [async ({ page, testUser, request }, use) => {
    const token = await loginViaApi(request, testUser);
    await page.addInitScript((t) => {
      window.localStorage.setItem('authToken', t);
    }, token);
    await use();
  }, { auto: false }],
});

export const expect = test.expect;

Tests that import from tests/fixtures/index.ts instead of @playwright/test get access to all these fixtures automatically:

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

test('transfer between accounts', async ({ authenticatedPage, dashboardPage, testUser }) => {
  await dashboardPage.goto();
  await dashboardPage.initiateTransfer({ to: 'recipient@bank.com', amount: 100 });
  await expect(dashboardPage.balanceAmount).toHaveText('$900.00');
});

The test body is pure intent. No setup, no teardown, no selectors — all of that is absorbed by fixtures and page objects.

Data Factories

Hard-coded test data is a maintenance burden. Data factories generate realistic, varied data on demand:

// tests/data/users.ts
import { faker } from '@faker-js/faker';

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    password: faker.internet.password({ length: 12, memorable: false }),
    role: 'user',
    ...overrides,
  };
}

export function buildAdminUser(): User {
  return buildUser({ role: 'admin', email: 'admin@example.com' });
}

Usage in tests:

test('register a new user', async ({ page, registerPage }) => {
  const user = buildUser({ name: 'John Doe' }); // Fixed name, random rest

  await registerPage.goto();
  await registerPage.fill(user);
  await registerPage.submit();

  await expect(page.locator('.success-modal')).toBeVisible();
});

Factories make every test independent: each test gets its own data, with no risk of state leaking between tests.

Environment Configuration

Real projects run against multiple environments: local, staging, production. Centralise environment config rather than scattering process.env calls across test files:

// tests/helpers/config.ts
const env = process.env.TEST_ENV || 'local';

const configs = {
  local: {
    baseURL: 'http://localhost:3000',
    apiURL: 'http://localhost:4000',
    defaultTimeout: 10000,
  },
  staging: {
    baseURL: process.env.STAGING_URL!,
    apiURL: process.env.STAGING_API_URL!,
    defaultTimeout: 20000,
  },
  production: {
    baseURL: process.env.PROD_URL!,
    apiURL: process.env.PROD_API_URL!,
    defaultTimeout: 30000,
  },
} as const;

export const config = configs[env as keyof typeof configs];
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { config } from './tests/helpers/config';

export default defineConfig({
  use: {
    baseURL: config.baseURL,
    actionTimeout: config.defaultTimeout,
  },
});

Tagging Tests for Selective Execution

Not every test needs to run on every push. Use tags to create execution tiers:

test('@smoke @auth login with valid credentials', async ({ loginPage }) => {
  // ...
});

test('@regression @auth login with expired token shows error', async ({ loginPage }) => {
  // ...
});

test('@e2e complete onboarding flow', async ({ page }) => {
  // ... a full journey — expensive, run less frequently
});

In playwright.config.ts:

export default defineConfig({
  projects: [
    {
      name: 'smoke',
      grep: /@smoke/,
      use: { retries: 0, timeout: 15000 },
    },
    {
      name: 'regression',
      grep: /@regression/,
      use: { retries: 2, trace: 'on-first-retry' },
    },
    {
      name: 'e2e',
      grep: /@e2e/,
      use: { retries: 1, video: 'on-first-retry' },
    },
  ],
});

Run smoke tests on every commit, regression nightly, full E2E before releases.

Shared Assertion Helpers

Repeated assertion patterns deserve their own helpers:

// tests/helpers/assertions.ts
import { expect, Page } from '@playwright/test';

export async function expectToastMessage(page: Page, message: string) {
  const toast = page.locator('.toast-message');
  await expect(toast).toBeVisible();
  await expect(toast).toHaveText(message);
  await expect(toast).toBeHidden({ timeout: 5000 }); // Toast auto-dismisses
}

export async function expectTableRow(page: Page, rowText: string) {
  await expect(
    page.locator(`tr:has-text("${rowText}")`)
  ).toBeVisible();
}

These helpers keep assertion logic in one place and make test bodies read like plain English:

test('transfer shows success toast', async ({ page, dashboardPage }) => {
  await dashboardPage.initiateTransfer({ to: 'user@bank.com', amount: 50 });
  await expectToastMessage(page, 'Transfer completed successfully');
});
Link to GitHub projectGitHub Octocat

Conclusion

Architecture is what separates a test suite from a collection of test scripts. The key principles:

  • Thin specs — tests describe intent, not implementation
  • Composable fixtures — infrastructure concerns live in one place
  • Data factories — every test gets fresh, independent data
  • Layered page objects — UI details are encapsulated, not repeated
  • Environment config — one config file governs all environments
  • Test tags — control what runs when, without separate test files

Apply these patterns incrementally. You don't need a perfect architecture before writing your first test — you need a direction to refactor toward as the suite grows.


Thank you for reading and see you in the next lesson! ☕💜