Home / Blog / Playwright page object model
The Playwright page object model, and whether you still need one
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.
- Indirection that buys nothing.
signInPage.signIn(user, password)hides three lines that read perfectly well, and a reviewer now opens two files to learn what the test did. Good argument, and it gets stronger the smaller the suite is. - A method for every element.
clickSubmitButton(),enterUsername(),getErrorText()— a hand-written re-implementation of the locator API with worse names, no chaining and no filtering. Good argument. A class like that is a second API laid over one your team already knows. - Assertions buried in the page object. The spec ends up with no
expectin it, so the file no longer states what is being tested, and the failure points at the abstraction. Good argument, and the one we would settle first. BasePageinheritance. A base class everything extends, collecting helpers nobody can safely delete because nobody can prove who calls them. Good argument — and it is an argument about inheritance, not about page objects. Composition through fixtures gives you the shared setup without the ancestry.- A class per URL, in an application made of components. Your product is a header, a data grid and a modal that turn up on nine routes, and the abstraction is drawn along an axis the application does not have. Good argument, and the one that decides against page objects in most modern front ends.
- "Page objects are a Selenium hangover." Weak. It says where
the idea came from and nothing about whether it pays today. The same sentence retires
describeblocks.
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.
- A flow that appears in forty specs and changes once. Sign-in grows a second factor; one file changes. Strong, and nobody has answered it. Every other argument here is smaller than this one.
- The suite gets read by people who did not write it. A named flow is vocabulary a team can share, and it is what makes a spec skimmable by a developer who does not work in the test repository. Strong as the team grows, and close to worthless at three specs. Both halves are true, and the second is why nobody should start here.
- Review diffs stay legible. A structural change lands in one file instead of scattering across forty. Good.
- It survives a design-system rename. Every label in the product changes and one layer takes the hit. Good — and it is the first argument again with a different trigger, so count it once when you are weighing this up.
- "It is the industry standard." Weak. A count of who does something is not a reason to do it, and this is the same move as the Selenium-hangover line pointed the other way.
- "It separates test logic from page logic." Weak. Ask which line of your spec is test logic and which is page logic. The distinction survives about two examples.
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.
- Move
expectout 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. - Delete single-action wrappers and expose the locator.
clickSignIn()becomessignInPage.submit, and the spec readsawait signInPage.submit.click(), which no reviewer has to look up. - 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. - Move construction into fixtures.
newandbeforeEachcome out of the specs, and the test signature starts declaring what the test needs. - 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.