In the previous chapter, we learned how to organize a Playwright project with a professional folder structure. Now, it’s time to dive deeper into one of the most powerful patterns in test automation, the Page Object Model (POM).
The POM pattern is the foundation of maintainable test automation. It helps you keep your code clean, reusable, and scalable, whether you’re a beginner writing your first test or an advanced engineer managing hundreds of tests.
In this chapter, we’ll start with a beginner-friendly explanation and then move into intermediate and advanced design patterns, including base pages, composition, and fixtures integration.
What is the Page Object Model?
The Page Object Model (POM) is a design pattern used in test automation to represent web pages as code typically as classes. Each page class encapsulates the page’s structure (locators) and behavior (actions).
Instead of writing locators and actions directly inside tests, you put them inside a page class, then call those methods from your test files.
This makes your tests:
- Easier to read
- Easier to maintain
- Less repetitive
Why use POM in Playwright?
Without POM:
test('Login test', async ({ page }) => {
await page.goto('https://www.saucedemo.com/');
await page.fill('#user-name', 'standard_user');
await page.fill('#password', 'secret_sauce');
await page.click('#login-button');
await expect(page).toHaveURL(/inventory/);
});
With POM:
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('standard_user', 'secret_sauce');
await expect(page).toHaveURL(/inventory/);
See the difference? The test becomes shorter, cleaner, and easier to understand.
Step 1: Creating your first Page Object
Let’s start with a simple example, a LoginPage.
pages/LoginPage.js:
export class LoginPage {
constructor(page) {
this.page = page;
this.username = page.locator('#user-name');
this.password = page.locator('#password');
this.loginBtn = page.locator('#login-button');
}
async goto() {
await this.page.goto('https://www.saucedemo.com/');
}
async login(username, password) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginBtn.click();
}
}
tests/login.spec.js:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('user can login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('standard_user', 'secret_sauce');
await expect(page).toHaveURL(/inventory/);
});
Tip: Keep page actions simple and meaningful. e.g.,
login(),searchProduct(),addToCart().
Step 2: Adding assertions inside page classes
Sometimes you can include lightweight assertions directly inside your page classes. For example:
export class DashboardPage {
constructor(page) {
this.page = page;
this.title = page.locator('.title');
}
async verifyUserLoggedIn() {
await expect(this.title).toHaveText('Products');
}
}
Now your test can look even cleaner:
const dashboardPage = new DashboardPage(page);
await dashboardPage.verifyUserLoggedIn();
Use this approach sparingly, it’s best to keep page classes focused on actions, not test logic.
Step 3: Organizing multiple pages
As your project grows, you’ll have multiple page classes.
Example folder structure:
pages/
├── BasePage.js
├── LoginPage.js
├── DashboardPage.js
└── CartPage.js
Each page represents a unique part of your app.
Step 4: Creating a Base Page
A BasePage is a parent class that contains common methods shared across multiple pages, things like navigation, waiting for elements, or taking screenshots.
pages/BasePage.js:
export class BasePage {
constructor(page) {
this.page = page;
}
async navigateTo(url) {
await this.page.goto(url);
}
async takeScreenshot(name) {
await this.page.screenshot({ path: `reports/${name}.png` });
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
Now your specific pages can extend this class:
pages/LoginPage.js:
import { BasePage } from './BasePage.js';
export class LoginPage extends BasePage {
constructor(page) {
super(page);
this.username = page.locator('#user-name');
this.password = page.locator('#password');
this.loginBtn = page.locator('#login-button');
}
async login(username, password) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginBtn.click();
}
}
This way, all your page objects share common utility methods from BasePage.
Step 5: Page composition
Sometimes, instead of using inheritance, you can use composition, where one page class contains another as a property.
Example: DashboardPage may include a HeaderComponent and a SidebarComponent.
pages/components/HeaderComponent.js:
export class HeaderComponent {
constructor(page) {
this.page = page;
this.cartIcon = page.locator('#shopping_cart_container');
this.menuButton = page.locator('#react-burger-menu-btn');
}
async openMenu() {
await this.menuButton.click();
}
}
pages/DashboardPage.js:
import { HeaderComponent } from './components/HeaderComponent.js';
export class DashboardPage {
constructor(page) {
this.page = page;
this.header = new HeaderComponent(page);
this.title = page.locator('.title');
}
async verifyDashboardTitle() {
await expect(this.title).toHaveText('Products');
}
}
Now in your test:
const dashboardPage = new DashboardPage(page);
await dashboardPage.header.openMenu();
await dashboardPage.verifyDashboardTitle();
This approach keeps code modular and promotes reusability.
Step 6: Using POM with Fixtures
You can combine POM with fixtures to make your tests even cleaner.
fixtures/pageFixture.js:
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
}
});
Then your tests become extremely readable:
import { test, expect } from '../fixtures/pageFixture';
test('user can log in and view dashboard', async ({ loginPage, dashboardPage }) => {
await loginPage.goto();
await loginPage.login('standard_user', 'secret_sauce');
await dashboardPage.verifyDashboardTitle();
});
Now every test automatically has access to the page objects it needs.
Best practices for Page Object Model
1. Keep actions meaningful – Use methods that represent what a user does, not how it’s done.
2. Avoid hardcoding data – Use environment variables or data files instead.
3. Keep locators private – Access them only through methods to avoid external dependencies.
4. Don’t over-engineer – Only create page classes when they’re actually reused.
5. Use composition for shared UI components – (e.g., header, sidebar, modal dialogs.)
6. Use BasePage for common methods – It reduces repetition and centralizes shared logic.
7. Use fixtures for dependency injection – Makes tests cleaner and easier to extend.
Common mistakes to avoid
1. Writing all actions directly in test files leads to repetition.
2. Mixing assertions with business logic keep verifications separate from actions.
3. Creating huge page classes break them into smaller, modular components.
4. Ignoring naming conventions, inconsistent naming causes confusion.
Exercise
Task 1: Create a BasePage class with common methods like navigateTo() and takeScreenshot().
Task 2: Create two page objects, LoginPage and DashboardPage, that extend BasePage.
Task 3: Use a fixture to inject your page objects into a test.
Bonus: Add a reusable component (like Header or Sidebar) and call its methods inside a test.
Cheat Sheet
| Concept | Example |
|---|---|
| Page class | class LoginPage {} |
| Locator | this.page.locator('#username') |
| Action | async login(u, p) |
| Base Page | Common navigation/screenshot methods |
| Component | HeaderComponent inside another page |
| Fixture integration | Extend base test to include POMs |
Summary
In this chapter, you learned:
- What the Page Object Model (POM) is and why it’s essential.
- How to create basic and advanced page classes.
- How to use BasePage and composition for scalability.
- How to integrate POMs with Playwright fixtures.
You now have the skills to build clean, modular, and enterprise-grade test automation frameworks.
