Test organisation and page objects

Compose page objects with fixtures, build test data explicitly, and use tags and sharding to keep a growing suite fast and readable.

Page objects as fixtures

// pages/InvoicesPage.ts
import { type Page, type Locator, expect } from "@playwright/test";

export class InvoicesPage {
  readonly page: Page;
  readonly rows: Locator;
  readonly search: Locator;

  constructor(page: Page) {
    this.page = page;
    this.rows = page.getByRole("row").filter({ hasNot: page.getByRole("columnheader") });
    this.search = page.getByRole("searchbox", { name: "Search invoices" });
  }

  async open() {
    await this.page.goto("/invoices");
    await expect(this.rows.first()).toBeVisible();
  }

  async searchFor(term: string) {
    await this.search.fill(term);
    await this.search.press("Enter");
  }

  rowByReference(reference: string): Locator {
    return this.rows.filter({ hasText: reference });
  }
}
// fixtures.ts
import { test as base } from "@playwright/test";
import { InvoicesPage } from "./pages/InvoicesPage";

export const test = base.extend<{ invoices: InvoicesPage }>({
  invoices: async ({ page }, use) => {
    await use(new InvoicesPage(page));
  },
});

// the test reads as intent, with no selectors in sight
test("finds an invoice by reference", async ({ invoices }) => {
  await invoices.open();
  await invoices.searchFor("INV-1042");
  await expect(invoices.rowByReference("INV-1042")).toHaveCount(1);
});
  • Expose locators as readonly properties; a test that needs a selector the page object does not have is a sign the object is missing a method, not a sign to reach into the page.
  • Page objects should not assert on business outcomes - return locators and let the test assert.
  • Compose rather than inherit: a NavBar component used inside several page objects is easier to reason about than a base class.
  • Keep one page object per URL-ish area, not per component in the design system.

Test data builders

type OrderInput = {
  sku: string;
  quantity: number;
  currency: "GBP" | "USD";
  note?: string;
};

export function anOrder(overrides: Partial<OrderInput> = {}): OrderInput {
  return {
    sku: "A-1",
    quantity: 1,
    currency: "GBP",
    ...overrides,
  };
}

// the test states only what it cares about
const order = anOrder({ quantity: 2, note: "leave at the door" });
ApproachGoodBad
Builder with overridesOnly the relevant field is visibleHidden defaults drift unnoticed
Shared JSON fixturesReusableOne change breaks many tests
Random dataFinds boundary bugsNon-reproducible failures
Unique per testSafe in parallelNeeds cleanup
Seeded factoryDeterministic and uniqueExtra infrastructure
💡
Make every test create the data it needs and delete it afterwards, keyed to something unique for that test. Suites only become flaky through shared state once they run in parallel - and by then the coupling is expensive to unpick.

Tags, groups and sharding

test.describe("checkout", { tag: ["@checkout", "@slow"] }, () => {
  test("pays with a saved card", { tag: "@smoke" }, async ({ page }) => {
    // ...
  });
});

test.skip(({ browserName }) => browserName === "firefox", "known Firefox bug");
npx playwright test --grep @smoke                 # the fast subset
npx playwright test --grep-invert @slow           # everything else
npx playwright test --shard=1/3                   # one third of the suite
npx playwright test --shard=1/3 --reporter=blob   # merge reports afterwards
npx playwright merge-reports --reporter=html ./blob-report
  1. Tag by the reason someone would select a subset: @smoke for the pull-request gate, @slow for the nightly run.
  2. Shard across CI machines by test count, and merge the reports so the failure view is still unified.
  3. Group with test.describe to share setup and to make the report readable; nesting more than two levels is usually a sign the file should be split.
  4. Split files by feature area, not by test type, so a change to a feature touches one file.
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
  - uses: actions/upload-artifact@v4
    with:
      name: blob-${{ matrix.shard }}
      path: blob-report

FAQ

Are page objects still worth it in Playwright?
Yes, but less than in older tools, because locators are lazy and auto-waiting means a page object mostly holds selectors and small flows. The main value is that a selector appears in exactly one place, and the test reads as intent rather than as DOM navigation.
How do I keep a large suite fast?
Parallelise with workers, shard across machines in CI, tag a small smoke subset for pull requests, and make each test create only the data it needs. Most slow suites are slow because every test rebuilds state through the UI.

Runner, fixtures and CI API testing with request contexts

Last refreshed 2026-09-18.