Page Object Model (POM)
The industry-standard design pattern for organizing test automation code. Learn how to build maintainable, scalable, and reusable test suites.
What is the Page Object Model?
The Page Object Model (POM) is a design pattern used in test automation that creates an object-oriented representation of your web application's pages. Each page (or significant component) of your application gets its own class that contains the locators and methods for interacting with that page.
Instead of writing selectors and interactions directly in your test files, you encapsulate them in dedicated Page Object classes. Your tests then call clean, readable methods like loginPage.login(username, password) instead of dealing with raw selectors and clicks.
Problems Without POM
Before learning POM, let's see what happens when you write tests without any design pattern. Here's a typical example of a test that logs in and checks the dashboard:
1import { test, expect } from '@playwright/test';
2
3// Test 1: Check dashboard after login
4test('should show dashboard after login', async ({ page }) => {
5 await page.goto('https://myapp.com/login');
6 await page.locator('#username').fill('admin');
7 await page.locator('#password').fill('secret123');
8 await page.locator('button[type="submit"]').click();
9 await expect(page.locator('.dashboard-title')).toHaveText('Welcome');
10});
11
12// Test 2: Check profile page after login
13test('should show profile page', async ({ page }) => {
14 // Duplicated login code!
15 await page.goto('https://myapp.com/login');
16 await page.locator('#username').fill('admin');
17 await page.locator('#password').fill('secret123');
18 await page.locator('button[type="submit"]').click();
19
20 await page.locator('.nav-profile').click();
21 await expect(page.locator('.profile-name')).toBeVisible();
22});
23
24// Test 3: Check settings after login
25test('should access settings', async ({ page }) => {
26 // Same login code AGAIN!
27 await page.goto('https://myapp.com/login');
28 await page.locator('#username').fill('admin');
29 await page.locator('#password').fill('secret123');
30 await page.locator('button[type="submit"]').click();
31
32 await page.locator('.nav-settings').click();
33 await expect(page.locator('.settings-panel')).toBeVisible();
34});- Code Duplication: The login logic is repeated in every test. If the login form changes (e.g., the selector changes from
#usernameto#email), you must update every single test. - Hard to Maintain: With 50+ tests, changing one selector means modifying dozens of files.
- Poor Readability: Tests are full of raw selectors and implementation details instead of business logic.
- No Reusability: You can't share interactions between test files.
POM Structure
A well-organized POM project follows a clear folder structure. Here's the recommended layout:
├── tests/
│ ├── pages/
│ │ ├── BasePage.ts   # Abstract base class
│ │ ├── LoginPage.ts   # Login page object
│ │ ├── DashboardPage.ts   # Dashboard page object
│ │ └── ProfilePage.ts   # Profile page object
│ ├── login.spec.ts   # Login tests
│ ├── dashboard.spec.ts   # Dashboard tests
│ └── profile.spec.ts   # Profile tests
├── playwright.config.ts
├── tsconfig.json
└── package.json
The Core Principles
- Locators are defined as class properties or getters — never hardcoded in tests
- Actions are encapsulated as public methods (e.g.,
login(),search()) - Helper logic stays as private methods inside the Page Object
- Tests only call Page Object methods — they never touch selectors directly
Creating a Page Object
Let's build a proper Page Object step by step. We'll create a LoginPage that encapsulates all the login functionality:
1import { Page, Locator } from '@playwright/test';
2
3export class LoginPage {
4 // The page instance from Playwright
5 private readonly page: Page;
6
7 // Locators defined as class properties
8 private readonly usernameInput: Locator;
9 private readonly passwordInput: Locator;
10 private readonly submitButton: Locator;
11 private readonly errorMessage: Locator;
12 private readonly rememberMeCheckbox: Locator;
13
14 constructor(page: Page) {
15 this.page = page;
16
17 // Initialize all locators in the constructor
18 this.usernameInput = page.getByLabel('Username');
19 this.passwordInput = page.getByLabel('Password');
20 this.submitButton = page.getByRole('button', { name: 'Sign In' });
21 this.errorMessage = page.locator('.error-message');
22 this.rememberMeCheckbox = page.getByLabel('Remember me');
23 }
24
25 // Public action methods
26 async goto(): Promise<void> {
27 await this.page.goto('/login');
28 }
29
30 async login(username: string, password: string): Promise<void> {
31 await this.usernameInput.fill(username);
32 await this.passwordInput.fill(password);
33 await this.submitButton.click();
34 }
35
36 async loginWithRememberMe(username: string, password: string): Promise<void> {
37 await this.usernameInput.fill(username);
38 await this.passwordInput.fill(password);
39 await this.rememberMeCheckbox.check();
40 await this.submitButton.click();
41 }
42
43 // Methods that return data
44 async getErrorMessage(): Promise<string> {
45 return await this.errorMessage.textContent() || '';
46 }
47
48 async isErrorVisible(): Promise<boolean> {
49 return await this.errorMessage.isVisible();
50 }
51}Now look how clean the test becomes:
1import { test, expect } from '@playwright/test';
2import { LoginPage } from './pages/LoginPage';
3
4test('successful login redirects to dashboard', async ({ page }) => {
5 const loginPage = new LoginPage(page);
6
7 await loginPage.goto();
8 await loginPage.login('admin', 'password123');
9
10 await expect(page).toHaveURL('/dashboard');
11});
12
13test('invalid credentials show error', async ({ page }) => {
14 const loginPage = new LoginPage(page);
15
16 await loginPage.goto();
17 await loginPage.login('wrong', 'credentials');
18
19 const error = await loginPage.getErrorMessage();
20 expect(error).toContain('Invalid credentials');
21});
22
23test('remember me keeps user logged in', async ({ page }) => {
24 const loginPage = new LoginPage(page);
25
26 await loginPage.goto();
27 await loginPage.loginWithRememberMe('admin', 'password123');
28
29 await expect(page).toHaveURL('/dashboard');
30});Best Practices
One Page Object = One Page or Component
Each class should represent a single page or a significant UI component. Don't create a "GodObject" that handles everything.
No Assertions in Page Objects
Page Objects should only contain locators and actions. All assertions (expect()) belong in test files. This keeps Page Objects reusable across different test scenarios.
Use Descriptive Method Names
Methods should describe the business action: addToCart(), searchForProduct(), not clickButton() or fillInput().
Return Promises from All Async Methods
Always type your return values. Use Promise<void> for actions and Promise<string>, Promise<boolean>, etc. for data retrieval methods.
Don't Expose Raw Locators
Avoid making locators public. Instead, provide methods that use the locators internally. If a test needs to check visibility, provide a method like isErrorVisible().
Don't Use Hardcoded Waits
Avoid page.waitForTimeout() in production code. Use Playwright's built-in auto-waiting or explicit wait conditions like waitForLoadState().
Base Page Pattern
In enterprise projects, you'll notice many pages share common functionality: navigation, waiting for page load, handling cookies, etc. The Base Page pattern extracts this shared logic into an abstract parent class.
1import { Page, Locator } from '@playwright/test';
2
3export abstract class BasePage {
4 protected readonly page: Page;
5
6 // Common locators shared across all pages
7 protected readonly header: Locator;
8 protected readonly footer: Locator;
9 protected readonly loadingSpinner: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.header = page.locator('header');
14 this.footer = page.locator('footer');
15 this.loadingSpinner = page.locator('.loading-spinner');
16 }
17
18 // Abstract method - each child MUST define its own URL
19 abstract getUrl(): string;
20
21 // Shared navigation
22 async navigate(): Promise<void> {
23 await this.page.goto(this.getUrl());
24 await this.waitForPageLoad();
25 }
26
27 // Wait for the page to be fully loaded
28 async waitForPageLoad(): Promise<void> {
29 await this.page.waitForLoadState('domcontentloaded');
30 // Wait for loading spinner to disappear
31 await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 10000 })
32 .catch(() => {}); // Ignore if spinner doesn't exist
33 }
34
35 // Common: get page title
36 async getPageTitle(): Promise<string> {
37 return await this.page.title();
38 }
39
40 // Common: get current URL
41 getCurrentUrl(): string {
42 return this.page.url();
43 }
44
45 // Common: take screenshot
46 async takeScreenshot(name: string): Promise<void> {
47 await this.page.screenshot({ path: `screenshots/${name}.png` });
48 }
49}Now every page object extends BasePage and inherits all shared functionality:
1import { Page, Locator } from '@playwright/test';
2import { BasePage } from './BasePage';
3
4export class DashboardPage extends BasePage {
5 private readonly welcomeMessage: Locator;
6 private readonly statsCards: Locator;
7 private readonly recentActivity: Locator;
8
9 constructor(page: Page) {
10 super(page); // Call parent constructor
11 this.welcomeMessage = page.locator('.welcome-msg');
12 this.statsCards = page.locator('.stats-card');
13 this.recentActivity = page.locator('.recent-activity');
14 }
15
16 // Implement the abstract method
17 getUrl(): string {
18 return '/dashboard';
19 }
20
21 async getWelcomeText(): Promise<string> {
22 return await this.welcomeMessage.textContent() || '';
23 }
24
25 async getStatsCount(): Promise<number> {
26 return await this.statsCards.count();
27 }
28
29 async isRecentActivityVisible(): Promise<boolean> {
30 return await this.recentActivity.isVisible();
31 }
32}1import { test, expect } from '@playwright/test';
2import { DashboardPage } from './pages/DashboardPage';
3
4test('dashboard loads correctly', async ({ page }) => {
5 const dashboard = new DashboardPage(page);
6
7 // navigate() and waitForPageLoad() come from BasePage!
8 await dashboard.navigate();
9
10 const welcome = await dashboard.getWelcomeText();
11 expect(welcome).toContain('Welcome');
12
13 const statsCount = await dashboard.getStatsCount();
14 expect(statsCount).toBeGreaterThan(0);
15});DashboardPage → extends BasePage → gives younavigate(), waitForPageLoad(), getPageTitle(),takeScreenshot() — all for free, without writing any extra code.Real-World Example: FutbinPage
Let's look at a real Page Object from an actual project. ThisFutbinPage class interacts with the FUTBIN website to scrape popular player data. Pay attention to how it organizes locators, actions, and helpers.
1import { Page } from '@playwright/test';
2import * as fs from 'fs';
3import * as path from 'path';
4
5export class FutbinPage {
6 // Store the page instance via constructor shorthand
7 constructor(private page: Page) { }
8
9 // Navigate to the site and handle cookies
10 async goto(): Promise<void> {
11 await this.page.goto('https://www.futbin.com');
12 await this.page.waitForTimeout(2000);
13 await this.acceptCookies();
14 }
15
16 // Private-like helper: handle cookie consent popups
17 async acceptCookies(): Promise<void> {
18 const selectors = [
19 '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
20 'button:has-text("Allow all")',
21 '#CybotCookiebotDialogBodyButtonAccept',
22 '.fc-cta-consent',
23 'button:has-text("Accept")'
24 ];
25
26 for (const selector of selectors) {
27 const btn = this.page.locator(selector).first();
28 if (await btn.isVisible({ timeout: 1000 }).catch(() => false)) {
29 await btn.click();
30 await this.page.waitForTimeout(1000);
31 return;
32 }
33 }
34 }
35
36 // Navigate to the popular players section
37 async goToPopularPlayers(): Promise<void> {
38 await this.page.locator('a:has-text("Players")').first().hover();
39 await this.page.waitForTimeout(500);
40 await this.page.locator('a[href="/popular"]').first().click();
41 await this.page.waitForLoadState('domcontentloaded');
42 await this.page.waitForTimeout(2000);
43 }
44
45 // Extract player data from the page
46 async getPopularPlayers(): Promise<{ name: string; price: string }[]> {
47 const players = await this.page.evaluate(() => {
48 const results: { name: string; price: string }[] = [];
49 const playerCards = document.querySelectorAll(
50 'a.playercard-wrapper[href*="/player/"]'
51 );
52
53 playerCards.forEach((card: Element) => {
54 const href = card.getAttribute('href') || '';
55 const match = href.match(/\/player\/\d+\/([^/?]+)/);
56 if (match) {
57 const nameFromUrl = match[1]
58 .replace(/-/g, ' ')
59 .replace(/\b\w/g, c => c.toUpperCase());
60
61 const priceEl = card.querySelector('.platform-pc-only');
62 let price = 'N/A';
63 if (priceEl) {
64 const priceText = priceEl.textContent?.trim() || '';
65 const priceMatch = priceText.match(/(\d{1,3}(,\d{3})*)/);
66 if (priceMatch) price = priceMatch[1];
67 }
68
69 if (!results.some(r => r.name === nameFromUrl)) {
70 results.push({ name: nameFromUrl, price });
71 }
72 }
73 });
74 return results;
75 });
76 return players;
77 }
78
79 // Save data to a report file
80 async saveReport(
81 players: { name: string; price: string }[]
82 ): Promise<string> {
83 const reportsDir = path.join(__dirname, '..', '..', 'reports');
84 if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir);
85
86 const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
87 const filepath = path.join(reportsDir,
88 `popular-players-${timestamp}.txt`);
89
90 const content = [
91 '=== FUTBIN Popular Players Report ===',
92 `Date: ${new Date().toLocaleString()}`,
93 `Total Players: ${players.length}`,
94 '',
95 ...players.map((p, i) => `${i + 1}. ${p.name} - ${p.price}`)
96 ].join('\n');
97
98 fs.writeFileSync(filepath, content);
99 return filepath;
100 }
101}Method Breakdown
page.evaluate(). Returns a typed array of player objects with name and price. Avoids duplicates.The Test Using This Page Object
1import { test, expect } from '@playwright/test';
2import { FutbinPage } from './pages/FutbinPage';
3
4test('Get Popular Players List', async ({ page }) => {
5 const futbin = new FutbinPage(page);
6
7 // Navigate and handle cookies
8 await futbin.goto();
9
10 // Navigate to popular players
11 await futbin.goToPopularPlayers();
12
13 // Extract player data
14 const players = await futbin.getPopularPlayers();
15
16 // Log results
17 console.log('\n=== Popular Players ===');
18 players.forEach((p, i) => {
19 console.log(`${i + 1}. ${p.name} - ${p.price}`);
20 });
21
22 // Save report
23 const reportPath = await futbin.saveReport(players);
24 console.log(`\nReport saved to: ${reportPath}`);
25
26 // Assert we found players
27 expect(players.length).toBeGreaterThan(0);
28});FutbinPage.ts.Component Objects
Sometimes a page has reusable UI components (header, navigation, modals, search bars) that appear on multiple pages. Instead of duplicating these in every Page Object, create Component Objects and compose them.
1import { Page, Locator } from '@playwright/test';
2
3// Component Object for the navigation bar
4export class NavigationComponent {
5 private readonly page: Page;
6 private readonly homeLink: Locator;
7 private readonly profileLink: Locator;
8 private readonly settingsLink: Locator;
9 private readonly logoutButton: Locator;
10 private readonly searchInput: Locator;
11
12 constructor(page: Page) {
13 this.page = page;
14 const nav = page.locator('nav.main-nav');
15 this.homeLink = nav.getByRole('link', { name: 'Home' });
16 this.profileLink = nav.getByRole('link', { name: 'Profile' });
17 this.settingsLink = nav.getByRole('link', { name: 'Settings' });
18 this.logoutButton = nav.getByRole('button', { name: 'Logout' });
19 this.searchInput = nav.getByPlaceholder('Search...');
20 }
21
22 async goToHome(): Promise<void> {
23 await this.homeLink.click();
24 }
25
26 async goToProfile(): Promise<void> {
27 await this.profileLink.click();
28 }
29
30 async goToSettings(): Promise<void> {
31 await this.settingsLink.click();
32 }
33
34 async logout(): Promise<void> {
35 await this.logoutButton.click();
36 }
37
38 async search(query: string): Promise<void> {
39 await this.searchInput.fill(query);
40 await this.searchInput.press('Enter');
41 }
42}Then compose it into your Page Objects:
1import { Page } from '@playwright/test';
2import { BasePage } from './BasePage';
3import { NavigationComponent } from '../components/NavigationComponent';
4
5export class DashboardPage extends BasePage {
6 // Compose the navigation component
7 public readonly navigation: NavigationComponent;
8
9 constructor(page: Page) {
10 super(page);
11 this.navigation = new NavigationComponent(page);
12 }
13
14 getUrl(): string {
15 return '/dashboard';
16 }
17
18 // ... dashboard-specific methods
19}
20
21// In your test:
22// const dashboard = new DashboardPage(page);
23// await dashboard.navigate();
24// await dashboard.navigation.goToSettings(); // Use component!NavigationComponent is written once and used in every page that has a nav bar. If the navigation UI changes, update one file — not every page object.Advanced POM Patterns
Fluent Interface (Method Chaining)
The fluent interface pattern lets you chain method calls for more expressive tests. Each method returns this (or another page object) instead of void:
1import { Page, Locator } from '@playwright/test';
2
3export class CheckoutPage {
4 private readonly page: Page;
5 private readonly addressInput: Locator;
6 private readonly cityInput: Locator;
7 private readonly zipInput: Locator;
8 private readonly cardNumberInput: Locator;
9 private readonly placeOrderButton: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.addressInput = page.getByLabel('Address');
14 this.cityInput = page.getByLabel('City');
15 this.zipInput = page.getByLabel('ZIP Code');
16 this.cardNumberInput = page.getByLabel('Card Number');
17 this.placeOrderButton = page.getByRole('button', { name: 'Place Order' });
18 }
19
20 // Each method returns 'this' for chaining
21 async fillAddress(address: string): Promise<CheckoutPage> {
22 await this.addressInput.fill(address);
23 return this;
24 }
25
26 async fillCity(city: string): Promise<CheckoutPage> {
27 await this.cityInput.fill(city);
28 return this;
29 }
30
31 async fillZip(zip: string): Promise<CheckoutPage> {
32 await this.zipInput.fill(zip);
33 return this;
34 }
35
36 async fillCardNumber(card: string): Promise<CheckoutPage> {
37 await this.cardNumberInput.fill(card);
38 return this;
39 }
40
41 async placeOrder(): Promise<void> {
42 await this.placeOrderButton.click();
43 }
44}
45
46// Usage in test — clean, chainable:
47// const checkout = new CheckoutPage(page);
48// await (await (await (await checkout
49// .fillAddress('123 Main St'))
50// .fillCity('New York'))
51// .fillZip('10001'))
52// .fillCardNumber('4111111111111111');
53// await checkout.placeOrder();Data-Driven Page Objects
Use TypeScript interfaces to create typed test data that works seamlessly with your Page Objects:
1// Define typed test data interfaces
2interface UserCredentials {
3 username: string;
4 password: string;
5}
6
7interface ShippingInfo {
8 address: string;
9 city: string;
10 state: string;
11 zip: string;
12 country: string;
13}
14
15// Page Object with typed data methods
16export class RegistrationPage {
17 constructor(private page: Page) {}
18
19 async fillRegistrationForm(user: UserCredentials & {
20 email: string;
21 firstName: string;
22 lastName: string;
23 }): Promise<void> {
24 await this.page.getByLabel('First Name').fill(user.firstName);
25 await this.page.getByLabel('Last Name').fill(user.lastName);
26 await this.page.getByLabel('Email').fill(user.email);
27 await this.page.getByLabel('Username').fill(user.username);
28 await this.page.getByLabel('Password').fill(user.password);
29 }
30
31 async fillShippingInfo(shipping: ShippingInfo): Promise<void> {
32 await this.page.getByLabel('Address').fill(shipping.address);
33 await this.page.getByLabel('City').fill(shipping.city);
34 await this.page.getByLabel('State').fill(shipping.state);
35 await this.page.getByLabel('ZIP').fill(shipping.zip);
36 await this.page.getByLabel('Country').fill(shipping.country);
37 }
38}
39
40// Test with typed data
41// const testUser = {
42// firstName: 'John', lastName: 'Doe',
43// email: 'john@test.com',
44// username: 'johndoe', password: 'Test@123'
45// };
46// await registrationPage.fillRegistrationForm(testUser);Enterprise Project Structure
Here's what a fully-scaled enterprise test automation project looks like. This structure supports dozens of page objects, shared components, test data management, and custom utilities:
├── tests/
│ ├── pages/
│ │ ├── BasePage.ts
│ │ ├── LoginPage.ts
│ │ ├── DashboardPage.ts
│ │ ├── ProductPage.ts
│ │ ├── CartPage.ts
│ │ ├── CheckoutPage.ts
│ │ └── ProfilePage.ts
│ ├── components/
│ │ ├── NavigationComponent.ts
│ │ ├── HeaderComponent.ts
│ │ ├── FooterComponent.ts
│ │ ├── SearchComponent.ts
│ │ └── ModalComponent.ts
│ ├── specs/
│ │ ├── auth/
│ │ │ ├── login.spec.ts
│ │ │ ├── logout.spec.ts
│ │ │ └── registration.spec.ts
│ │ ├── shopping/
│ │ │ ├── product-browse.spec.ts
│ │ │ ├── add-to-cart.spec.ts
│ │ │ └── checkout.spec.ts
│ │ └── user/
│ │ ├── profile.spec.ts
│ │ └── settings.spec.ts
│ ├── fixtures/
│ │ ├── test-data.ts   # Typed test data
│ │ └── custom-fixtures.ts   # Playwright fixtures
│ └── utils/
│ ├── helpers.ts   # Shared utilities
│ └── api-client.ts   # API helpers
├── playwright.config.ts
├── tsconfig.json
└── package.json
Using Playwright Fixtures for POM
For the cleanest test code, you can use Playwright's fixtures system to automatically create Page Objects and inject them into your tests:
1import { test as base } from '@playwright/test';
2import { LoginPage } from './pages/LoginPage';
3import { DashboardPage } from './pages/DashboardPage';
4import { ProductPage } from './pages/ProductPage';
5
6// Declare your custom fixtures
7type MyFixtures = {
8 loginPage: LoginPage;
9 dashboardPage: DashboardPage;
10 productPage: ProductPage;
11};
12
13// Extend Playwright's test with your fixtures
14export const test = base.extend<MyFixtures>({
15 loginPage: async ({ page }, use) => {
16 const loginPage = new LoginPage(page);
17 await use(loginPage);
18 },
19
20 dashboardPage: async ({ page }, use) => {
21 const dashboardPage = new DashboardPage(page);
22 await use(dashboardPage);
23 },
24
25 productPage: async ({ page }, use) => {
26 const productPage = new ProductPage(page);
27 await use(productPage);
28 },
29});
30
31export { expect } from '@playwright/test';1// Import YOUR custom test, not Playwright's
2import { test, expect } from '../fixtures/custom-fixtures';
3
4// Page objects are automatically injected!
5test('user can browse products', async ({ loginPage, dashboardPage, productPage }) => {
6 await loginPage.goto();
7 await loginPage.login('admin', 'password');
8
9 // No need to create page objects manually
10 await dashboardPage.navigate();
11 const welcome = await dashboardPage.getWelcomeText();
12 expect(welcome).toContain('Welcome');
13});