Skip to content

E2E With Playwright: Page Objects

When Playwright page objects pay off, and when they become a second UI framework that flaky tests hide behind.

5 min read
Playwright
E2E Testing
Page Objects
Frontend
Testing

Playwright still owns the journeys where fakes lie: auth cookies, real navigation, multi-tab flows, third-party redirects. Page objects help when they reduce flake and duplication. They hurt when they become a second UI framework nobody trusts.

The job of a page object

A page object should:

  1. Name a user-facing place: login screen, order detail, settings shell.
  2. Expose outcomes, not CSS selectors: submitValidCredentials(), not getByTestId('btn-primary-2') scattered through every spec.
  3. Stay thin: locators + a few composed actions. No business assertions that belong in the test.
ts
// e2e/pages/login.page.ts, illustrative import type { Page, Locator } from '@playwright/test'; export class LoginPage { readonly email: Locator; readonly password: Locator; readonly submit: Locator; constructor(private readonly page: Page) { this.email = page.getByLabel('Email'); this.password = page.getByLabel('Password'); this.submit = page.getByRole('button', { name: 'Sign in' }); } async goto() { await this.page.goto('/login'); } async signIn(email: string, password: string) { await this.email.fill(email); await this.password.fill(password); await this.submit.click(); } }
ts
// e2e/auth/login.spec.ts, illustrative import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; test('signs in and lands on dashboard', async ({ page }) => { const login = new LoginPage(page); await login.goto(); await login.signIn('ada@example.com', 'correct-horse'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); });

The assertion stays in the test. The page object knows how to sign in; the spec knows what success looks like.

When page objects pay off

Use them when:

  • The same journey setup repeats across specs (login, pick org, open project).
  • Accessible names are stable and you want one place to update a label change.
  • Multiple contributors would otherwise invent three selector dialects.

Skip them when:

  • The flow appears in one spec and will stay one.
  • You are still discovering the product surface, premature objects freeze bad names.
  • The "page object" is really a DSL for the whole app (god class with forty methods). That is a framework, not a helper.
SmellPrefer
Spec files full of raw getByTestId soupThin page object + roles/labels
One-off marketing page testInline locators in the spec
AppPage.clickThing().then().then() chainSmaller objects + explicit awaits in the test
Objects that assert toast copyMove expect to the spec

Locator policy (keep it boring)

I default to role + name, then label, then text, then test id as escape hatch. Page objects should encode that policy so specs do not renegotiate it per PR.

ts
// Prefer page.getByRole('button', { name: 'Save' }); // Escape hatch, document why in a comment at the locator page.getByTestId('legacy-chart-canvas');

If the only way to target a control is a test id, that is often a product a11y bug wearing a test costume. Fix the accessible name when you can; do not grow a parallel id taxonomy forever.

Shared chrome vs screen objects

Nav, toasts, and org switchers show up everywhere. I extract a small AppChrome helper for those, and keep screen objects focused on one place.

ts
// e2e/pages/app-chrome.ts, illustrative import type { Page, Locator } from '@playwright/test'; export class AppChrome { readonly status: Locator; constructor(private readonly page: Page) { this.status = page.getByRole('status'); } async openUserMenu() { await this.page.getByRole('button', { name: 'Account menu' }).click(); } }

Expose the toast locator; keep expect(...).toContainText in the spec. Feature assertions ("order cancelled") stay with the journey that cares, chrome only names shared UI.

Fixture composition: pass page in; do not reach for globals. Parallel workers will punish hidden shared state.

Failure modes

Page objects that hide waits. await this.submit.click() that silently retries for ten seconds teaches flakes to live in helpers. Prefer Playwright’s auto-waiting locators; avoid handmade sleep inside objects.

Second source of truth for routes. Hardcoding /settings/profile in five objects while the app uses a route helper is how renames break E2E only. Share path builders with the app when practical, or keep a single routes.ts for e2e.

Over-mocking inside E2E. If the page object stubs the network until nothing real remains, you rebuilt integration tests with a slower browser. Push that down to Vitest; keep Playwright for the seams fakes lie about.

God object. class App { everything() } will rot. Prefer one object per meaningful screen or shared chrome (nav, auth gate).

Refactoring the app to please the objects. If you add data-testid soup solely so a bloated page object can survive, stop, fix accessible names or shrink the object. E2E helpers serve the product, not the other way around.

Continuity

Auth flows without real IdP round trips are the natural follow-on: how to keep E2E honest when you cannot mint production sessions in CI.

Takeaway

Use Playwright page objects to name stable user-facing places and shared setup - not to build a second UI framework that buries waits, assertions, and flake.