Page Object Model Done Right

Organize your locators and actions into reusable page objects

9 min readNode.js

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 page object
  • 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!