Skip to content

Blog / Playwright

Playwright

Playwright Page Object Model: When It Helps, When It Hurts, and How to Wire It with Fixtures

A small, honest page object model for Playwright, wired in with fixtures, and the patterns that make page objects worse than none.

October 23, 2026 · Blendra Team · 12 min read

A Playwright page object model is a trade: you add a layer of classes so that a selector change touches one file instead of forty tests. That trade pays off when many tests drive the same screens. It costs you when the classes grow into a second application that hides waits and assertions from the people reading the tests. This guide builds a small page object model in TypeScript, injects it with test.extend fixtures, and shows where the pattern goes wrong.

Every snippet below comes from one small project that I ran with Playwright 1.61 against a local demo shop: 5 tests, all passing, and 50 out of 50 passing with --repeat-each 10.

Key takeaways

  • Page objects own selectors and intent. They hold readonly locators and methods named after what a user does: signIn(), addToCart().
  • Fixtures own creation. test.extend builds each page object and hands it to the test as an argument. No new LoginPage(page) in test files.
  • Tests own assertions. Keep expect in the spec by default. A shared, named check in a page object is fine if it is web-first and the team agrees on it.
  • Return locators, not values. A method that returns await locator.textContent() takes a snapshot and turns a retrying assertion into a race.
  • Skip page objects for small suites, one-off flows and API tests. A plain test is easier to read than an abstraction with one caller.

What a page object is for

A page object answers one question for the rest of the suite: "how do I find and operate this part of the UI?" It does not decide whether the result is correct. That split gives you three concrete wins:

  1. One place to fix a selector. When the sign-in button's label changes, you edit one line in login-page.ts.
  2. Tests that read like the test case. await loginPage.signIn(email, password) says what happens. Three fill and click calls say how.
  3. Shared vocabulary. The method names become the words your team uses in reviews and bug reports.

Martin Fowler's original description of the pattern (2013) makes two points that still hold. Page objects should model what a user perceives, not the DOM tree, so they often map to parts of a page rather than whole pages. And the test should do the checking, with room for checks of page-wide invariants.

Playwright's own page object guide is less strict. Its getStarted() example clicks a link and then calls expect(...).toBeVisible() inside the page object. Both views exist in the wild, which is why this post gives the assertion question its own section.

Responsibilities of tests, fixtures and page objects in a Playwright suite

A minimal Playwright page object model in TypeScript

Here is the whole login page object. It has four readonly locators and two methods.

// pages/login-page.ts
import type { Locator, Page } from '@playwright/test';

export class LoginPage {
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.email = page.getByLabel('Email');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
    this.error = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login.html');
  }

  async signIn(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}

Four details matter more than the class itself:

  • Locators are created in the constructor and never awaited. A Playwright Locator is a lazy description. It finds the element only when you act on it or assert on it, so creating it early costs nothing and never goes stale.
  • The locators are public. Tests need them for assertions (expect(loginPage.error).toHaveText(...)). Hiding them behind getters that return strings breaks auto-retrying, as you will see below.
  • Methods are intent-level. signIn() is one user intention. There is no fillEmail() plus fillPassword() plus clickSubmit() trio for tests to stitch together. You can still reach the parts through the locators when a test needs to type only an email.
  • Selectors are role and label first. getByRole and getByLabel survive styling changes and match what a screen reader sees. The ranked guide to Playwright locators covers when to fall back to a test id.

If your page objects came from a recorder, they usually start as one method per click. The Playwright codegen guide shows how to fold recorded steps into methods like signIn().

Inject page objects with fixtures (test.extend)

You could write const loginPage = new LoginPage(page) at the top of every test. It works, and it is how the official page object guide does it. The problem shows up later: every test repeats the wiring, and any setup the page needs (sign in first, seed a cart) gets copied around.

Playwright's fixtures solve this. test.extend returns a new test function whose callback can ask for your page objects by name.

// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { ProductsPage } from './pages/products-page';
import { ProductPage } from './pages/product-page';

type ShopFixtures = {
  loginPage: LoginPage;
  productsPage: ProductsPage;
  productPage: ProductPage;
  /** A signed-in shopper. Request it when the test is not about signing in. */
  signedIn: void;
};

export const shopper = {
  email: process.env.SHOP_USER ?? 'shopper@example.test',
  password: process.env.SHOP_PASSWORD ?? 'demo-password',
};

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

  signedIn: async ({ page, loginPage }, use) => {
    await loginPage.goto();
    await loginPage.signIn(shopper.email, shopper.password);
    await page.waitForURL('**/products.html');
    await use();
  },

  productsPage: async ({ page, signedIn }, use) => {
    await use(new ProductsPage(page));
  },

  productPage: async ({ page, signedIn }, use) => {
    await use(new ProductPage(page));
  },
});

export { expect };

What this buys you:

  • Fixtures are lazy. Playwright sets up a fixture only when a test, a hook or another fixture asks for it. A login test that asks for loginPage never runs the signedIn setup.
  • Fixtures compose. productsPage depends on signedIn, which depends on loginPage. Ask for productsPage and Playwright builds the chain in order.
  • Setup and teardown live together. Code before await use(...) is setup; code after it is teardown. If a fixture created data through an API, you would delete it after use.
  • Tests import one module. Specs import test and expect from ./fixtures, never from @playwright/test and never from ./pages.

Signing in through the form in a fixture keeps this example self-contained. In a real suite, sign in once and reuse the saved session with storageState, as the Playwright authentication guide explains. The page object fixtures do not change when you switch.

Now the specs read as intent plus assertions:

// tests/login.spec.ts
import { test, expect, shopper } from '../fixtures';

test('a shopper signs in and lands on the products page', async ({ page, loginPage }) => {
  await loginPage.goto();
  await loginPage.signIn(shopper.email, shopper.password);

  await expect(page).toHaveURL(/\/products\.html$/);
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

test('a wrong password shows an error and stays on sign in', async ({ page, loginPage }) => {
  await loginPage.goto();
  await loginPage.signIn(shopper.email, 'not-the-password');

  await expect(loginPage.error).toHaveText('Invalid email or password');
  await expect(page).toHaveURL(/\/login\.html$/);
});

The demo's sign-in button waits 400 ms before it navigates, like a real login request. No test needs a manual wait for it: toHaveURL and toHaveText retry until they pass or time out. The Playwright assertions guide covers which matchers retry and which do not.

Two more fixture features help as a suite grows. mergeTests() combines fixtures from separate modules, so a team can keep shopFixtures and adminFixtures apart and merge them where a test needs both. Worker-scoped fixtures ({ scope: 'worker' }) run once per worker process, which suits expensive shared resources. Keep page objects test-scoped, though: each test gets its own page, so its page objects must be new too.

Should page objects assert? Both sides

This is the most argued question about the pattern. Here are both positions, then a rule you can adopt.

Keep assertions in tests (the default here).

  • A reader sees what the test proves without opening another file.
  • A page object method has one job. signIn() that also asserts the products page loaded cannot be reused by the "wrong password" test.
  • When a check fails, the report's code frame points at the line in the spec, which is where a reviewer looks first.

Allow named checks in page objects.

  • Some checks repeat in dozens of tests ("the cart badge shows N"), and one shared method keeps their wording and timeout identical.
  • Playwright's own example puts an expect inside a page object method, so the team will meet the pattern anyway.

If you allow them, keep them web-first and give them a custom message. This fragment adds a check to a cart component (it needs expect imported from @playwright/test):

// Fragment: an optional named check inside a component object
async expectCount(count: number) {
  await expect(this.button, `cart badge shows ${count}`).toHaveText(`Cart (${count})`);
}

When it fails, Playwright prints your message first, then the locator, the expected and received values, and a call log:

Error: cart badge shows 3

expect(locator).toHaveText(expected) failed

Locator:  getByRole('navigation', { name: 'Account' }).getByRole('button', { name: /^Cart/ })
Expected: "Cart (3)"
Received: "Cart (1)"
Timeout:  5000ms

The code frame under that error points at the line inside the component, not the test. The test's line appears one entry lower in the stack. That is the real cost of the second style: the reader has to follow one more hop.

A workable rule: actions never assert. A page object may expose a separate expect... method for a check that many tests share. One-off checks stay in the spec.

Component objects for headers, modals and tables

Pages are not the only unit worth modelling. A cart drawer, a site header, a date picker or a data table often appears on many pages. Model it once as a component object and compose it into each page object.

// components/cart-drawer.ts
import type { Locator, Page } from '@playwright/test';

/** The cart button in the header and the drawer it opens. Used on every shop page. */
export class CartDrawer {
  readonly button: Locator;
  readonly dialog: Locator;
  readonly items: Locator;
  readonly checkoutLink: Locator;

  constructor(page: Page) {
    this.button = page.getByRole('navigation', { name: 'Account' }).getByRole('button', { name: /^Cart/ });
    this.dialog = page.getByRole('dialog', { name: 'Your cart' });
    this.items = this.dialog.getByRole('list', { name: 'Cart items' }).getByRole('listitem');
    this.checkoutLink = this.dialog.getByRole('link', { name: 'Go to checkout' });
  }

  async open() {
    await this.button.click();
  }

  async remove(productName: string) {
    await this.dialog.getByRole('button', { name: `Remove ${productName}` }).click();
  }

  async checkout() {
    await this.checkoutLink.click();
  }
}

Every locator inside the drawer is chained from this.dialog. That scoping is the point of a component object: "Remove Blue mug" inside the drawer can never match a button elsewhere on the page. For a component that repeats on one page, such as table rows, pass a root Locator to the constructor instead of the Page.

Two page objects reuse it:

// pages/products-page.ts
import type { Locator, Page } from '@playwright/test';
import { CartDrawer } from '../components/cart-drawer';

export class ProductsPage {
  readonly heading: Locator;
  readonly cart: CartDrawer;

  constructor(private readonly page: Page) {
    this.heading = page.getByRole('heading', { name: 'Products', level: 1 });
    this.cart = new CartDrawer(page);
  }

  async goto() {
    await this.page.goto('/products.html');
  }

  /** One row of the product list, found by the product's visible name. */
  product(name: string): Locator {
    return this.page.getByRole('list', { name: 'Products' }).getByRole('listitem').filter({ hasText: name });
  }

  async addToCart(name: string) {
    await this.product(name).getByRole('button', { name: 'Add to cart' }).click();
  }
}
// pages/product-page.ts
import type { Locator, Page } from '@playwright/test';
import { CartDrawer } from '../components/cart-drawer';

export class ProductPage {
  readonly quantity: Locator;
  readonly addButton: Locator;
  readonly cart: CartDrawer;

  constructor(private readonly page: Page) {
    this.quantity = page.getByLabel('Quantity');
    this.addButton = page.getByRole('button', { name: 'Add to cart' });
    this.cart = new CartDrawer(page);
  }

  async goto(sku: string) {
    await this.page.goto(`/product.html?sku=${encodeURIComponent(sku)}`);
  }

  async addToCart(quantity = 1) {
    await this.quantity.fill(String(quantity));
    await this.addButton.click();
  }
}

Notice product(name) returns a Locator, not a row's text. A test can click inside it, assert on it, or count it. And the tests that use both pages:

// tests/cart.spec.ts
import { test, expect } from '../fixtures';

test('adding from the list updates the cart', async ({ productsPage }) => {
  await productsPage.goto();
  await productsPage.addToCart('Blue mug');

  await expect(productsPage.cart.button).toHaveText('Cart (1)');
  await productsPage.cart.open();
  await expect(productsPage.cart.items).toHaveText([/Blue mug × 1/]);
});

test('the same cart drawer works on a product page', async ({ productPage, page }) => {
  await productPage.goto('bottle');
  await productPage.addToCart(2);

  await expect(productPage.cart.button).toHaveText('Cart (2)');
  await productPage.cart.open();
  await productPage.cart.checkout();

  await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
  await expect(page.getByText('2 items to pay for')).toBeVisible();
});

test('removing the last item empties the cart', async ({ productsPage }) => {
  await productsPage.goto();
  await productsPage.addToCart('Green notebook');
  await expect(productsPage.cart.button).toHaveText('Cart (1)');

  await productsPage.cart.open();
  await productsPage.cart.remove('Green notebook');

  await expect(productsPage.cart.items).toHaveCount(0);
  await expect(productsPage.cart.dialog.getByText('Your cart is empty')).toBeVisible();
});

The config starts the demo server and sets baseURL, which is why goto('/login.html') works:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: 'list',
  use: {
    baseURL: 'http://127.0.0.1:4180',
    trace: 'on-first-retry',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'node server.mjs',
    url: 'http://127.0.0.1:4180/login.html',
    reuseExistingServer: !process.env.CI,
  },
});

server.mjs is a small, dependency-free static file server for a demo shop with a login page, a product list, a product page and a checkout page. Point baseURL and webServer at your own app instead. Running npx playwright test gives:

Running 5 tests using 5 workers

  ✓  3 [chromium] › tests/cart.spec.ts:3:1 › adding from the list updates the cart (960ms)
  ✓  1 [chromium] › tests/login.spec.ts:11:1 › a wrong password shows an error and stays on sign in (989ms)
  ✓  5 [chromium] › tests/login.spec.ts:3:1 › a shopper signs in and lands on the products page (978ms)
  ✓  2 [chromium] › tests/cart.spec.ts:24:1 › removing the last item empties the cart (1.0s)
  ✓  4 [chromium] › tests/cart.spec.ts:12:1 › the same cart drawer works on a product page (1.0s)

  5 passed (1.8s)

Anti-patterns that make page objects worse than none

Getters that return values instead of locators

This is the most common way a page object introduces flakiness. The demo's cart badge updates 250 ms after a click, like a badge waiting for an API response. This fragment reads the badge once:

// Fragment: anti-pattern, do not copy
async cartCount(): Promise<number> {
  const text = await this.page.getByRole('button', { name: /^Cart/ }).textContent();
  return Number(text?.match(/\d+/)?.[0] ?? 0);
}

// in the test
expect(await productsPage.cartCount()).toBe(1);

It fails immediately, with nothing to retry:

Error: expect(received).toBe(expected) // Object.is equality

Expected: 1
Received: 0

textContent() returns whatever the DOM holds at that instant, and expect(value).toBe() is a plain value check. Return the Locator and write await expect(productsPage.cart.button).toHaveText('Cart (1)'), which waits for the badge to update. When the app answers faster, the same bug passes on your laptop and fails on a slower CI runner. The guide to flaky Playwright tests covers this class of failure in depth.

Hidden waits

// Fragment: anti-pattern, do not copy
async addToCart(name: string) {
  await this.product(name).getByRole('button', { name: 'Add to cart' }).click();
  await this.page.waitForTimeout(1000); // "the badge is slow"
}

Every caller now pays one second, even the tests that never look at the badge. When the badge gets slower than a second, the wait stops helping and nobody remembers it is there. Actions already wait for the element to be actionable. Wait for an outcome with a web-first assertion in the test, or with waitForResponse when the outcome is a network call. The auto-waiting guide explains what Playwright already waits for.

God objects

One AppPage class with 80 methods for every screen is a page object in name only. Nobody can find anything, and every change conflicts in code review. Split by what the user perceives: one class per page or per component, each small enough to read in one screen.

Assertions buried in actions

signIn() that also checks "Welcome back" works for the happy path and breaks the moment you write the wrong-password test. Keep actions free of checks, as described above.

Inheritance chains

BasePage → AuthenticatedPage → ShopPage → ProductsPage looks tidy until one page needs half of a parent. Composition (a page has a CartDrawer) bends better than inheritance (a page is a ShopPage). A small BasePage with goto() is fine; four levels are not.

Test data inside page objects

A page object that knows shopper@example.test ties every test to one account. Pass data in as arguments. Keep users and fixtures in a data module or in fixtures, where tests can override them.

Plain tests, page objects, or fixtures plus page objects

Plain tests Page objects, built in each test Fixtures + page objects
Where selectors live In every test In page classes In page classes
Wiring code in specs None new LoginPage(page) per test None: tests ask for loginPage
Shared setup (sign-in, seeded data) Copied or in beforeEach Copied or in beforeEach In the fixture, with teardown after use
Cost of a selector change Every test that uses it One line One line
Extra concepts to learn None Classes Classes and fixtures
Good fit A handful of tests, spikes, API tests Mid-size suites, teams new to fixtures Growing suites, several teams, shared setup

When to skip page objects

Page objects are not free. Skip them, or wait, when:

  • The suite is small. Ten tests that touch a screen once each gain little from a class. Extract a page object the second or third time you copy the same locators.
  • The test is an API test. There is no UI to model. Use the request fixture and plain helpers.
  • The flow is one-off. A migration check that runs once next week does not need an abstraction.
  • The locators are already clear. page.getByRole('button', { name: 'Sign in' }) is as readable as loginPage.submit. The page object earns its place through reuse, not through readability alone.

Start with plain tests and extract when duplication appears. That is cheaper than deleting an abstraction nobody uses.

How a generated suite organises page objects (Blendra)

If you record flows with Blendra and export a Playwright TypeScript project, you get this same split without writing it by hand. The export holds a page object under pages/ for each page in the page library that your exported journeys use, keeps each page's selectors in its own file under locators/, and writes fixtures/base.ts, which exposes every page object as a fixture through test.extend. Specs import test from that file and receive page objects as arguments. See the generated project structure for the full tree.

Two differences from the hand-written version above are worth knowing. The generated page objects contain no assertions and no test data; checks live in the specs. And their methods are action-level, one per action in the page library (for example fillEmail and clickSignIn), rather than one signIn() per intention. Rename pages and actions in the page library and the next export uses the new names for classes and methods (Page objects, Manage locators). The Blendra pricing page lists what each plan includes.

FAQ

Page objects or fixtures: which should I use?

Both, for different jobs. Page objects describe how to operate a part of the UI. Fixtures decide when objects are created, what setup runs first, and what is cleaned up after. A fixture with no page object is still useful (a seeded user, an API client). A page object with no fixture works too, but every test repeats the wiring.

Where do test ids live in a page object model?

In the page or component object, as locators: this.checkout = page.getByTestId('checkout-button'). Tests should not mention them. Prefer roles and labels where the UI exposes them, and keep test ids for elements without a stable accessible name. The data-testid best practices guide covers naming and ownership.

How big should a page object be?

Small enough to read on one screen. A useful signal: if a class has methods that no test calls, or if two teams keep editing it for unrelated reasons, split it. Component objects are usually the right cut for headers, drawers, modals and tables.

Should a page object method return the next page object?

It can. Fowler's article suggests it, and it makes chained flows type-safe. With fixtures, most tests already ask for every page object they need, so returning the next page is less useful than it is with hand-built objects. Pick one style per suite.

Does the same pattern work in Playwright for Python?

Yes. Classes hold locators and actions, and pytest fixtures in conftest.py take the role of test.extend. The Playwright Python page object guide walks through it.

FixturesPage objectsPlaywrightTypeScript

Start with the flow you test by hand every release.

Start free →