· 2 min read
Page Object Model in Playwright with TypeScript: Complete Guide
Learn how to structure scalable Playwright tests using the Page Object Model pattern. Includes TypeScript examples, best practices, and real-world architecture patterns.
Published: · 2 min read
Learn how to structure scalable Playwright tests using the Page Object Model pattern. Includes TypeScript examples, best practices, and real-world architecture patterns.
The Page Object Model (POM) is a design pattern that creates an abstraction layer between your tests and your web pages. Instead of writing locators directly in tests, you encapsulate page elements and actions inside reusable classes.
Without POM (hard to maintain):
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.locator('#email').fill('user@example.com');
await page.locator('#password').fill('password123');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.dashboard-welcome')).toBeVisible();
});
With POM (scalable):
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(loginPage.welcomeMessage).toBeVisible();
});
When the login form changes, you update one file — not 50 tests.
1. Maintainability
Locators live in one place. When UI changes, update once.
2. Readability
Tests read like user stories: loginPage.login(), dashboard.navigateToSettings().
3. Reusability
Login logic written once, used in 100 tests.
4. Separation of concerns
Tests focus on behavior. Page objects handle implementation.
5. Team scalability
New team members understand tests faster.
Here's the structure I use for all my Playwright projects:
tests/
├── pages/
│ ├── BasePage.ts
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
├── components/
│ ├── Header.ts
│ ├── Modal.ts
│ └── Sidebar.ts
├── fixtures/
│ └── fixtures.ts
├── specs/
│ ├── login.spec.ts
│ ├── dashboard.spec.ts
│ └── checkout.spec.ts
└── playwright.config.ts
Start with a base class that all pages extend:
// tests/pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
protected page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(): Promise<void> {
await this.page.goto(this.url);
}
abstract get url(): string;
// Common elements across all pages
get header(): Locator {
return this.page.getByRole('banner');
}
get footer(): Locator {
return this.page.getByRole('contentinfo');
}
// Common actions
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
}
Now create a specific page class:
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
constructor(page: Page) {
super(page);
}
get url(): string {
return '/login';
}
// Locators (use getters for lazy evaluation)
get emailInput(): Locator {
return this.page.getByLabel('Email address');
}
get passwordInput(): Locator {
return this.page.getByLabel('Password');
}
get submitButton(): Locator {
return this.page.getByRole('button', { name: 'Sign in' });
}
get errorMessage(): Locator {
return this.page.getByRole('alert');
}
get forgotPasswordLink(): Locator {
return this.page.getByRole('link', { name: 'Forgot password?' });
}
// Actions
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async loginAndExpectDashboard(email: string, password: string): Promise<void> {
await this.login(email, password);
await expect(this.page).toHaveURL(/.*dashboard/);
}
async loginWithInvalidCredentials(email: string, password: string): Promise<void> {
await this.login(email, password);
await expect(this.errorMessage).toBeVisible();
}
}
// tests/pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
constructor(page: Page) {
super(page);
}
get url(): string {
return '/dashboard';
}
// Locators
get welcomeMessage(): Locator {
return this.page.getByRole('heading', { name: /Welcome/ });
}
get userMenu(): Locator {
return this.page.getByRole('button', { name: 'User menu' });
}
get logoutButton(): Locator {
return this.page.getByRole('menuitem', { name: 'Logout' });
}
get settingsLink(): Locator {
return this.page.getByRole('link', { name: 'Settings' });
}
// Actions
async logout(): Promise<void> {
await this.userMenu.click();
await this.logoutButton.click();
}
async navigateToSettings(): Promise<void> {
await this.settingsLink.click();
}
}
Now your tests are clean and readable:
// tests/specs/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test.describe('Login functionality', () => {
test('user can login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('valid@example.com', 'password123');
await expect(dashboardPage.welcomeMessage).toBeVisible();
});
test('user sees error with invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginWithInvalidCredentials('invalid@example.com', 'wrong');
await expect(loginPage.errorMessage).toContainText('Invalid credentials');
});
test('user can navigate to forgot password', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.forgotPasswordLink.click();
await expect(page).toHaveURL(/.*forgot-password/);
});
});
For reusable UI components (modals, headers, sidebars), create component classes:
// tests/components/Modal.ts
import { Page, Locator } from '@playwright/test';
export class Modal {
private page: Page;
private container: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.getByRole('dialog');
}
get title(): Locator {
return this.container.getByRole('heading');
}
get closeButton(): Locator {
return this.container.getByRole('button', { name: 'Close' });
}
get confirmButton(): Locator {
return this.container.getByRole('button', { name: 'Confirm' });
}
get cancelButton(): Locator {
return this.container.getByRole('button', { name: 'Cancel' });
}
async close(): Promise<void> {
await this.closeButton.click();
}
async confirm(): Promise<void> {
await this.confirmButton.click();
}
}
Use it in page objects:
// In DashboardPage.ts
async openDeleteModal(): Promise<Modal> {
await this.deleteButton.click();
return new Modal(this.page);
}
For even cleaner tests, integrate page objects with Playwright fixtures:
// tests/fixtures/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect } from '@playwright/test';
Now tests are even cleaner:
// tests/specs/login.spec.ts
import { test, expect } from '../fixtures/fixtures';
test('user can login', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(dashboardPage.welcomeMessage).toBeVisible();
});
1. Use getters for locators
Getters are lazily evaluated. The locator is resolved when accessed, not when the class is instantiated.
// Good
get submitButton(): Locator {
return this.page.getByRole('button', { name: 'Submit' });
}
// Avoid (evaluated too early)
submitButton = this.page.getByRole('button', { name: 'Submit' });
2. Keep actions atomic
Each method should do one logical thing:
// Good
async login(email: string, password: string) { ... }
async fillEmail(email: string) { ... }
// Avoid combining unrelated actions
async loginAndCheckSettings() { ... }
3. Don't add assertions in page objects (usually)
Assertions belong in tests. Page objects encapsulate interactions.
Exception: Verification methods like loginAndExpectDashboard() can include assertions for convenience.
4. Use role-based locators
More resilient than CSS selectors:
// Good
page.getByRole('button', { name: 'Submit' })
// Fragile
page.locator('#submit-btn-primary')
Common Mistakes to Avoid
1. God page objects
If your page object has 50+ methods, break it into smaller components.
2. Exposing raw locators everywhere
Encapsulate interactions: loginPage.login() instead of loginPage.submitButton.click().
3. Not waiting for navigation
After clicks that navigate, wait for the URL or element:
await this.submitButton.click();
await this.page.waitForURL(/.*dashboard/);
4. Sharing state between tests
Each test should be independent. Don't rely on previous test state.
For 100+ tests, organize by feature:
tests/
├── pages/
├── components/
├── fixtures/
├── auth/
│ ├── login.spec.ts
│ ├── register.spec.ts
│ └── password-reset.spec.ts
├── checkout/
│ ├── cart.spec.ts
│ ├── payment.spec.ts
│ └── confirmation.spec.ts
└── admin/
├── users.spec.ts
└── settings.spec.ts
If you're building a test automation framework and want it done right from the start, I can help design the architecture, set up page objects, and train your team on best practices.
Get notified when I publish something new, and unsubscribe at any time.
· 2 min read
Learn how to structure scalable Playwright tests using the Page Object Model pattern. Includes TypeScript examples, best practices, and real-world architecture patterns.
· 4 min read
A practical, step-by-step guide to migrating your Selenium test suite to Playwright. Includes code comparison, common pitfalls, and a migration strategy that won't disrupt your team.