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
NavBarcomponent 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" });| Approach | Good | Bad |
|---|---|---|
| Builder with overrides | Only the relevant field is visible | Hidden defaults drift unnoticed |
| Shared JSON fixtures | Reusable | One change breaks many tests |
| Random data | Finds boundary bugs | Non-reproducible failures |
| Unique per test | Safe in parallel | Needs cleanup |
| Seeded factory | Deterministic and unique | Extra 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- Tag by the reason someone would select a subset:
@smokefor the pull-request gate,@slowfor the nightly run. - Shard across CI machines by test count, and merge the reports so the failure view is still unified.
- Group with
test.describeto share setup and to make the report readable; nesting more than two levels is usually a sign the file should be split. - 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-reportFAQ
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.
Related
Runner, fixtures and CI API testing with request contexts
Last refreshed 2026-09-18.