15 Days of Playwright - Day 8: Page Object Model
Page Object Model is a design pattern that helps organize test code by creating classes for each page, encapsulating elements and actions in one place.


Jhonatas Matos
Page Object Model is a design pattern that helps organize test code by creating classes for each page, encapsulating elements and actions in one place. This approach reduces code duplication and makes tests easier to maintain when the UI changes.
Today, I will explore how to implement POM with Playwright, covering basic patterns, best practices, and real-world examples.
Understanding Page Object Model
Page Object Model creates a separation between test code and page implementation. Each page gets its own class that contains all elements and actions. According to Playwright's documentation, this pattern "encapsulates page details away from tests."
Basic Page Object Structure
A Page Object is essentially a class that represents a page in your application. It encapsulates all the elements and actions that can be performed on that page. Here's what each part does:
// The class represents a specific page in your application
class LoginPage {
// Constructor receives the page instance and defines all element locators
constructor(page) {
this.page = page;
this.usernameInput = page.locator('#user-name');
this.passwordInput = page.locator('#password');
this.loginButton = page.locator('#login-button');
}
// Methods represent actions users can perform on this page
async navigate() {
await this.page.goto('https://www.saucedemo.com/');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
Key components:
- Constructor: Defines all element locators for the page
- Methods: Represent user actions (navigation, form submissions, etc.)
- Encapsulation: All page-specific code lives in one place
This structure keeps your test code clean and separates the "what" (test logic) from the "how" (page implementation).
Using Page Objects in Tests
Once you've created your page objects, using them in tests is straightforward. The page object handles all the interaction details, making your tests cleaner and more focused on the test logic itself.
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
test('Successful login using POM', async ({ page }) => {
// Create an instance of the LoginPage
const loginPage = new LoginPage(page);
// Use the page object methods instead of raw selectors
await loginPage.navigate();
await loginPage.login('standard_user', 'secret_sauce');
// Assertions remain in the test
await expect(page.locator('.title')).toHaveText('Products');
});
What this achieves:
- Clean Tests: No more duplicated selectors across multiple test files
- Maintainability: UI changes only require updates in one place (the page object)
- Readability: Tests focus on the "what" rather than the "how"
- Reusability: The same login method can be used across all tests
This separation makes your test suite more scalable and easier to maintain as your application grows.
Component Objects
For reusable UI components, create separate component classes. Playwright recommends this approach for complex applications with shared UI elements.
Header Component
class HeaderComponent {
constructor(page) {
this.page = page;
this.cartIcon = page.locator('.shopping_cart_link');
this.cartBadge = page.locator('.shopping_cart_badge');
}
async getCartItemCount() {
return await this.cartBadge.textContent();
}
async goToCart() {
await this.cartIcon.click();
}
}
Using Components in Page Objects
class InventoryPage {
constructor(page) {
this.page = page;
this.header = new HeaderComponent(page);
this.productItems = page.locator('.inventory_item');
}
async addItemToCart(itemIndex) {
await this.productItems.nth(itemIndex)
.locator('.btn_inventory').click();
}
}
Advanced Patterns
Base Page Class
As your test suite grows, you might find common functionality used across multiple pages. Instead of duplicating code, you can create a base page class that provides shared methods.
// Base class contains common functionality used by all pages
class BasePage {
constructor(page) {
this.page = page;
}
// Shared utility methods
async waitForTimeout(delay) {
await this.page.waitForTimeout(delay);
}
async getTitle() {
return await this.page.title();
}
}
// Other pages extend the base class to inherit common functionality
class LoginPage extends BasePage {
constructor(page) {
super(page); // Call parent constructor
this.usernameInput = page.locator('#user-name');
this.passwordInput = page.locator('#password');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.page.locator('#login-button').click();
await this.waitForTimeout(1000); // Using inherited method
}
}
When to use inheritance:
- When multiple pages share common functionality
- For utility methods used across different page types
- To enforce consistent patterns across all pages
Consider composition over inheritance for more complex scenarios, as it provides better flexibility.
Real-World Example
Complete Test Flow with POM
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { InventoryPage } from '../pages/inventory-page';
import { CartPage } from '../pages/cart-page';
test('Complete purchase flow with POM', async ({ page }) => {
// Login
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('standard_user', 'secret_sauce');
// Add items to cart
const inventoryPage = new InventoryPage(page);
await inventoryPage.addItemToCart(0);
await inventoryPage.addItemToCart(1);
// Verify cart count
const cartCount = await inventoryPage.header.getCartItemCount();
expect(cartCount).toBe('2');
// Go to cart
await inventoryPage.header.goToCart();
// Verify cart items
const cartPage = new CartPage(page);
const itemNames = await cartPage.getItemNames();
expect(itemNames).toHaveLength(2);
});
Best Practices
Based on Playwright's official recommendations, here are the key practices:
Category | Best Practice (Do) | Anti-Pattern (Avoid) | Why It Matters |
---|---|---|---|
Page Object Size | Create small, focused page objects | Overly large page objects | Smaller classes are easier to maintain and understand |
Method Design | Use descriptive method names | Vague or technical method names | Clear names make tests more readable and intentional |
Method Returns | Return values from page object methods | Void methods with side effects | Returning values enables better assertions and test flexibility |
Responsibility | Keep test logic out of page objects | Test assertions in page objects | Separation of concerns makes both tests and page objects more maintainable |
Architecture | Prefer composition | Complex inheritance hierarchies | Composition is more flexible and easier to understand than deep inheritance |
Business Logic | UI interactions only | Business logic in page objects | Page objects should handle how to interact, not what the business rules are |
Project Structure
Maintaining a clear folder structure is crucial for scalable test suites. Here's a recommended organization:
tests/
pages/ // All page objects
login-page.js
inventory-page.js
cart-page.js
components/ // Reusable UI components
header-component.js
modal-component.js
specs/ // Test files
login.spec.js
cart.spec.js
Why this structure works:
- Separation of concerns: Page objects separated from test specs
- Easy navigation: Clear where to find page vs test code
- Scalability: Easy to add new pages/components
- Maintainability: Related files are grouped together
This structure helps teams collaborate effectively and keeps your codebase organized as it grows.
Pratical Example
Login Page Implementation
class LoginPage {
constructor(page) {
this.page = page;
this.usernameInput = page.locator('#user-name');
this.passwordInput = page.locator('#password');
this.loginButton = page.locator('#login-button');
this.errorMessage = page.locator('[data-test="error"]');
}
async navigate() {
await this.page.goto('https://www.saucedemo.com/');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
}
module.exports = { LoginPage };
Test Using Login Page
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
test.describe('Login Tests with POM', () => {
test('Successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('standard_user', 'secret_sauce');
await expect(page.locator('.title')).toHaveText('Products');
});
test('Failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('invalid_user', 'wrong_password');
const errorMessage = await loginPage.getErrorMessage();
expect(errorMessage).toContain('Username and password do not match');
});
});
See the complete Page Object Model implementation:
Link to GitHub project
Conclusion
Page Object Model provides a structured approach to test organization in Playwright. By creating classes for each page and component, you can reduce code duplication, improve maintainability, and create cleaner test code.
Key benefits include:
- Reduced code duplication through centralized selectors
- Improved test readability with meaningful method names
- Easier maintenance with single responsibility classes
- Better collaboration through consistent patterns
With POM, your test suite becomes more scalable and easier to maintain as your application grows.
Official Documentation: For more advanced patterns and detailed examples, refer to the Playwright Page Object Model documentation.
Thank you for reading and see you in the next lesson! ☕💜