Apply the Page Object Model pattern to encapsulate Playwright locators and actions in reusable TypeScript classes, keeping test files focused on behaviour rather than DOM details.
Without the Page Object Model, E2E tests become brittle. When the login button's selector changes, you update it in every test file that logs in. When the registration flow adds a new step, you find and update twenty test files. The Page Object Model (POM) solves this by encapsulating page-specific locators and actions in a single class. Tests use the Page Object's methods without knowing the DOM details — when the DOM changes, you update one class, and all tests that use it keep working.
Playwright's fixture system makes POM even cleaner: you define custom fixtures that automatically instantiate Page Objects with the current page, eliminating boilerplate from every test.
A Page Object wraps a Playwright Page and exposes typed actions and locators.
1// e2e/pages/login.page.ts
2import { type Page, type Locator, expect } from '@playwright/test';
3
4export class LoginPage {
5 readonly page: Page;
6 readonly emailInput: Locator;
7 readonly passwordInput: Locator;
8 readonly submitButton: Locator;
9 readonly errorMessage: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.emailInput = page.getByLabel('Email');
14 this.passwordInput = page.getByLabel('Password');
15 this.submitButton = page.getByRole('button', { name: 'Sign in' });
16 this.errorMessage = page.getByRole('alert');
17 }
18
19 async goto(): Promise<void> {
20 await this.page.goto('/login');
21 }
22
23 async login(email: string, password: string): Promise<void> {
24 await this.emailInput.fill(email);
25 await this.passwordInput.fill(password);
26 await this.submitButton.click();
27 }
28
29 async expectErrorMessage(text: string): Promise<void> {
30 await expect(this.errorMessage).toContainText(text);
31 }
32
33 async expectToBeOnLoginPage(): Promise<void> {
34 await expect(this.page).toHaveURL('/login');
35 }
36}
37
38// Usage in test:
39import { test, expect } from '@playwright/test';
40import { LoginPage } from '../pages/login.page';
41
42test('shows error for wrong password', async ({ page }) => {
43 const loginPage = new LoginPage(page);
44 await loginPage.goto();
45 await loginPage.login('[email protected]', 'wrong-password');
46 await loginPage.expectErrorMessage('Invalid credentials');
47 await loginPage.expectToBeOnLoginPage();
48});
49
50test('redirects to dashboard on success', async ({ page }) => {
51 const loginPage = new LoginPage(page);
52 await loginPage.goto();
53 await loginPage.login('[email protected]', 'SecurePass1!');
54 await expect(page).toHaveURL('/dashboard');
55});When an action navigates to a new page, return the next Page Object. This creates a fluent, type-safe flow.
1// e2e/pages/dashboard.page.ts
2import { type Page, type Locator, expect } from '@playwright/test';
3import { CoursesPage } from './courses.page';
4
5export class DashboardPage {
6 readonly page: Page;
7 readonly welcomeHeading: Locator;
8 readonly myCoursesSection: Locator;
9 readonly coursesNavLink: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
14 this.myCoursesSection = page.getByRole('region', { name: 'My Courses' });
15 this.coursesNavLink = page.getByRole('link', { name: 'Courses' });
16 }
17
18 async expectLoaded(): Promise<void> {
19 await expect(this.page).toHaveURL('/dashboard');
20 await expect(this.welcomeHeading).toBeVisible();
21 }
22
23 async navigateToCourses(): Promise<CoursesPage> {
24 await this.coursesNavLink.click();
25 const coursesPage = new CoursesPage(this.page);
26 await coursesPage.expectLoaded();
27 return coursesPage;
28 }
29}
30
31// Updated LoginPage — login() now returns DashboardPage
32export class LoginPage {
33 // ...
34 async loginAndGoToDashboard(email: string, password: string): Promise<DashboardPage> {
35 await this.emailInput.fill(email);
36 await this.passwordInput.fill(password);
37 await this.submitButton.click();
38 const dashboard = new DashboardPage(this.page);
39 await dashboard.expectLoaded();
40 return dashboard;
41 }
42}
43
44// Test reads as a user story:
45test('student can browse courses from dashboard', async ({ page }) => {
46 const loginPage = new LoginPage(page);
47 await loginPage.goto();
48 const dashboard = await loginPage.loginAndGoToDashboard('[email protected]', 'Pass1!');
49 const courses = await dashboard.navigateToCourses();
50 await courses.expectAtLeastOneCourseVisible();
51});Playwright's fixture system eliminates manual new PageObject(page) calls in every test.
1// e2e/fixtures.ts
2import { test as base, type Page } from '@playwright/test';
3import { LoginPage } from './pages/login.page';
4import { DashboardPage } from './pages/dashboard.page';
5import { CoursesPage } from './pages/courses.page';
6
7type PageObjects = {
8 loginPage: LoginPage;
9 dashboardPage: DashboardPage;
10 coursesPage: CoursesPage;
11 // Automatically logged-in page fixture
12 authenticatedPage: Page;
13};
14
15export const test = base.extend<PageObjects>({
16 loginPage: async ({ page }, use) => {
17 await use(new LoginPage(page));
18 },
19
20 dashboardPage: async ({ page }, use) => {
21 await use(new DashboardPage(page));
22 },
23
24 coursesPage: async ({ page }, use) => {
25 await use(new CoursesPage(page));
26 },
27
28 // Pre-authenticated fixture: every test using this starts logged in
29 authenticatedPage: async ({ page }, use) => {
30 const loginPage = new LoginPage(page);
31 await loginPage.goto();
32 await loginPage.login('[email protected]', 'TestPass1!');
33 await expect(page).toHaveURL('/dashboard');
34 await use(page); // test runs with this logged-in page
35 },
36});
37
38export { expect } from '@playwright/test';
39
40// Using the fixtures — no manual instantiation:
41import { test, expect } from '../fixtures';
42
43test('login shows error for wrong password', async ({ loginPage }) => {
44 await loginPage.goto();
45 await loginPage.login('[email protected]', 'wrong');
46 await loginPage.expectErrorMessage('Invalid credentials');
47});
48
49test('dashboard shows welcome message', async ({ authenticatedPage, dashboardPage }) => {
50 await dashboardPage.expectLoaded();
51 await expect(dashboardPage.welcomeHeading).toBeVisible();
52});Page Objects model full pages. Component Objects model reusable UI components that appear on multiple pages.
1// e2e/components/navbar.component.ts
2import { type Page, type Locator, expect } from '@playwright/test';
3
4export class NavbarComponent {
5 readonly nav: Locator;
6 readonly logo: Locator;
7 readonly userMenu: Locator;
8 readonly signOutButton: Locator;
9
10 constructor(page: Page) {
11 this.nav = page.getByRole('navigation', { name: 'Main navigation' });
12 this.logo = this.nav.getByRole('link', { name: 'DeveloperBootstrap' });
13 this.userMenu = this.nav.getByRole('button', { name: /account menu/i });
14 this.signOutButton = page.getByRole('menuitem', { name: 'Sign out' });
15 }
16
17 async signOut(): Promise<void> {
18 await this.userMenu.click();
19 await this.signOutButton.click();
20 }
21
22 async expectUserMenuVisible(name: string): Promise<void> {
23 await expect(this.userMenu).toContainText(name);
24 }
25}
26
27// e2e/components/toast.component.ts
28export class ToastComponent {
29 readonly toast: Locator;
30
31 constructor(page: Page) {
32 this.toast = page.getByRole('status'); // or role='alert' for errors
33 }
34
35 async expectSuccess(text: string): Promise<void> {
36 await expect(this.toast).toContainText(text);
37 await expect(this.toast).toHaveAttribute('data-type', 'success');
38 }
39
40 async expectError(text: string): Promise<void> {
41 await expect(this.toast).toContainText(text);
42 }
43
44 async waitForDismiss(): Promise<void> {
45 await this.toast.waitFor({ state: 'hidden' });
46 }
47}
48
49// Usage across multiple pages:
50test('shows success toast and signs out', async ({ page, authenticatedPage }) => {
51 const navbar = new NavbarComponent(page);
52 const toast = new ToastComponent(page);
53
54 await page.getByRole('button', { name: 'Save profile' }).click();
55 await toast.expectSuccess('Profile saved');
56
57 await navbar.signOut();
58 await expect(page).toHaveURL('/login');
59});Page Objects add indirection. For simple tests or one-off scenarios, a helper function is often clearer.
1// Page Object is overkill for a single-use action:
2class CheckoutPage {
3 async fillCard(number: string, expiry: string, cvc: string) { ... }
4 async submit() { ... }
5 async expectSuccess() { ... }
6}
7// If checkout is only tested in one file, this adds unnecessary abstraction.
8
9// Simpler alternative: local helper function
10async function completePurchase(page: Page, card = '4242424242424242') {
11 await page.getByLabel('Card number').fill(card);
12 await page.getByLabel('Expiry').fill('12/28');
13 await page.getByLabel('CVC').fill('123');
14 await page.getByRole('button', { name: 'Pay Now' }).click();
15 await expect(page.getByRole('status')).toContainText('Payment successful');
16}
17
18// Use Page Objects when:
19// ✅ The same page is accessed in 3+ test files
20// ✅ A page has complex multi-step interactions
21// ✅ The page's selector changes frequently
22// ✅ You want to model the app's navigation flow explicitly
23
24// Use helper functions when:
25// ✅ The interaction is used in 1-2 test files
26// ✅ The steps are simple and unlikely to change
27// ✅ You want to avoid the overhead of a class
28
29// Anti-pattern: Page Object with pass-through methods
30class SimplePage {
31 async clickButton(name: string) {
32 await this.page.getByRole('button', { name }).click();
33 }
34 // This adds no value over calling page.getByRole() directly
35}Complete POM implementation for the registration, login, dashboard, and profile pages with custom fixtures and component objects for navbar and toast notifications.
1// e2e/pages/register.page.ts
2import { type Page, expect } from '@playwright/test';
3
4export class RegisterPage {
5 constructor(private readonly page: Page) {}
6
7 async goto() { await this.page.goto('/register'); }
8
9 async register(name: string, email: string, password: string) {
10 await this.page.getByLabel('Full name').fill(name);
11 await this.page.getByLabel('Email address').fill(email);
12 await this.page.getByLabel('Password').fill(password);
13 await this.page.getByLabel('Confirm password').fill(password);
14 await this.page.getByRole('button', { name: 'Create account' }).click();
15 }
16
17 async expectVerificationEmailSent() {
18 await expect(this.page).toHaveURL('/verify-email-sent');
19 await expect(this.page.getByRole('heading')).toContainText('Check your email');
20 }
21
22 async expectValidationError(field: string, message: string) {
23 const fieldLocator = this.page.getByLabel(field);
24 await expect(fieldLocator).toHaveAttribute('aria-invalid', 'true');
25 const errorId = await fieldLocator.getAttribute('aria-describedby');
26 await expect(this.page.locator(`#${errorId}`)).toContainText(message);
27 }
28}
29
30// e2e/fixtures.ts
31import { test as base, expect } from '@playwright/test';
32import { RegisterPage } from './pages/register.page';
33import { LoginPage } from './pages/login.page';
34import { DashboardPage } from './pages/dashboard.page';
35
36type Fixtures = {
37 registerPage: RegisterPage;
38 loginPage: LoginPage;
39 dashboardPage: DashboardPage;
40 loggedInPage: { page: any; dashboard: DashboardPage };
41};
42
43export const test = base.extend<Fixtures>({
44 registerPage: async ({ page }, use) => use(new RegisterPage(page)),
45 loginPage: async ({ page }, use) => use(new LoginPage(page)),
46 dashboardPage: async ({ page }, use) => use(new DashboardPage(page)),
47
48 loggedInPage: async ({ page }, use) => {
49 const login = new LoginPage(page);
50 await login.goto();
51 await login.login('[email protected]', 'TestPass1!');
52 const dashboard = new DashboardPage(page);
53 await dashboard.expectLoaded();
54 await use({ page, dashboard });
55 },
56});
57export { expect };
58
59// e2e/auth.spec.ts
60import { test, expect } from './fixtures';
61
62test.describe('Registration', () => {
63 test('creates account and redirects to email verification page', async ({ registerPage }) => {
64 await registerPage.goto();
65 await registerPage.register('Alice Smith', `alice-${Date.now()}@example.com`, 'SecurePass1!');
66 await registerPage.expectVerificationEmailSent();
67 });
68
69 test('shows validation error for weak password', async ({ registerPage }) => {
70 await registerPage.goto();
71 await registerPage.register('Bob', '[email protected]', 'weak');
72 await registerPage.expectValidationError('Password', 'at least 8 characters');
73 });
74});
75
76test.describe('Login', () => {
77 test('redirects to dashboard with valid credentials', async ({ loginPage }) => {
78 await loginPage.goto();
79 await loginPage.login('[email protected]', 'TestPass1!');
80 await expect(loginPage.page).toHaveURL('/dashboard');
81 });
82
83 test('shows error for invalid credentials', async ({ loginPage }) => {
84 await loginPage.goto();
85 await loginPage.login('[email protected]', 'wrong');
86 await loginPage.expectErrorMessage('Invalid credentials');
87 });
88});
89
90test.describe('Dashboard (authenticated)', () => {
91 test('shows welcome message after login', async ({ loggedInPage: { dashboard } }) => {
92 await expect(dashboard.welcomeHeading).toBeVisible();
93 });
94
95 test('navigates to courses from dashboard', async ({ loggedInPage: { page, dashboard } }) => {
96 const courses = await dashboard.navigateToCourses();
97 await courses.expectLoaded();
98 await expect(page).toHaveURL('/courses');
99 });
100});The fixture system eliminates new PageObject(page) from every test. The loggedInPage fixture encapsulates the login flow so any test needing authentication gets a ready dashboard in one line. RegisterPage.expectValidationError() uses ARIA attributes (aria-invalid, aria-describedby) to assert on accessible error messages — this simultaneously tests the UI and the accessibility implementation. Page Objects return void or the next typed Page Object — never raw Playwright Locators — keeping the abstraction clean.
The next lesson covers authentication in E2E tests: using storageState to persist login sessions across tests, avoiding login on every test, and testing role-specific UI with different browser contexts.
Your progress is saved in this browser, on this device only.