Why POM?
Selectors and workflows change. POM keeps them in one place so tests describe user intent, not DOM structure.
A Basic Page Object
pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly username: Locator;
readonly password: Locator;
readonly submit: Locator;
constructor(private page: Page) {
this.username = page.getByLabel('Username');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/login');
}
async loginAs(user: string, pass: string) {
await this.username.fill(user);
await this.password.fill(pass);
await this.submit.click();
}
}Using It in a Test
ts
test('admin can log in', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.loginAs('admin', 'secret');
await expect(page).toHaveURL(/dashboard/);
});Level Up with Fixtures
Auto-inject page objects with a custom test fixture — no more manual new LoginPage(page).
fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
export const test = base.extend<{ login: LoginPage }>({
login: async ({ page }, use) => use(new LoginPage(page)),
});POM Rules of Thumb
- No assertions inside page objects — keep them in tests.
- One page object per URL / screen.
- Expose behaviour (loginAs), not internals (usernameInput).
- Return other page objects from actions that navigate.
Get Playwright tutorials in your inbox
Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.