Page Object Model in Playwright
On this page
Playwright changes two things about page objects. Its locators are lazy, so a page object can hold them as plain fields without any risk of stale elements. And its test runner has fixtures, which let you inject ready-made page objects into tests instead of constructing them by hand. This chapter rebuilds the login suite in TypeScript and uses both.
Setup#
npm init playwright@latest
Accept TypeScript and a tests folder. The installer adds @playwright/test (1.65.0 at the time of writing) and @types/node. Then point the config at the practice site:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'https://practicetestautomation.com',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
With baseURL set, page.goto('/practice-test-login/') resolves against it, which is what makes the environment switch in chapter 05 a one-line change.
The shape Playwright recommends#
The Page object models guide on playwright.dev uses one consistent shape: the class takes a Page, creates every Locator once in the constructor, stores them as readonly fields, and exposes async methods for user actions. Here is our login page in that shape:
// pages/login-page.ts
import { type Locator, type Page } from '@playwright/test';
import { LoggedInPage } from './logged-in-page';
export class LoginPage {
readonly username: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
constructor(private readonly page: Page) {
this.username = page.getByLabel('Username');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Submit' });
// #error is in the DOM from the start (opacity 0); .show is added on failure
this.error = page.locator('#error.show');
}
async goto() {
await this.page.goto('/practice-test-login/');
}
async loginAs(user: string, pass: string): Promise<LoggedInPage> {
await this.username.fill(user);
await this.password.fill(pass);
await this.submit.click();
return new LoggedInPage(this.page);
}
}
// pages/logged-in-page.ts
import { type Locator, type Page } from '@playwright/test';
import { LoginPage } from './login-page';
export class LoggedInPage {
readonly heading: Locator;
readonly message: Locator;
readonly logOutLink: Locator;
constructor(private readonly page: Page) {
this.heading = page.getByRole('heading', { level: 1 });
this.message = page.locator('.post-content strong');
this.logOutLink = page.getByRole('link', { name: 'Log out' });
}
async logOut(): Promise<LoginPage> {
await this.logOutLink.click();
return new LoginPage(this.page);
}
}
Compare this with the Selenium version from chapter 01:
-
No base class, no waits. Playwright's actions wait for the element to be attached, visible, stable, enabled and able to receive events before acting. The
BasePagewithvisible()andclick()helpers has nothing left to do. -
Locators are public. In Selenium we returned strings so tests could not poke at
WebElements. In Playwright, exposingLocators is the norm, because tests need them for web-first assertions such asawait expect(loginPage.error).toHaveText(...), which retry until they pass or time out. A locator is a query, not a handle, so exposing it does not leak page internals the way a cached element would. -
User-facing locators.
getByLabel('Username')andgetByRole('button', { name: 'Submit' })describe what the user sees. They survive markup refactors that would break#submit, and they fail when the accessible name changes, which is usually a real bug.
The Selenium page objects returned strings, but the Playwright ones expose Locator fields. Why is that acceptable?
A Locator is not a handle to a cached element, so exposing it leaks nothing that can go stale. Returning strings would force one-shot reads and lose toHaveText's retrying.
A trap that page objects help you avoid#
Why #error.show and not just #error? Because this test passes without ever logging in:
import { test, expect } from '@playwright/test';
test('this passes, and it should not', async ({ page }) => {
await page.goto('/practice-test-login/');
await expect(page.locator('#error')).toHaveText('Your username is invalid!');
await expect(page.locator('#error')).toBeVisible();
});
The error box is in the DOM from page load, with that text, hidden only by opacity: 0. Playwright's actionability docs define visible as a non-empty bounding box without visibility:hidden, and state outright that elements with opacity:0 are considered visible, so toBeVisible() passes. Selenium's isDisplayed() returns false for the same element. We ran both against the live page while writing this chapter.
The fix is to encode "the error is actually showing" once, in the page object, as #error.show. Every test that asserts on loginPage.error gets the correct meaning for free. This is the strongest argument for page objects in Playwright: not saving keystrokes, but putting hard-won knowledge about a page in exactly one place.
Injecting page objects with fixtures#
You can construct page objects inside each test:
test('wrong password', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
// ...
});
That works, and it is what the playwright.dev POM guide shows. Once you have a dozen page objects, it turns into the same setup boilerplate POM was meant to remove. Playwright's fixtures solve it: extend the base test with a fixture that builds the page object, and tests just ask for it by name.
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
type Pages = {
loginPage: LoginPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
});
export { expect } from '@playwright/test';
The fixture depends on the built-in page fixture, so it gets a fresh, isolated browser context per test. Everything before use() is setup, everything after is teardown. Fixtures are only created for tests that request them, so a test that never mentions loginPage never pays for the navigation.
The tests import test and expect from the fixtures file instead of from @playwright/test:
// tests/login.spec.ts
import { test, expect } from './fixtures';
test('valid user sees the welcome message', async ({ loginPage, page }) => {
const home = await loginPage.loginAs('student', 'Password123');
await expect(page).toHaveURL(/logged-in-successfully/);
await expect(home.heading).toHaveText('Logged In Successfully');
await expect(home.message).toContainText('successfully logged in');
});
test('wrong password shows an error', async ({ loginPage }) => {
await loginPage.loginAs('student', 'wrong-password');
await expect(loginPage.error).toHaveText('Your password is invalid!');
});
test('wrong username shows an error', async ({ loginPage }) => {
await loginPage.loginAs('not-a-user', 'Password123');
await expect(loginPage.error).toHaveText('Your username is invalid!');
});
test('log out returns to the login form', async ({ loginPage, page }) => {
const home = await loginPage.loginAs('student', 'Password123');
await home.logOut();
await expect(page).toHaveURL(/practice-test-login/);
});
npx playwright test runs all four in parallel workers. Note that loginAs returns a LoggedInPage even when we expect a failure; in Playwright that is harmless, because constructing the object performs no checks. If you want the return type to document intent the way the Selenium version does, add a separate loginExpectingError method that returns this.
A spec in the same file as the login tests never asks for loginPage. Does it still pay for the navigation to the login form?
Fixtures are lazy: the setup before use() runs only when a test names the fixture (or the fixture is marked auto).
Why fixtures beat beforeEach#
You could build page objects in test.beforeEach and store them in let variables. Fixtures are better for three reasons:
-
Type safety. A test's parameters declare exactly what it uses; there are no
let loginPage: LoginPagevariables that might be undefined. -
Composition. A
loggedInPagefixture can depend onloginPage, and Playwright resolves the chain for you. Chapter 05 adds option fixtures for test data on top. -
Reuse across files. The fixtures file is imported by every spec;
beforeEachhooks have to be repeated or wrapped in adescribe.
Fixtures also give you a natural place for cross-cutting work, such as reporting a test's result to a remote grid, which is what chapter 07 does. For more on JavaScript runners beyond Playwright, see our overview of JavaScript testing frameworks, and for connecting this suite to remote browsers, the Playwright Test documentation.