You can write Playwright tests without the Page Object Model (POM). But you shouldn't.
I learned this the hard way. In the first month of Playwright development on the Wells Fargo project, we had 50 tests. By month three, we had 200. By month six, 400.
Tests written without POM became unmaintainable—a single UI change broke 20+ tests. Tests written with POM? One change, one file updated, all tests still passing.
This guide shows you exactly how to build POM in Playwright + TypeScript so your tests stay maintainable at any scale.
What Is the Page Object Model?
The Page Object Model separates what you test from how you interact with the UI.
Without POM (bad):
// Test is tightly coupled to selectors
test('login', async ({ page }) => {
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button.btn-primary');
await expect(page).toHaveURL('/dashboard');
});
When the dev team changes: CSS class from `btn-primary` to `btn-submit` → test breaks → you spend 30 minutes fixing selectors instead of building new tests.
With POM (good):
// Test is clean, readable, uses page object methods
test('login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password');
await loginPage.expectToBeDashboard();
});
// LoginPage encapsulates all selectors + interactions
class LoginPage {
async login(email: string, password: string) {
// Selectors defined once, used everywhere
}
}
When the dev team changes CSS: Update LoginPage.ts selectors (1 file) → all 50 login tests still passing.
This is the core value: one place to update UI interactions, unlimited tests using them.
Building the Foundation: BasePage
Every page should inherit from BasePage, which encapsulates common interactions.
// src/pages/BasePage.ts
import { Page, Locator } from '@playwright/test';
export class BasePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// Common methods used across all pages
async goto(path: string) {
await this.page.goto(path);
}
async click(locator: Locator) {
await locator.click();
}
async fill(locator: Locator, text: string) {
await locator.clear();
await locator.fill(text);
}
async getText(locator: Locator): Promise {
return await locator.textContent() || '';
}
async expectToBeVisible(locator: Locator) {
await locator.waitFor({ state: 'visible' });
}
async waitForNavigation(fn: () => Promise) {
await Promise.all([
this.page.waitForNavigation(),
fn(),
]);
}
}
Benefits of BasePage:
- ✅ Consistent method naming across all pages (click, fill, getText, etc.)
- ✅ Centralized wait logic (automatic vs explicit)
- ✅ Reusable navigation patterns
- ✅ Error handling in one place
Building Page Objects: LoginPage Example
// src/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
// Selectors defined once
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
// Use data-testid for stability
this.emailInput = page.locator('input[data-testid="email"]');
this.passwordInput = page.locator('input[data-testid="password"]');
this.loginButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator('[role="alert"]');
}
// High-level actions
async login(email: string, password: string) {
await this.fill(this.emailInput, email);
await this.fill(this.passwordInput, password);
await this.click(this.loginButton);
}
async loginAndExpectError(email: string, password: string) {
await this.login(email, password);
await this.expectToBeVisible(this.errorMessage);
}
async expectErrorMessage(expectedText: string) {
const error = await this.getText(this.errorMessage);
return error.includes(expectedText);
}
}
Key principles:
- Selectors are private: Tests can't access them directly (enforces using methods)
- Methods describe intent, not actions: `login()` not `fillEmail().fillPassword().clickSubmit()`
- Return values enable assertions: `expectErrorMessage()` returns boolean for assertions
- Locators defined once: CSS class changes? Update here, not 50 tests
Using Page Objects in Tests
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../src/pages/LoginPage';
import { DashboardPage } from '../src/pages/DashboardPage';
test.describe('Authentication', () => {
let loginPage: LoginPage;
let dashboardPage: DashboardPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
// Navigate to login
await loginPage.goto('/login');
});
test('should login with valid credentials', async ({ page }) => {
// Tests read like business requirements
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
await expect(dashboardPage.greeting).toBeVisible();
});
test('should show error with invalid credentials', async () => {
await loginPage.loginAndExpectError('user@example.com', 'wrong');
const hasError = await loginPage.expectErrorMessage('Invalid credentials');
expect(hasError).toBe(true);
});
});
Tests are now: ✅ Readable by non-developers ✅ Independent of selector changes ✅ Maintainable at scale
Advanced POM Patterns
Method Chaining for Fluent APIs
// Enable fluent test writing
await loginPage
.fillEmail('user@example.com')
.fillPassword('password')
.submit()
.expectToBeDashboard();
// Implement with return this pattern
async fillEmail(email: string) {
await this.fill(this.emailInput, email);
return this; // Return the page object for chaining
}
Conditional Logic in Page Objects
// Handle optional UI states
async loginIfRequired(email: string, password: string) {
if (await this.emailInput.isVisible()) {
await this.login(email, password);
}
}
async dismissBannerIfPresent() {
if (await this.banner.isVisible()) {
await this.click(this.bannerdCloseButton);
}
}
Component Objects for Reusable UI Components
// src/components/Modal.ts
export class Modal {
constructor(private page: Page) {}
async expectTitle(title: string) {
await expect(this.page.locator('[role="dialog"] h2')).toContainText(title);
}
async close() {
await this.page.locator('[role="dialog"] button[aria-label="close"]').click();
}
}
// src/pages/SettingsPage.ts
export class SettingsPage extends BasePage {
private readonly modal = new Modal(this.page);
async closeSettingsModal() {
await this.modal.close();
}
}
POM Anti-Patterns to Avoid
❌ Test Logic in Page Objects
// BAD: Business logic in page object
async loginAndVerify(email: string, password: string) {
await this.login(email, password);
// This assertion belongs in the test, not page object
if (!await this.isLoggedIn()) {
throw new Error('Login failed');
}
}
// GOOD: Page object returns value, test asserts
async login(email: string, password: string) {
// Just perform the action
}
async isLoggedIn(): Promise {
// Return state for test to assert
}
// In test:
await loginPage.login(email, password);
expect(await loginPage.isLoggedIn()).toBe(true);
❌ Overly Generic Methods
// BAD: Too generic
async fillForm(data: Record) {
for (const [key, value] of Object.entries(data)) {
await this.page.fill(`input[name="${key}"]`, value);
}
}
// GOOD: Specific methods
async fillEmail(email: string) {
await this.fill(this.emailInput, email);
}
async fillPassword(password: string) {
await this.fill(this.passwordInput, password);
}
❌ Exposing Selectors to Tests
// BAD: Public selectors
export class LoginPage {
emailInput: Locator; // Public - tests can use directly
}
// In test (bad):
await loginPage.emailInput.fill('user@example.com');
// GOOD: Private selectors
class LoginPage {
private readonly emailInput: Locator; // Private - forces using methods
}
// In test (good):
await loginPage.fillEmail('user@example.com');
Real Project Structure: Wells Fargo
src/
├── pages/
│ ├── BasePage.ts # Base class for all pages
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ ├── AccountPage.ts
│ └── TransactionsPage.ts
├── components/
│ ├── Modal.ts # Reusable modal component
│ ├── Sidebar.ts # Reusable navigation
│ └── DataTable.ts # Reusable table component
├── fixtures/
│ └── pageFixtures.ts # Playwright fixtures for page objects
└── utils/
└── testData.ts # Test data factories
tests/
├── auth.spec.ts
├── transactions.spec.ts
├── accounts.spec.ts
└── search.spec.ts
POM at Scale: 400+ Tests
At 400+ tests across 12 page objects, we maintained:
- ✅ <2% test failure rate (down from 40% with Selenium)
- ✅ 12-minute full suite execution (parallel)
- ✅ 2-3 hour maintenance per month (UI changes)
- ✅ New engineers writing tests confidently in day 2
Key to success: Disciplined POM structure from the start. Don't retrofit POM after tests are written—start with it.
Frequently Asked Questions
When should I create a new page object?
When you have a distinct page/screen with unique UI elements. Don't create page objects for every modal or component—create component objects for those instead and reuse across pages.
Should I put assertions in page objects?
No. Page objects should return data/state for tests to assert. Example: `async isErrorVisible()` returns boolean, test does `expect(result).toBe(true)`.
How do I test dynamic/changing UIs?
Use flexible selectors (data-testid > CSS classes > XPath). Talk to developers about adding data-testid attributes for testing—it's the most stable approach.
Can I use Page Object Model with BDD frameworks?
Absolutely. Combine POM with Cucumber/Gherkin for readable, maintainable BDD tests. Each step definition uses page objects under the hood.
Next Steps
Start implementing POM in your Playwright tests today. You won't regret it.
The first 50 tests feel "good enough" without POM. At 200 tests, you'll wish you had it. At 400+ tests, it's the difference between a maintainable framework and a nightmare.
Need help implementing POM at scale? I offer framework design and architecture consulting.
Let's build a POM-based framework that scales →
Related Articles:
Tayyab Akmal
AI & QA Automation Engineer
I've caught critical bugs in fintech, e-commerce, and SaaS platforms — then built the automation that prevents them from shipping again. 6+ years scaling test automation and AI-driven QA.