Home / Blog / Playwright page object model

The Playwright page object model, and whether you still need one

Quick answer

The page object model still works in Playwright, and it is no longer the default. Locators already solve the fragility page objects were invented to contain, so what a page object buys you now is a shared name for a flow that several specs repeat. Build one when that happens, and deliver it through a fixture instead of a beforeEach.

Someone on your team has read that page objects are a Selenium hangover. Someone else maintains four hundred specs built on them and is not about to rewrite those. Both can point at something true, and they are answering different questions.

What is a page object in Playwright?

A class that owns the locators for one part of your application and exposes methods for the flows that happen there. The specs call the methods; the locators live in one file.

The code here is Playwright 1.62, and each block is a whole file that ran in Chrome before it went on the page. This is the shape:

import { type Locator, type Page } from '@playwright/test';

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

  constructor(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 signIn(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}
Playwright 1.62 · TypeScript · sign-in-page.ts

Four locators built once in the constructor, one method for the flow, and no base class. The locators are readonly because nothing should reassign them after construction.

The class in Playwright's own documentation differs from this one in a way you can check yourself. That example builds its locators with page.locator('a', { hasText: 'Get started' }) and page.locator('article div.markdown ul > li > a'), while the best-practices guide on the same site carries a section headed Prefer user-facing attributes to XPath or CSS selectors. The class above uses getByLabel and getByRole for that reason.

What does Playwright already do that page objects used to do?

A locator is a query the framework runs again on every use. Playwright's locator documentation puts it this way: "Every time a locator is used for an action, an up-to-date DOM element is located in the page." Building a locator in a constructor costs nothing until a test uses it, and it is resolved again on every use after that.

Selenium works the other way round, and its own error guide says so. On the page it labels v4.0, that guide describes StaleElementReferenceException like this: "Elements do not get relocated automatically; the driver creates a reference ID for the element and has a particular place it expects to find it in the DOM." Its suggested repair is to wrap the element in an object that stores the locator and re-finds it when the cached reference goes stale. A Playwright Locator is that wrapper, shipped in the framework. A page object whose job was holding element references and re-finding them has had that job taken.

Locators wait by themselves. Delete the waitForElement helper that a Selenium page object grows rather than porting it. Playwright checks actionability before it acts; which timeout fired when one runs out is a separate question.

Locators are strict. The same documentation: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." A findElement-style page object silently acted on the first match. This one throws, which moves the ambiguity into the pull request. Choosing between the seven built-in locators is a decision with its own rules.

Three things drove the pattern originally: holding element references so they could be re-found, hiding the waiting, and naming. Two of them are now done by the framework. Naming is left, and naming is a reason worth arguing about.

What is the strongest case against page objects?

Each argument below carries its verdict beside it.

The first five are reasons to write less of the pattern, and none of them is a reason to delete the pattern. The wider set of habits worth keeping in a suite is elsewhere.

What is the strongest case for keeping them?

Argued the same way, weak arguments included.

Page object model vs fixtures: what is the alternative made of?

The alternative is a module of functions that take a Page and return a Locator or run a flow, plus a fixture that hands the test what it needs. Prose cannot settle whether that is better, so below is one test written three ways, and all three run.

They run against a sign-in form mounted with setContent, so there is no server to start and you can paste any of this into an empty project:

import { type Page } from '@playwright/test';

const MARKUP = `
  <form id="form">
    <label for="email">Email</label>
    <input id="email" type="email">
    <label for="password">Password</label>
    <input id="password" type="password">
    <button type="submit">Sign in</button>
  </form>
  <p id="error" role="alert" hidden></p>
  <h1 id="welcome" hidden></h1>
  <script>
    document.getElementById('form').addEventListener('submit', event => {
      event.preventDefault();
      const email = document.getElementById('email').value;
      const signedIn = document.getElementById('password').value === 'correct-horse';
      document.getElementById('form').hidden = signedIn;
      document.getElementById('error').hidden = signedIn;
      document.getElementById('welcome').hidden = !signedIn;
      document.getElementById('error').textContent = 'Email or password is incorrect';
      document.getElementById('welcome').textContent = 'Welcome, ' + email;
    });
  </script>`;

export async function mountSignIn(page: Page) {
  await page.setContent(MARKUP);
}
Playwright 1.62 · TypeScript · sign-in-app.ts

One: inline

import { test, expect } from '@playwright/test';
import { mountSignIn } from './sign-in-app';

test('a wrong password is rejected', async ({ page }) => {
  await mountSignIn(page);
  await page.getByLabel('Email').fill('ada@example.com');
  await page.getByLabel('Password').fill('hunter2');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('alert')).toHaveText('Email or password is incorrect');
});
Playwright 1.62 · TypeScript · passes

Two: through a page object class

import { test, expect } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { SignInPage } from './sign-in-page';

test('a wrong password is rejected', async ({ page }) => {
  await mountSignIn(page);
  const signInPage = new SignInPage(page);
  await signInPage.signIn('ada@example.com', 'hunter2');
  await expect(signInPage.error).toHaveText('Email or password is incorrect');
});
Playwright 1.62 · TypeScript · passes

Three: fixtures, helper functions and typed locators, with no class

import { type Locator, type Page } from '@playwright/test';

export function errorBanner(page: Page): Locator {
  return page.getByRole('alert');
}

export async function signIn(page: Page, email: string, password: string) {
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
}
Playwright 1.62 · TypeScript · sign-in-helpers.ts
import { test as base, expect, type Page } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { errorBanner, signIn } from './sign-in-helpers';

const test = base.extend<{ signInScreen: Page }>({
  signInScreen: async ({ page }, use) => {
    await mountSignIn(page);
    await use(page);
  },
});

test('a wrong password is rejected', async ({ signInScreen }) => {
  await signIn(signInScreen, 'ada@example.com', 'hunter2');
  await expect(errorBanner(signInScreen)).toHaveText('Email or password is incorrect');
});
Playwright 1.62 · TypeScript · passes

The inline version is the shortest and the easiest to review, and it is the one to keep until a second spec wants the same four lines. The class version moves those lines behind an import and a new; at one spec that is a net loss, at forty it is the only version that survives a change to the form. The third costs one module and one fixture, is typed against Page throughout, and gives a BasePage nothing to attach itself to.

The framing that puts these in opposition also comes apart on Playwright's own documentation. Its fixtures guide introduces the central example by saying that two fixtures, todoPage and settingsPage, follow the Page Object Model pattern, and the fixture body builds the page object before passing it to whichever test asks for it. The documentation page for page objects builds no fixture at all; the documentation page for fixtures builds page objects. Written that way, the same test looks like this:

import { test as base, expect } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { SignInPage } from './sign-in-page';

const test = base.extend<{ signInPage: SignInPage }>({
  signInPage: async ({ page }, use) => {
    await mountSignIn(page);
    await use(new SignInPage(page));
  },
});

test('a wrong password is rejected', async ({ signInPage }) => {
  await signInPage.signIn('ada@example.com', 'hunter2');
  await expect(signInPage.error).toHaveText('Email or password is incorrect');
});
Playwright 1.62 · TypeScript · passes

A fixture decides what a test is handed before its first line runs. A page object decides the shape of the thing it is handed. Those are different jobs, which is why one can deliver the other, and why you settle what belongs in a fixture before choosing either. The contest worth having is about how much of your application any one abstraction is allowed to cover: how many methods it carries, how much of a flow it hides, and whether the spec still says what is being tested.

What do we build on client work?

Firm86 builds Playwright repositories for a living, so this is the section where you are entitled to a house answer with a reason attached. We have not published one, and inventing one here would be worth less than saying so.

Two things about it are settled: where the decision gets made, and what happens to it afterwards. On a framework build the structure is chosen against the application in the first phase and then written into the conventions document that ships with the repository, which names the home of a new spec and draws the line between a fixture and a helper. A convention that was never written down lasts until the third engineer joins.

In the meantime we stand behind two arguments rather than claims about our own company: assertions belong in the spec, and setup belongs in a fixture. Anything past those two is a judgement about one application, and it is worth more made against your repository than made in general.

When this bites you

The trace stops naming what happened. A test that fails three calls inside signIn() reports the failure at a line in the page object, and the trace shows a flat run of actions with nothing saying which flow they belonged to. test.step, which Playwright's API reference records as added in v1.10, puts the name back into the report:

import { test, expect } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { SignInPage } from './sign-in-page';

test('a wrong password is rejected', async ({ page }) => {
  await mountSignIn(page);
  const signInPage = new SignInPage(page);

  await test.step('sign in as ada@example.com with a stale password', async () => {
    await signInPage.signIn('ada@example.com', 'hunter2');
  });

  await expect(signInPage.error).toHaveText('Email or password is incorrect');
});
Playwright 1.62 · TypeScript · passes

The report now shows one collapsed row named sign in as ada@example.com with a stale password with the failing action inside it, so whoever reads the run at 2am knows which flow died before opening anything.

A page object that holds state. A field assigned from an earlier action, an order number read after checkout or a row index, is correct once and wrong on the second use. Locators are safe to cache in a constructor because they are resolved again on every use. Values read out of the page are not, and the failure arrives as a mismatch in a completely different test.

A page object that returns another page object. signInPage.submit() returning a DashboardPage reads elegantly and puts every navigation failure in the wrong file: the stack points into the constructor of the page you were going to, not at the click that never landed.

A constructor that does work. Building locators in a constructor is safe; anything else in there is not, because a constructor cannot await. A goto, a waitFor or a fetch of test data will run un-awaited or force the constructor to hand back a promise. Whatever has to happen before the test starts belongs in the fixture that builds the object, and this is the checkable difference between a page object written for Playwright and one ported into it.

You already have four hundred specs. What now?

Take a rewrite off the table first. A suite that runs, that your team understands and that catches regressions does not need modernising, and moving four hundred specs costs a quarter you could spend on coverage you do not have. Doing nothing is a legitimate answer here.

If you do want to move, the sequence below is in the order it pays, and stopping after the first item is a reasonable outcome.

  1. Move expect out of the page objects and into the specs. Cheapest change, largest gain in legibility, and it goes one file at a time with nobody's agreement needed. Your spec files start stating what they check again.
  2. Delete single-action wrappers and expose the locator. clickSignIn() becomes signInPage.submit, and the spec reads await signInPage.submit.click(), which no reviewer has to look up.
  3. Stop adding to BasePage. Take the next helper somebody wants to put there and make it a function in a module. The base class stops growing on the day you decide it does, and that is a smaller job than deleting it.
  4. Move construction into fixtures. new and beforeEach come out of the specs, and the test signature starts declaring what the test needs.
  5. Leave the rest alone.

Steps one and two on a single file look like this. Here is the page object that hides the test — four wrappers around single actions and an assertion living inside a method:

import { expect, type Page } from '@playwright/test';

export class LoginPage {
  constructor(private readonly page: Page) {}

  async enterEmail(email: string) {
    await this.page.getByLabel('Email').fill(email);
  }

  async enterPassword(password: string) {
    await this.page.getByLabel('Password').fill(password);
  }

  async clickSignIn() {
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }

  async assertSignedIn(email: string) {
    await expect(this.page.getByRole('heading', { name: `Welcome, ${email}` })).toBeVisible();
  }
}
Playwright 1.62 · TypeScript · login-page.ts
import { test } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { LoginPage } from './login-page';

test('a correct password signs the user in', async ({ page }) => {
  await mountSignIn(page);
  const loginPage = new LoginPage(page);
  await loginPage.enterEmail('ada@example.com');
  await loginPage.enterPassword('correct-horse');
  await loginPage.clickSignIn();
  await loginPage.assertSignedIn('ada@example.com');
});
Playwright 1.62 · TypeScript · passes

Read the import line. There is no expect in the spec, so the file that is supposed to say what is being checked has delegated that too. Put the assertion back, expose the locators, and you are left with the SignInPage class and this spec:

import { test, expect } from '@playwright/test';
import { mountSignIn } from './sign-in-app';
import { SignInPage } from './sign-in-page';

test('a correct password signs the user in', async ({ page }) => {
  await mountSignIn(page);
  const signInPage = new SignInPage(page);
  await signInPage.signIn('ada@example.com', 'correct-horse');
  await expect(page.getByRole('heading', { name: 'Welcome, ada@example.com' })).toBeVisible();
});
Playwright 1.62 · TypeScript · passes

Four methods went, one stayed, and the spec now names the thing it is checking. That edit is available on any file in your suite this afternoon, and it does not commit you to the rest of the list.

Questions

Is the page object model dead in Playwright?

No, and it is also no longer the default. Page objects still do the thing they were always best at, which is giving a flow that several specs repeat one name and one place to change. What they no longer do is protect you from stale element references or from waiting, because locators handle both, so a class that exists for those two reasons is carrying weight for nothing.

Page object model or fixtures — which should I use?

They answer different questions and they compose. A fixture decides what a test is handed before its first line runs; a page object decides the shape of the thing it is handed. Playwright's own fixtures documentation constructs page objects inside fixture bodies, which is the plainest evidence available that the either-or framing is argued on the wrong axis. What a fixture is for, in depth.

Do I need a page object for a suite of twenty tests?

Usually not. Twenty specs that each drive a form directly are readable by anyone who opens one, and the abstraction is paid for on the day you write it while the benefit arrives later, if at all. The trigger is repetition rather than size: the day the same six lines turn up in a fourth spec, give them a name.

Should assertions live inside page objects?

Put them in the spec. An assertion inside a page object method takes the statement of what is being tested out of the file whose name says what is being tested, and it makes the failure point at your abstraction instead of at the behaviour that broke. Expose the locator and let the spec write the expect.

We have four hundred specs on page objects. Do we have to change anything?

No. A suite that runs, that your team understands and that catches regressions is not a problem waiting to be solved, and a rewrite costs a quarter you could spend on coverage you do not have. If you want one change, move the assertions out of the page objects and into the specs. It is cheap, it goes one file at a time, and it gives you back the ability to read a spec and know what it checks.

What shape is the suite you already have?

How many specs, and whether there is a page object layer under them. With those, a first conversation can be about your repository: whether the structure is what hurts, or whether the structure is fine and something else is failing. Paste the directory listing of your tests/ folder if you have it to hand.