When every test imports page objects directly and handles its own login setup, a single fixture change requires updating dozens of files. The fix is a fixture index: one file that merges all fixture definitions, so tests import from ../../fixtures and nothing else. This article covers the folder layout that makes this work, the BasePage class for shared navigation, the mergeTests pattern for composing fixture files, environment config with a requireEnv() wrapper that fails at startup on missing variables, and the strangler fig migration pattern for introducing structure into an existing suite without stopping to rewrite everything.

The folder structure that actually scales

Architecture decisions made at the folder level propagate everywhere. A structure that reflects the framework's purpose (separation between test logic, page interaction, data setup, and utilities) keeps complexity manageable as the suite grows.

my-app-tests/
  tests/
    auth/
      login.spec.ts
      logout.spec.ts
    items/
      items-crud.spec.ts
      items-search.spec.ts
  pages/
    BasePage.ts
    LoginPage.ts
    DashboardPage.ts
  fixtures/
    index.ts
    auth.fixture.ts
    pages.fixture.ts
    data.fixture.ts
  data/
    factories/
      userFactory.ts
      itemFactory.ts
    seeds/
      seedItems.ts
  helpers/
    waitHelpers.ts
    apiHelpers.ts
  utils/
    envConfig.ts
    logger.ts
  playwright.config.ts
  tsconfig.json
  .eslintrc.json
  .env.example

The key rules that make this work: tests/ contains only spec files and no shared logic. pages/ contains only page object classes. fixtures/ is the glue layer that wires everything together. data/ owns all test data creation and seeding. helpers/ holds reusable functions that don't belong to a specific page. utils/ holds infrastructure: config, logging, anything framework-level.

Tests import from fixtures/index.ts and nothing else. That single constraint keeps the dependency graph clean.

The base page class

Every page object in a growing suite needs the same set of capabilities: navigation, waiting for the page to reach a known state, and a consistent way to handle common UI patterns. Without a base class, these patterns get copy-pasted across pages and drift apart over time.

// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';

export abstract class BasePage {
  protected readonly page: Page;
  abstract readonly path: string;

  constructor(page: Page) {
    this.page = page;
  }

  async navigate(params?: Record<string, string>): Promise<void> {
    const url = new URL(this.path, envConfig.baseURL);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.set(key, value);
      });
    }
    await this.page.goto(url.toString());
    await this.waitForPageLoad();
  }

  protected async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState('domcontentloaded');
  }

  async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'visible', timeout });
  }

  async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'hidden', timeout });
  }

  async assertHeading(text: string): Promise<void> {
    await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
  }

  async assertURL(expectedPath: string): Promise<void> {
    await expect(this.page).toHaveURL(new RegExp(expectedPath));
  }

  async dismissModal(): Promise<void> {
    const overlay = this.page.locator('[data-testid="modal-overlay"]');
    if (await overlay.isVisible()) {
      await this.page.keyboard.press('Escape');
      await this.waitForHidden(overlay);
    }
  }
}

Page objects extend the base and only define what's specific to that page:

// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';

export class DashboardPage extends BasePage {
  readonly path = '/dashboard';
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;
  readonly searchInput: Locator;

  constructor(page: Page) {
    super(page);
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
    this.itemsTable = page.getByRole('table', { name: 'Travel items' });
    this.searchInput = page.getByRole('searchbox');
  }

  protected override async waitForPageLoad(): Promise<void> {
    await super.waitForPageLoad();
    await this.itemsTable.waitFor({ state: 'visible' });
  }

  async getRowCount(): Promise<number> {
    const rows = this.itemsTable.getByRole('row');
    return (await rows.count()) - 1;
  }

  async searchFor(term: string): Promise<void> {
    await this.searchInput.fill(term);
    await this.page.waitForResponse('**/api/items?search=**');
  }
}

The waitForPageLoad override in DashboardPage is the pattern that eliminates flaky tests at scale. Each page defines its own "ready" condition, and navigation waits for that condition before returning. Tests never need to add manual waits.

The fixture layer

Fixtures are the framework's dependency injection system. One file exports everything tests need: the extended test object, all page objects, data helpers, and expect. Test files import from exactly one place.

// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import { envConfig } from '../utils/envConfig';

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto(envConfig.baseURL);
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill(envConfig.testUser.email);
    await page.getByLabel('Password').fill(envConfig.testUser.password);
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByRole('heading', { name: 'Dashboard' }).waitFor();

    await use(page);
  },
});

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';

export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';

mergeTests is the right tool here: it composes fixtures from multiple files without losing type safety. Every test in the project now has the same import:

import { test, expect } from '../../fixtures';

That single import gives every test access to authenticatedPage, dashboardPage, loginPage, and all data fixtures. Adding a new fixture means editing one file in fixtures/ and it's immediately available everywhere.

Keep the fixture index file thin: only mergeTests calls and re-exports. The moment you put fixture logic directly in index.ts, it becomes harder to locate where a specific fixture is defined. One fixture file per domain (auth, pages, data) keeps things navigable.

Config management across environments

Hard-coded URLs are the fastest way to make a test suite unmaintainable. Environment-specific config needs a single source of truth that the rest of the framework reads from.

// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

const envFile = process.env.TEST_ENV
  ? `.env.${process.env.TEST_ENV}`
  : '.env';

dotenv.config({ path: path.resolve(process.cwd(), envFile) });

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. ` +
      `Did you copy .env.example to ${envFile}?`
    );
  }
  return value;
}

export const envConfig = {
  baseURL: requireEnv('BASE_URL'),
  apiBaseURL: requireEnv('API_BASE_URL'),
  testUser: {
    email: requireEnv('TEST_USER_EMAIL'),
    password: requireEnv('TEST_USER_PASSWORD'),
  },
  apiToken: requireEnv('API_TOKEN'),
  environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;

Three .env files sit at the project root and are committed to the repository (secrets go in CI variables, not here):

# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me

# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=

playwright.config.ts reads from envConfig rather than from process.env directly:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: envConfig.environment !== 'local',
  retries: envConfig.environment === 'local' ? 0 : 2,
  workers: envConfig.environment === 'local' ? undefined : 4,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['./utils/slackReporter.ts'],
  ],
  use: {
    baseURL: envConfig.baseURL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Running against staging is now one environment variable: TEST_ENV=staging npx playwright test.

Test data strategy

Tests that depend on data created by a previous test are the most fragile tests in any suite. Every test must own its data from creation to cleanup. Three patterns handle different scenarios: factories for in-test data, builders for complex objects, and API seeding for expensive pre-conditions.

Factories generate valid objects with sensible defaults and let tests override only what matters for the specific scenario:

// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';

export interface ItemData {
  name: string;
  category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
  quantity: number;
  notes?: string;
}

export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
  return {
    name: faker.commerce.productName(),
    category: 'Documents',
    quantity: faker.number.int({ min: 1, max: 10 }),
    ...overrides,
  };
}

export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
  return Array.from({ length: count }, () => buildItem(overrides));
}

For complex objects with many dependencies, the builder pattern gives tests a fluent API:

// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';

export interface UserData {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  role: 'admin' | 'member' | 'viewer';
}

export class UserBuilder {
  private data: UserData = {
    email: faker.internet.email(),
    password: 'TestPass123!',
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    role: 'member',
  };

  withRole(role: UserData['role']): this {
    this.data.role = role;
    return this;
  }

  withEmail(email: string): this {
    this.data.email = email;
    return this;
  }

  asAdmin(): this {
    this.data.role = 'admin';
    return this;
  }

  build(): UserData {
    return { ...this.data };
  }
}

API seeding handles the case where UI creation is too slow or creates unreliable state. The data fixture wires it together and handles cleanup:

// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';

type DataFixtures = {
  apiRequest: APIRequestContext;
  seededItem: ItemData & { id: string };
};

export const test = base.extend<DataFixtures>({
  apiRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: envConfig.apiBaseURL,
      extraHTTPHeaders: {
        Authorization: `Bearer ${envConfig.apiToken}`,
        'Content-Type': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },

  seededItem: async ({ apiRequest }, use) => {
    const itemData = buildItem();
    const response = await apiRequest.post('/items', { data: itemData });
    const created = await response.json() as ItemData & { id: string };

    await use(created);

    // Cleanup runs whether test passes or fails
    await apiRequest.delete(`/items/${created.id}`).catch(() => {
      console.warn(`Cleanup failed for item ${created.id} — may need manual removal`);
    });
  },
});

The .catch() in teardown is intentional. If cleanup throws, the test result should not be affected. Log the warning and move on.

Never rely on test execution order to set up shared state. Tests run in parallel by default and the order is not guaranteed. Each test must create and destroy its own data. The one exception is read-only reference data (lookup tables, categories) that never changes: seed those once per run via a global setup script.

Reporters: HTML and Slack notifications

The built-in HTML reporter is sufficient for local development. CI pipelines need something that delivers results where the team actually looks. In most cases, Slack.

A custom reporter implements Playwright's Reporter interface:

// utils/slackReporter.ts
import type {
  Reporter,
  FullConfig,
  Suite,
  TestCase,
  TestResult,
  FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';

export default class SlackReporter implements Reporter {
  private passed = 0;
  private failed = 0;
  private skipped = 0;
  private failedTests: string[] = [];
  private startTime = Date.now();

  onBegin(_config: FullConfig, _suite: Suite): void {
    this.startTime = Date.now();
  }

  onTestEnd(test: TestCase, result: TestResult): void {
    if (result.status === 'passed') this.passed++;
    else if (result.status === 'skipped') this.skipped++;
    else {
      this.failed++;
      this.failedTests.push(test.titlePath().join(' > '));
    }
  }

  async onEnd(result: FullResult): Promise<void> {
    const webhookUrl = process.env.SLACK_WEBHOOK_URL;
    if (!webhookUrl) return;

    const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
    const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
    const total = this.passed + this.failed + this.skipped;

    const blocks = [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${status} *Playwright Tests — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passed in ${duration}s`,
        },
      },
    ];

    if (this.failedTests.length > 0) {
      blocks.push({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*Failed tests:*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
        },
      });
    }

    const payload = JSON.stringify({ blocks });
    await this.postToSlack(webhookUrl, payload);
  }

  private postToSlack(webhookUrl: string, payload: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = new URL(webhookUrl);
      const req = https.request(
        { hostname: url.hostname, path: url.pathname, method: 'POST',
          headers: { 'Content-Type': 'application/json' } },
        () => resolve()
      );
      req.on('error', reject);
      req.write(payload);
      req.end();
    });
  }
}

Register the reporter in playwright.config.ts:

reporter: [
  ['html', { outputFolder: 'playwright-report', open: 'never' }],
  envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],

The Slack reporter only activates on non-local environments. No noise during local development.

TypeScript strict mode and linting

Test code is production code. It runs in CI, it affects release decisions, and bugs in tests are harder to catch than bugs in application code because there's nothing testing the tests. TypeScript strict mode and ESLint catch entire categories of issues before they reach a team member.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@fixtures": ["./fixtures/index.ts"],
      "@pages/*": ["./pages/*"],
      "@data/*": ["./data/*"],
      "@helpers/*": ["./helpers/*"],
      "@utils/*": ["./utils/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

The paths configuration means test files can use clean imports:

import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';

For ESLint, the key rules for test quality are the ones that prevent common Playwright-specific mistakes:

// .eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "playwright"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/strict-type-checked",
    "plugin:playwright/recommended"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/await-thenable": "error",
    "playwright/no-wait-for-timeout": "error",
    "playwright/no-conditional-in-test": "warn",
    "playwright/prefer-web-first-assertions": "error",
    "playwright/no-networkidle": "warn"
  }
}

no-floating-promises is the most important rule in a Playwright test suite. Missing an await before a Playwright call is a common source of false positives: the assertion runs before the action completes, the test passes, and then the UI is in an unexpected state for the next step. TypeScript alone won't catch this; the linting rule will.

Growing alongside an existing suite: the strangler fig pattern

The strangler fig pattern describes incrementally replacing an old system by growing a new one around it, gradually routing traffic from old to new until nothing touches the old system and it can be removed. The same approach applies to test frameworks.

Starting a "framework rewrite" as a parallel effort always fails. The new framework lives in a separate branch, the old suite keeps changing, the branch never merges. The strangler fig approach keeps the team shipping tests in the old structure while the new structure gradually absorbs them.

The practical steps:

Step 1: Create the new folder structure alongside the existing tests. Don't move anything yet.

tests/          ← existing flat structure, untouched
framework/      ← new structure, starts empty
  tests/
  pages/
  fixtures/
  ...
playwright.config.ts  ← updated to run both

Update playwright.config.ts to include both test directories:

export default defineConfig({
  projects: [
    {
      name: 'legacy',
      testDir: './tests',
      use: { baseURL: envConfig.baseURL },
    },
    {
      name: 'framework',
      testDir: './framework/tests',
      use: { baseURL: envConfig.baseURL },
    },
  ],
});

Step 2: When writing a new test, always write it in the new structure. Never add to the old flat folder. This stops the old structure from growing. Step 3: When modifying an existing test (to fix it, or because the feature it covers changed), move it to the new structure as part of the same PR. The test improves and migrates in a single change.

// framework/tests/items/items-search.spec.ts
// Migrated from tests/items-search.spec.ts
// Migration: extracted LoginPage, wired to fixtures, removed hardcoded URL

import { test, expect } from '../../fixtures';

test('search filters items by name', async ({ authenticatedPage, dashboardPage }) => {
  await dashboardPage.navigate();
  await dashboardPage.searchFor('Passport');

  await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // header + 1 result
});

Step 4: Add a lint rule or a simple CI check that fails if the tests/ directory gains new files:

// package.json
"scripts": {
  "check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
  "test": "playwright test",
  "test:framework": "playwright test --project=framework",
  "test:legacy": "playwright test --project=legacy",
  "lint": "eslint . --ext .ts",
  "typecheck": "tsc --noEmit"
}

The check script reads the git diff and fails if any new .spec.ts files appear in tests/. Teams stop adding to the old structure not because of a rule but because the new structure is clearly better, and the check provides a safety net for anyone who hasn't noticed the pattern yet.

After a few months of this, the legacy directory contains only old tests that nobody has touched. At that point, a dedicated migration sprint converts the remainder, and the legacy directory is deleted. The migration happened incrementally, the team shipped features the whole time, and the framework is in production from day one.

FAQ

How many page objects should one class cover?

One page per class, one modal per class. If a page has two completely separate sections (a sidebar and a main panel with different concerns), split them into two classes and compose them in the fixture. A class that covers two pages is a sign that the boundary was drawn in the wrong place.

Should fixtures ever contain assertions?

No. Fixtures set up and tear down state. An assertion in a fixture makes it impossible to tell whether a test failure came from the test logic or the setup. If you need to verify that setup completed successfully, use Playwright's waitFor with a condition rather than an assertion. Assertions belong exclusively in test files.

How do I handle tests that need different user roles?

Create separate auth fixtures, one per role: adminPage, memberPage, viewerPage. Each fixture logs in as a different user and hands the authenticated page to the test. If the number of roles grows, consider a factory pattern: authenticatedAs('admin') returns the right fixture based on a parameter.

What's the right number of workers for parallel execution?

Start with workers: '50%' in playwright.config.ts (half the available CPU cores). Monitor your CI runner's resource usage over several runs. If tests start flaking due to resource contention, reduce workers. If the runner has headroom, increase them. The right number depends on the runner spec and how resource-intensive each test is, not on a universal formula.

When should I use test.describe vs separate spec files?

Separate spec files for separate features. test.describe for logical groupings within a feature: happy path vs edge cases, or read operations vs write operations. The rule of thumb: if two groups of tests need different test.use() configuration (different fixture overrides), they belong in separate describe blocks or separate files. If they use the same setup, grouping them is a style choice.

→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Page Object Model in Playwright: From Messy to Maintainable | Parallel Execution in Playwright: Workers, Shards, and Sharding for Speed | Test Isolation: Why Each Playwright Test Should Be Stateless