Page Object Model
Tame brittle E2E suites by hiding selectors and flows behind a small API.
- e2e
- maintainability
- design
Problem
E2E tests are littered with raw selectors and step-by-step interactions. A single UI change forces edits across dozens of tests, and intent is buried under mechanics.
Solution
Wrap each page (or meaningful component) in a class that exposes intent-revealing methods — login(user), addToCart(item) — and keeps selectors private. Tests read like user stories; selector churn is contained in one place.
Keep page objects thin: they model interaction, not assertions. Let the test decide what to assert so the object stays reusable.
Snippets
export class LoginPage {
constructor(private readonly page: Page) {}
// Selectors are private; intent is public.
private readonly email = () => this.page.getByLabel('Email');
private readonly password = () => this.page.getByLabel('Password');
private readonly submit = () => this.page.getByRole('button', { name: 'Sign in' });
async goto() {
await this.page.goto('/login');
}
async login(email: string, pass: string) {
await this.email().fill(email);
await this.password().fill(pass);
await this.submit().click();
}
}test('user can sign in', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('user@example.com', 'secret');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Pitfalls
- Putting assertions inside the page object — it couples the model to one test and hurts reuse.
- God objects: one giant page class for the whole app. Split by page or component.
- Leaking selectors back into tests — once they escape, the abstraction stops paying off.