Page Object Model Done Right
Organize your locators and actions into reusable page objects
Page Object Model Done Right
In the previous tutorial, we learned about fixtures for clean setup and teardown. Now let's solve the "I'm copying the same locators everywhere" problem.
As your test suite grows, you'll find yourself repeating the same locators and actions across tests. The Page Object Model (POM) solves this by wrapping page interactions in reusable classes. It's not required, but trust me ā it makes larger test suites much easier to maintain.
Why Page Objects?
Without page objects, you end up with this scattered across your tests:
// In test 1
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
// In test 2 ā same thing again
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('admin123');
await page.getByRole('button', { name: 'Sign in' }).click();
// In test 3... you get the idea
Now imagine the button text changes from "Sign in" to "Log in". You'd have to update every. single. test. Yikes.
With page objects:
// In all tests
await loginPage.login('user@example.com', 'password123');
Change it once in the page object, and all tests are updated.
Your First Page Object
Let's create a page object for a login page:
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
Key points:
- Constructor takes the
pageobject - Locators are defined as class properties (initialized once)
- Methods wrap common actions
Using Page Objects in Tests
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('login with invalid password shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrongpassword');
await expect(loginPage.errorMessage).toHaveText('Invalid credentials');
});
Notice how clean the tests are? The login logic is hidden away, and tests read like plain English. How cool is that?
Make It Even Cleaner with Fixtures
"Do I really have to create page objects manually in every test?"
Nope! Use fixtures:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
type Pages = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<Pages>({
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:
import { test, expect } from './fixtures';
test('successful login', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Page Object Best Practices
1. Expose Actions, Not Implementation
Bad ā exposes internal structure:
class LoginPage {
// Don't do this
getEmailInput() {
return this.page.getByLabel('Email');
}
}
// Test has to know the implementation
await loginPage.getEmailInput().fill('user@example.com');
Good ā exposes the action:
class LoginPage {
async fillEmail(email: string) {
await this.emailInput.fill(email);
}
// Or better, combine related actions
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
2. Return New Page Objects for Navigation
When an action navigates to a new page, return that page's object:
class LoginPage {
async login(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
return new DashboardPage(this.page);
}
}
class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.getByRole('heading', { name: /welcome/i });
}
async logout(): Promise<LoginPage> {
await this.page.getByRole('button', { name: 'Logout' }).click();
return new LoginPage(this.page);
}
}
Now you can chain actions:
test('login and logout flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
const dashboardPage = await loginPage.login('user@example.com', 'pass123');
await expect(dashboardPage.welcomeMessage).toBeVisible();
const loginPageAgain = await dashboardPage.logout();
await expect(loginPageAgain.signInButton).toBeVisible();
});
3. Keep Assertions in Tests
"Should I put assertions in my page objects?"
No! Page objects shouldn't assert. They provide data and actions ā tests decide what to verify.
Bad:
class LoginPage {
async loginAndVerify(email: string, password: string) {
await this.login(email, password);
// Don't put assertions in page objects
await expect(this.page).toHaveURL('/dashboard');
}
}
Good:
class LoginPage {
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
// Test decides what to verify
test('login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'pass123');
await expect(page).toHaveURL('/dashboard');
});
Why? Because different tests might need different assertions after login. Keep page objects flexible.
4. Use Getters for Dynamic Locators
For locators that depend on parameters, use methods:
class ProductsPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// Static locator as property
readonly searchInput = this.page.getByPlaceholder('Search products');
// Dynamic locator as method
productCard(productName: string): Locator {
return this.page.getByRole('article').filter({ hasText: productName });
}
addToCartButton(productName: string): Locator {
return this.productCard(productName).getByRole('button', { name: 'Add to cart' });
}
async addToCart(productName: string) {
await this.addToCartButton(productName).click();
}
}
Component Objects
Not everything is a page. Some UI elements appear everywhere ā navigation bars, footers, modals. Create component objects for these:
// components/NavBar.ts
import { Page, Locator } from '@playwright/test';
export class NavBar {
readonly page: Page;
readonly cartIcon: Locator;
readonly cartCount: Locator;
readonly searchInput: Locator;
readonly userMenu: Locator;
constructor(page: Page) {
this.page = page;
const navbar = page.getByRole('navigation');
this.cartIcon = navbar.getByLabel('Shopping cart');
this.cartCount = navbar.getByTestId('cart-count');
this.searchInput = navbar.getByPlaceholder('Search');
this.userMenu = navbar.getByRole('button', { name: 'Account' });
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async goToCart() {
await this.cartIcon.click();
}
async openUserMenu() {
await this.userMenu.click();
}
}
Use components inside page objects:
// pages/ProductsPage.ts
import { Page } from '@playwright/test';
import { NavBar } from '../components/NavBar';
export class ProductsPage {
readonly page: Page;
readonly navbar: NavBar;
constructor(page: Page) {
this.page = page;
this.navbar = new NavBar(page);
}
async goto() {
await this.page.goto('/products');
}
async searchProduct(query: string) {
await this.navbar.search(query);
}
}
Project Structure
Here's a clean way to organize everything:
tests/
āāā fixtures.ts # Custom fixtures with page objects
āāā pages/
ā āāā LoginPage.ts
ā āāā DashboardPage.ts
ā āāā ProductsPage.ts
ā āāā CheckoutPage.ts
āāā components/
ā āāā NavBar.ts
ā āāā Footer.ts
ā āāā Modal.ts
āāā specs/
āāā auth.spec.ts
āāā products.spec.ts
āāā checkout.spec.ts
Real-World Example: E-commerce Checkout
Let's put it all together with a realistic checkout flow:
// pages/ProductsPage.ts
import { Page, Locator } from '@playwright/test';
import { NavBar } from '../components/NavBar';
import { CartPage } from './CartPage';
export class ProductsPage {
readonly page: Page;
readonly navbar: NavBar;
constructor(page: Page) {
this.page = page;
this.navbar = new NavBar(page);
}
async goto() {
await this.page.goto('/products');
}
productCard(name: string): Locator {
return this.page.locator('.product-card').filter({ hasText: name });
}
async addToCart(productName: string) {
await this.productCard(productName)
.getByRole('button', { name: 'Add to cart' })
.click();
}
async goToCart(): Promise<CartPage> {
await this.navbar.goToCart();
return new CartPage(this.page);
}
}
// pages/CartPage.ts
import { Page, Locator } from '@playwright/test';
import { CheckoutPage } from './CheckoutPage';
export class CartPage {
readonly page: Page;
readonly checkoutButton: Locator;
readonly emptyMessage: Locator;
readonly totalPrice: Locator;
constructor(page: Page) {
this.page = page;
this.checkoutButton = page.getByRole('button', { name: 'Checkout' });
this.emptyMessage = page.getByText('Your cart is empty');
this.totalPrice = page.getByTestId('total-price');
}
cartItem(productName: string): Locator {
return this.page.getByRole('listitem').filter({ hasText: productName });
}
async removeItem(productName: string) {
await this.cartItem(productName)
.getByRole('button', { name: 'Remove' })
.click();
}
async proceedToCheckout(): Promise<CheckoutPage> {
await this.checkoutButton.click();
return new CheckoutPage(this.page);
}
}
// pages/CheckoutPage.ts
import { Page, Locator } from '@playwright/test';
export class CheckoutPage {
readonly page: Page;
readonly nameInput: Locator;
readonly emailInput: Locator;
readonly addressInput: Locator;
readonly placeOrderButton: Locator;
readonly confirmationMessage: Locator;
constructor(page: Page) {
this.page = page;
this.nameInput = page.getByLabel('Full name');
this.emailInput = page.getByLabel('Email');
this.addressInput = page.getByLabel('Address');
this.placeOrderButton = page.getByRole('button', { name: 'Place order' });
this.confirmationMessage = page.getByRole('heading', { name: /order confirmed/i });
}
async fillShippingInfo(name: string, email: string, address: string) {
await this.nameInput.fill(name);
await this.emailInput.fill(email);
await this.addressInput.fill(address);
}
async placeOrder() {
await this.placeOrderButton.click();
}
}
The test using all of these:
// specs/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { ProductsPage } from '../pages/ProductsPage';
test('complete checkout flow', async ({ page }) => {
const productsPage = new ProductsPage(page);
// Browse and add products
await productsPage.goto();
await productsPage.addToCart('Wireless Headphones');
await productsPage.addToCart('Phone Case');
// Go to cart
const cartPage = await productsPage.goToCart();
await expect(cartPage.cartItem('Wireless Headphones')).toBeVisible();
await expect(cartPage.cartItem('Phone Case')).toBeVisible();
// Checkout
const checkoutPage = await cartPage.proceedToCheckout();
await checkoutPage.fillShippingInfo(
'John Doe',
'john@example.com',
'123 Main St'
);
await checkoutPage.placeOrder();
// Verify
await expect(checkoutPage.confirmationMessage).toBeVisible();
});
Clean, readable, maintainable. That's the dream.
When NOT to Use Page Objects
"Should I use POM for everything?"
Nope! Page objects aren't always the answer:
Skip POM for:
- One-off tests or quick scripts
- Very simple pages with few interactions
- Early exploration when the UI is still changing rapidly
Use POM when:
- You have repeated interactions across tests
- UI locators change frequently
- Multiple people work on the test suite
- You want readable, maintainable tests
Start without page objects. Extract them when you notice duplication.
What's Next?
Your tests are now organized and maintainable:
- Page objects wrap page interactions in reusable classes
- Define locators as properties, actions as methods
- Return new page objects when navigating to new pages
- Keep assertions in tests, not page objects
- Use component objects for reusable UI pieces
- Combine with fixtures for cleaner test setup
But what happens when tests fail? Time to learn about debugging. Let's go!