QA Studio Logo
Back to Blog
Best Practices

Why Page Object Model is Essential for Maintainable Test Automation

Learn how the Page Object Model pattern transforms brittle test suites into maintainable, scalable automation - with real Playwright TypeScript examples.

November 11, 2025
QA Studio Team
Playwright TypeScript Test Automation Page Object Model Design Patterns

If you've ever inherited a test suite where a single UI change breaks 47 tests, you've experienced the maintenance nightmare that Page Object Model (POM) solves. Let's explore why this pattern is essential and how to implement it effectively with Playwright and TypeScript.

The Problem: Fragile Tests

Here's what most teams start with:

// ❌ Fragile test without Page Object Model
test('user can complete checkout', async ({ page }) => {
	await page.goto('https://example.com/login');
	await page.locator('#email').fill('user@example.com');
	await page.locator('#password').fill('password123');
	await page.locator('button[type="submit"]').click();

	await page.locator('a[href="/products"]').click();
	await page.locator('.product-card').first().click();
	await page.locator('button.add-to-cart').click();

	await page.locator('.cart-icon').click();
	await page.locator('button.checkout').click();
	await page.locator('#shipping-name').fill('John Doe');
	await page.locator('#shipping-address').fill('123 Main St');
	await page.locator('button.complete-order').click();

	await expect(page.locator('.success-message')).toBeVisible();
});

test('user can add multiple items to cart', async ({ page }) => {
	await page.goto('https://example.com/login');
	await page.locator('#email').fill('user@example.com');
	await page.locator('#password').fill('password123');
	await page.locator('button[type="submit"]').click();

	// Same login code repeated...
	await page.locator('a[href="/products"]').click();
	await page.locator('.product-card').nth(0).click();
	await page.locator('button.add-to-cart').click();
	// More duplication...
});

What happens when the login form changes? You update 30+ tests. When the cart button selector changes? Another 20 tests. This doesn't scale.

The Solution: Page Object Model

With POM, you encapsulate page logic in classes:

// ✅ Maintainable test with Page Object Model
import { LoginPage } from './pages/LoginPage';
import { ProductsPage } from './pages/ProductsPage';
import { CartPage } from './pages/CartPage';
import { CheckoutPage } from './pages/CheckoutPage';

test('user can complete checkout', async ({ page }) => {
	const loginPage = new LoginPage(page);
	const productsPage = new ProductsPage(page);
	const cartPage = new CartPage(page);
	const checkoutPage = new CheckoutPage(page);

	await loginPage.navigate();
	await loginPage.login('user@example.com', 'password123');

	await productsPage.navigate();
	await productsPage.addFirstProductToCart();

	await cartPage.open();
	await cartPage.proceedToCheckout();

	await checkoutPage.fillShippingInfo({
		name: 'John Doe',
		address: '123 Main St'
	});
	await checkoutPage.completeOrder();

	await expect(checkoutPage.successMessage).toBeVisible();
});

Now when the login form changes? Update one file. When cart logic changes? One file. Your tests remain unchanged.

Implementing Page Object Model in Playwright

Basic Page Object Structure

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
	readonly page: Page;
	readonly emailInput: Locator;
	readonly passwordInput: Locator;
	readonly submitButton: Locator;
	readonly errorMessage: Locator;

	constructor(page: Page) {
		this.page = page;
		this.emailInput = page.locator('[data-testid="email-input"]');
		this.passwordInput = page.locator('[data-testid="password-input"]');
		this.submitButton = page.locator('button[type="submit"]');
		this.errorMessage = page.locator('[data-testid="error-message"]');
	}

	async navigate() {
		await this.page.goto('/login');
	}

	async login(email: string, password: string) {
		await this.emailInput.fill(email);
		await this.passwordInput.fill(password);
		await this.submitButton.click();
	}

	async loginWithError(email: string, password: string) {
		await this.login(email, password);
		await this.errorMessage.waitFor({ state: 'visible' });
	}

	async getErrorText(): Promise<string> {
		return (await this.errorMessage.textContent()) || '';
	}
}

Key Principles

1. Use data-testid for stability

// ❌ Fragile - breaks when CSS changes
this.submitButton = page.locator('button.btn-primary.submit-login');

// ✅ Stable - semantic selector
this.submitButton = page.locator('[data-testid="login-submit"]');

2. Locators as properties, not methods

// ❌ Creates new locator on each call
getEmailInput() {
  return this.page.locator('#email');
}

// ✅ Reusable locator instance
readonly emailInput: Locator = this.page.locator('#email');

3. Hide implementation details

// ❌ Test knows too much about the UI
test('should login', async ({ page }) => {
	const loginPage = new LoginPage(page);
	await loginPage.emailInput.fill('user@example.com');
	await loginPage.passwordInput.fill('password123');
	await loginPage.submitButton.click();
});

// ✅ Test focuses on behavior, not implementation
test('should login', async ({ page }) => {
	const loginPage = new LoginPage(page);
	await loginPage.login('user@example.com', 'password123');
});

Advanced Patterns

Component Objects for Reusable UI Elements

Many pages share common components (headers, modals, forms). Extract them:

// components/Navigation.ts
export class Navigation {
	readonly page: Page;
	readonly cartIcon: Locator;
	readonly userMenu: Locator;
	readonly logoutButton: Locator;

	constructor(page: Page) {
		this.page = page;
		this.cartIcon = page.locator('[data-testid="cart-icon"]');
		this.userMenu = page.locator('[data-testid="user-menu"]');
		this.logoutButton = page.locator('[data-testid="logout-button"]');
	}

	async openCart() {
		await this.cartIcon.click();
	}

	async logout() {
		await this.userMenu.click();
		await this.logoutButton.click();
	}

	async getCartItemCount(): Promise<number> {
		const badge = this.page.locator('[data-testid="cart-badge"]');
		const text = await badge.textContent();
		return parseInt(text || '0', 10);
	}
}

// pages/ProductsPage.ts
export class ProductsPage {
	readonly page: Page;
	readonly navigation: Navigation;

	constructor(page: Page) {
		this.page = page;
		this.navigation = new Navigation(page);
	}

	async addProductToCart(productName: string) {
		const product = this.page.locator(`[data-testid="product-${productName}"]`);
		await product.locator('button.add-to-cart').click();
	}
}

// Usage in test
test('cart badge updates when adding items', async ({ page }) => {
	const productsPage = new ProductsPage(page);

	await productsPage.navigate();
	const initialCount = await productsPage.navigation.getCartItemCount();

	await productsPage.addProductToCart('laptop');

	const newCount = await productsPage.navigation.getCartItemCount();
	expect(newCount).toBe(initialCount + 1);
});

Base Page Pattern

Avoid repeating common functionality:

// pages/BasePage.ts
export abstract class BasePage {
	readonly page: Page;
	readonly navigation: Navigation;

	constructor(page: Page) {
		this.page = page;
		this.navigation = new Navigation(page);
	}

	abstract navigate(): Promise<void>;

	async waitForPageLoad() {
		await this.page.waitForLoadState('domcontentloaded');
	}

	async takeScreenshot(name: string) {
		await this.page.screenshot({ path: `screenshots/${name}.png` });
	}

	async getTitle(): Promise<string> {
		return await this.page.title();
	}
}

// pages/ProductsPage.ts
export class ProductsPage extends BasePage {
	async navigate() {
		await this.page.goto('/products');
		await this.waitForPageLoad();
	}

	async addProductToCart(productName: string) {
		const product = this.page.locator(`[data-testid="product-${productName}"]`);
		await product.locator('button.add-to-cart').click();
	}
}

Dynamic Locators

Handle lists and dynamic content elegantly:

// pages/ProductsPage.ts
export class ProductsPage {
	readonly page: Page;

	constructor(page: Page) {
		this.page = page;
	}

	// Dynamic locator method
	getProductByName(name: string): Locator {
		return this.page.locator(`[data-testid="product-${name}"]`);
	}

	async addProductToCart(productName: string) {
		const addButton = this.getProductByName(productName).locator('[data-testid="add-to-cart"]');
		await addButton.click();
	}

	async getProductPrice(productName: string): Promise<string> {
		const priceElement = this.getProductByName(productName).locator('[data-testid="price"]');
		return (await priceElement.textContent()) || '';
	}

	async isProductOutOfStock(productName: string): Promise<boolean> {
		const badge = this.getProductByName(productName).locator(
			'[data-testid="out-of-stock-badge"]'
		);
		return await badge.isVisible();
	}
}

// Usage
test('can add multiple products', async ({ page }) => {
	const productsPage = new ProductsPage(page);
	await productsPage.navigate();

	await productsPage.addProductToCart('laptop');
	await productsPage.addProductToCart('mouse');
	await productsPage.addProductToCart('keyboard');
});

Page Object Model for API Testing

POM isn't just for UI! Apply it to API tests:

// api/UsersApi.ts
import { APIRequestContext } from '@playwright/test';

export class UsersApi {
	private request: APIRequestContext;
	private baseUrl: string;

	constructor(request: APIRequestContext, baseUrl: string) {
		this.request = request;
		this.baseUrl = baseUrl;
	}

	async createUser(userData: { email: string; password: string; name: string }) {
		const response = await this.request.post(`${this.baseUrl}/api/users`, {
			data: userData
		});
		return response;
	}

	async getUser(userId: string) {
		const response = await this.request.get(`${this.baseUrl}/api/users/${userId}`);
		return response;
	}

	async updateUser(userId: string, updates: Partial<{ email: string; name: string }>) {
		const response = await this.request.patch(`${this.baseUrl}/api/users/${userId}`, {
			data: updates
		});
		return response;
	}

	async deleteUser(userId: string) {
		const response = await this.request.delete(`${this.baseUrl}/api/users/${userId}`);
		return response;
	}

	async getUserProjects(userId: string) {
		const response = await this.request.get(`${this.baseUrl}/api/users/${userId}/projects`);
		return response;
	}
}

// test
test('user CRUD operations', async ({ request }) => {
	const usersApi = new UsersApi(request, 'https://qastudio.dev');

	// Create
	const createResponse = await usersApi.createUser({
		email: 'test@example.com',
		password: 'password123',
		name: 'Test User'
	});
	expect(createResponse.status()).toBe(201);
	const user = await createResponse.json();

	// Read
	const getResponse = await usersApi.getUser(user.id);
	expect(getResponse.status()).toBe(200);

	// Update
	const updateResponse = await usersApi.updateUser(user.id, {
		name: 'Updated Name'
	});
	expect(updateResponse.status()).toBe(200);

	// Delete
	const deleteResponse = await usersApi.deleteUser(user.id);
	expect(deleteResponse.status()).toBe(204);
});

Common Mistakes to Avoid

1. Over-Engineering

// ❌ Too granular - maintenance burden
class LoginPage {
	async fillEmail(email: string) {
		/* ... */
	}
	async fillPassword(password: string) {
		/* ... */
	}
	async clickSubmit() {
		/* ... */
	}
	async waitForRedirect() {
		/* ... */
	}
}

// Test becomes verbose
await loginPage.fillEmail('user@example.com');
await loginPage.fillPassword('password123');
await loginPage.clickSubmit();
await loginPage.waitForRedirect();

// ✅ Right level of abstraction
class LoginPage {
	async login(email: string, password: string) {
		await this.emailInput.fill(email);
		await this.passwordInput.fill(password);
		await this.submitButton.click();
	}
}

// Test is clean
await loginPage.login('user@example.com', 'password123');

2. Assertions in Page Objects

// ❌ Don't put assertions in page objects
class LoginPage {
	async login(email: string, password: string) {
		await this.emailInput.fill(email);
		await this.passwordInput.fill(password);
		await this.submitButton.click();

		// ❌ Assertion in page object
		await expect(this.page).toHaveURL('/dashboard');
	}
}

// ✅ Return data, let tests assert
class LoginPage {
	async login(email: string, password: string) {
		await this.emailInput.fill(email);
		await this.passwordInput.fill(password);
		await this.submitButton.click();
	}
}

// Test handles assertions
test('successful login redirects to dashboard', async ({ page }) => {
	const loginPage = new LoginPage(page);
	await loginPage.login('user@example.com', 'password123');

	// ✅ Test controls what to verify
	await expect(page).toHaveURL('/dashboard');
});

3. Business Logic in Page Objects

// ❌ Business logic in page object
class CheckoutPage {
	async calculateTotalWithTax(subtotal: number): number {
		return subtotal * 1.08; // Tax calculation doesn't belong here
	}
}

// ✅ Page objects only handle UI interactions
class CheckoutPage {
	async getDisplayedTotal(): Promise<string> {
		return (await this.totalElement.textContent()) || '';
	}
}

// Business logic lives elsewhere (or in the test itself)
test('checkout displays correct total', async ({ page }) => {
	const checkoutPage = new CheckoutPage(page);

	const displayedTotal = await checkoutPage.getDisplayedTotal();
	const expectedTotal = calculateExpectedTotal(items); // Separate function

	expect(displayedTotal).toBe(expectedTotal);
});

When NOT to Use Page Object Model

POM isn't always the answer:

1. Simple, one-off tests

// For a quick smoke test, POM is overkill
test('homepage loads', async ({ page }) => {
	await page.goto('/');
	await expect(page.locator('h1')).toBeVisible();
});

2. Prototype/exploration phase

// When exploring new features, write direct tests first
// Refactor to POM once patterns emerge

3. Visual regression tests

// Visual tests don't benefit much from POM
test('dashboard visual regression', async ({ page }) => {
	await page.goto('/dashboard');
	await expect(page).toHaveScreenshot('dashboard.png');
});

Real Impact: Before and After

Here's what one team experienced after adopting POM:

Before POM

  • Test maintenance: 8 hours/week
  • Average PR delay: 2 days (waiting for test fixes)
  • Test flakiness: 15% failure rate
  • Onboarding time: 2 weeks for new QA engineers
  • Tests broken per UI change: Average 23 tests

After POM

  • Test maintenance: 2 hours/week (75% reduction)
  • Average PR delay: Same day
  • Test flakiness: 3% failure rate
  • Onboarding time: 3 days
  • Tests broken per UI change: 0 (update one page object)

Getting Started

Start small:

  1. Pick your most brittle test - The one that breaks most often
  2. Extract one page object - Start with LoginPage or similar
  3. Refactor 3-5 tests to use it
  4. Measure impact - Track time saved on next UI change
  5. Expand gradually - Add more page objects as needed

Example Project Structure

tests/
├── pages/
│   ├── BasePage.ts
│   ├── LoginPage.ts
│   ├── ProductsPage.ts
│   ├── CartPage.ts
│   └── CheckoutPage.ts
├── components/
│   ├── Navigation.ts
│   ├── Modal.ts
│   └── SearchBar.ts
├── api/
│   ├── UsersApi.ts
│   ├── ProjectsApi.ts
│   └── TestsApi.ts
└── specs/
    ├── auth.spec.ts
    ├── checkout.spec.ts
    └── products.spec.ts

Conclusion

Page Object Model transforms test automation from a maintenance burden into a strategic asset. By encapsulating page logic, you create tests that survive UI changes, onboard new team members faster, and scale with your application.

The initial investment in creating page objects pays dividends every time your UI changes - which, in modern development, is constantly.

Start with one page. Refactor one test. Measure the impact. Then expand.

Your future self (and your teammates) will thank you.


Want to see POM in action? Check out QA Studio's open-source test suite for real-world examples, or try our platform to organize and track your automated tests.