Playwright Page Object Model: Pattern Guide with Examples

Wrap every page of your app in a single class so tests call loginPage.signIn() instead of repeating selectors. This guide walks through Playwright POM from scratch page classes, fixtures, and the patterns that scale.

What is the page object model and why does it matter

Definition: The page object model (POM) is a design pattern where each page or major component of your application gets its own class. That class stores every locator and user action for that page, so tests never touch raw selectors directly. POM enforces a single source of truth for UI interactions.

Think of it like a remote control for a TV. You press "volume up" without caring which circuit board signal fires. A page object works the same way for your tests. You call loginPage.signIn(user, pass) and the class handles the three clicks and two fills behind the scenes.

Without POM, a typical Playwright test automation project looks like this after six months:

The same page.locator('#email') scattered across 30 spec files

A renamed CSS class breaks tests in places you did not expect

New team members struggle to understand what each test actually validates

Pull request reviews take longer because reviewers trace selectors instead of reading intent

POM solves all four problems by giving you a single source of truth for each page. When a locator changes, you open one class file, update one line, and every test that depends on it keeps passing.

Tip: POM originated in Selenium but fits Playwright perfectly because locators are lazily evaluated, resolving only during actions.

The Playwright official documentation recommends POM as the go-to approach for structuring large test suites. According to the 2024 State of JS survey, Playwright crossed 50% awareness among JavaScript developers. As adoption grows, structuring code with the page object pattern becomes less of a nice-to-have and more of a baseline expectation for any serious playwright test structure.

Setting up your project for POM

Before writing any page class, you need a clean folder structure. A well-organized project makes it obvious where to add new page objects and where to find existing ones.

Prerequisites

Make sure you have Node.js 18+ installed. Then initialize a new Playwright project:

terminal
npm init playwright@latest

The installer asks a few questions. Pick TypeScript when prompted. TypeScript adds type safety to your page objects, which catches locator typos at compile time instead of runtime.

Note: JavaScript works too. TypeScript simply provides the added benefits of autocomplete and compile-time type checking.

Here is a folder layout that scales from 5 tests to 500:

project-structure
# project-structure
playwright-project/
├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   ├── CheckoutPage.ts
│   └── components/
│       ├── NavbarComponent.ts
│       └── SearchComponent.ts
├── tests/
│   ├── login.spec.ts
│   ├── dashboard.spec.ts
│   └── checkout.spec.ts
├── fixtures/
│   └── base.ts
├── test-data/
│   └── users.json
├── playwright.config.ts
└── package.json

Three key decisions in this layout:

pages/ is separate from tests/. Page objects are utilities, not tests. Keeping them apart prevents circular imports and makes the purpose of each file clear at a glance.

components/ lives inside pages/. Reusable UI pieces like navbars and modals get their own classes. A NavbarComponent can be imported into DashboardPage, CheckoutPage, or any page that shows the nav.

fixtures/ holds your custom fixture file. This is where you wire page objects into the test runner using test.extend(). More on this in a later section.

Configuring playwright.config.ts

Your config file does not need anything POM-specific. But a few settings help when you are building out page objects:

playwright.config.ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  retries: 1,
  use: {
    baseURL: 'https://your-app.com',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

Setting baseURL is important here. Your page objects can use relative URLs like await this.page.goto('/login') instead of hardcoding the full domain. This makes switching between staging and production a single config change.

Project tree at a glance: Keep three root folders: pages/ (classes), tests/ (specs), and fixtures/ (setup). This 1:1 mapping ensures organized scalability.

Track every test run in real time
See pass/fail trends across branches with TestDino
Try free CTA Graphic

Building your first Playwright page object class

A page object class has three parts: a constructor that receives the Playwright page instance, locator definitions that point to UI elements, and methods that wrap user actions.

Here is a complete LoginPage class:

pages/LoginPage.ts
// pages/LoginPage.ts
import { type Locator, type Page } 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.getByTestId('login-error');
  }
  async goto() {
    await this.page.goto('/login');
  }
  async signIn(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.signInButton.click();
  }
}

A few things to notice in this code:

Locators use getByRole and getByLabel instead of CSS selectors. These Playwright locators are resilient because they match how real users see the page, not how the DOM is structured.

Every locator is readonly. This prevents tests from accidentally overwriting a locator at runtime.

The signIn method hides implementation details. Tests call loginPage.signIn('[email protected]', 'pass123') without knowing whether the form has two fields or ten.

No assertions live in the page object. The page object performs actions. The test file checks results. Mixing both creates tight coupling that makes refactoring harder.

Tip: Prefer getByRole and getByLabel over CSS selectors. They tie to semantics instead of structure, surviving UI changes better.

Building a second page object

After login, users land on a dashboard. Here is a DashboardPage class that models that:

pages/DashboardPage.ts
// pages/DashboardPage.ts
import { type Locator, type Page } from '@playwright/test';
export class DashboardPage {
  readonly page: Page;
  readonly welcomeHeading: Locator;
  readonly projectList: Locator;
  readonly createProjectButton: Locator;
  readonly searchInput: Locator;
  constructor(page: Page) {
    this.page = page;
    this.welcomeHeading = page.getByRole('heading', { name: /welcome/i });
    this.projectList = page.getByTestId('project-list');
    this.createProjectButton = page.getByRole('button', { name: 'New project' });
    this.searchInput = page.getByPlaceholder('Search projects');
  }
  async createProject(name: string) {
    await this.createProjectButton.click();
    await this.page.getByLabel('Project name').fill(name);
    await this.page.getByRole('button', { name: 'Create' }).click();
  }
  async searchProject(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
  }
}

Notice how createProject combines multiple steps into a single method call. The test does not need to know that creating a project involves clicking a button, filling a modal, and confirming. This is the core advantage of the playwright page object model pattern. In practice, teams that adopt this approach early report spending significantly less time on selector-related maintenance as their suite grows.

Modeling reusable components

Not everything is a full page. Navigation bars, sidebars, and modals appear across multiple pages. Model these as component classes:

pages/components/NavbarComponent.ts
// pages/components/NavbarComponent.ts
import { type Locator, type Page } from '@playwright/test';
export class NavbarComponent {
  readonly page: Page;
  readonly profileMenu: Locator;
  readonly logoutButton: Locator;
  readonly notificationBell: Locator;
  constructor(page: Page) {
    this.page = page;
    this.profileMenu = page.getByTestId('profile-menu');
    this.logoutButton = page.getByRole('menuitem', { name: 'Logout' });
    this.notificationBell = page.getByLabel('Notifications');
  }
  async logout() {
    await this.profileMenu.click();
    await this.logoutButton.click();
  }
}

Then import this component into any page that uses the navbar:

pages/DashboardPage.ts
// pages/DashboardPage.ts (updated)
import { NavbarComponent } from './components/NavbarComponent';
export class DashboardPage {
  readonly navbar: NavbarComponent;
  // ... other locators
  constructor(page: Page) {
    this.navbar = new NavbarComponent(page);
    // ... other setup
  }
}

Now your test can call dashboardPage.navbar.logout() directly. This composition approach keeps each class focused on one responsibility, which matters a lot when reducing test maintenance across a growing suite.

Writing tests that use page objects

With page classes in place, your test files become short, readable, and focused entirely on what you are verifying.

Basic test using page objects

tests/login.spec.ts
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test('user can sign in with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);
  await loginPage.goto();
  await loginPage.signIn('[email protected]', 'securePass123');
  await expect(dashboardPage.welcomeHeading).toBeVisible();
  await expect(page).toHaveURL(/dashboard/);
});
test('shows error for invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.signIn('[email protected]', 'wrongPassword');
  await expect(loginPage.errorMessage).toContainText('Invalid email or password');
});

Compare this to a test without POM:

tests/login-no-pom.spec.ts
// tests/login-no-pom.spec.ts (without POM - do not copy)
test('user can sign in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('securePass123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
});

The non-POM version looks simpler for one test. But multiply it by 50 tests, change one label from "Email" to "Email address", and you are updating every single file. With POM, that fix lives in LoginPage.ts alone.

Note: Keep assertions in test files, never in page objects. This ensures your classes remain reusable across all test scenarios.

Testing multi-page flows

Real user journeys span multiple pages. POM makes these flows read like a story:

tests/checkout.spec.ts
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { CheckoutPage } from '../pages/CheckoutPage';
test('complete purchase flow', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);
  const checkoutPage = new CheckoutPage(page);
  await loginPage.goto();
  await loginPage.signIn('[email protected]', 'buyerPass');
  await dashboardPage.searchProject('Widget Pro');
  await checkoutPage.addToCart();
  await checkoutPage.applyDiscount('SAVE10');
  await checkoutPage.completePurchase();
  await expect(checkoutPage.confirmationMessage).toContainText('Order confirmed');
});

Each line describes a user action in plain English. Anyone reviewing this PR, even someone who has never touched the codebase, can understand what the test validates. This readability is one of the biggest reasons the playwright POM pattern is the default recommendation for teams running Playwright scripts at scale.

Keeping test data separate

Hardcoded credentials inside tests create maintenance problems. Move test data to JSON files:

test-data/users.json
// test-data/users.json
{
  "validUser": {
    "email": "[email protected]",
    "password": "securePass123"
  },
  "invalidUser": {
    "email": "[email protected]",
    "password": "wrongPassword"
  }
}

Then import and use them in your tests:

tests/login.spec.ts
// tests/login.spec.ts (updated)
import users from '../test-data/users.json';
test('user can sign in with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.signIn(users.validUser.email, users.validUser.password);
  // assertions...
});

This separation means credentials, URLs, and test inputs live outside your page objects and test logic. When you need to update test data for a new environment, you edit one JSON file instead of hunting through dozens of specs.

Terminal Output: Because specs call named methods instead of selectors, your npx playwright test output reads like clear, human-readable sentences.

Using Playwright fixtures with POM

Manually creating page objects with new LoginPage(page) in every test works, but it adds boilerplate. Playwright has a built-in solution for this: fixtures. Fixtures let you pre-configure page objects and inject them directly into your test function signature.

What are fixtures

Definition: Fixtures are reusable setup/teardown blocks that automatically inject resources (like page objects) into tests.

The test.extend() method creates custom fixtures. Here is how to wire your page objects into fixtures:

fixtures/base.ts
// fixtures/base.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { CheckoutPage } from '../pages/CheckoutPage';
type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  checkoutPage: CheckoutPage;
};
export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page));
  },
});
export { expect } from '@playwright/test';

Using fixture-based page objects in tests

Now your tests import test from the fixture file instead of from @playwright/test:

tests/login.spec.ts
// tests/login.spec.ts (with fixtures)
import { test, expect } from '../fixtures/base';
test('user can sign in with valid credentials', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.signIn('[email protected]', 'securePass123');
  await expect(dashboardPage.welcomeHeading).toBeVisible();
});
test('shows error for invalid credentials', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.signIn('[email protected]', 'wrongPassword');
  await expect(loginPage.errorMessage).toContainText('Invalid email or password');
});

Notice what changed. There is no new LoginPage(page) anywhere. You just add loginPage to the destructured test arguments and Playwright creates it for you. This eliminates repetitive setup code across every spec file.

Tip: Fixtures support setup steps. You can log in inside the fixture so tests start pre-authenticated, speeding up execution.

Adding setup steps to fixtures

For tests that always need a logged-in user, move the login step into the fixture:

fixtures/base.ts
// fixtures/base.ts (with auth fixture)
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type AuthFixtures = {
  authenticatedPage: DashboardPage;
};
export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.signIn('[email protected]', 'securePass123');
    await use(new DashboardPage(page));
  },
});
export { expect } from '@playwright/test';

Now any test that needs a logged-in dashboard simply requests authenticatedPage:

tests/dashboard.spec.ts
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures/base';
test('can create a new project', async ({ authenticatedPage }) => {
  await authenticatedPage.createProject('My New Project');
  await expect(authenticatedPage.projectList).toContainText('My New Project');
});

This keeps authentication logic out of individual tests. If your login flow changes, you update the fixture once, and every test that depends on it adapts automatically. Teams managing Playwright test management workflows at scale rely on this pattern to avoid duplication.

Debug failures with AI insights
Get root cause analysis on every failed Playwright test
Explore CTA Graphic

Advanced Playwright POM patterns for production

The basics of POM cover most use cases. But production projects with 200+ tests often need patterns that go beyond single-page classes.

Base page class with shared methods

Most pages share common elements like headers, footers, and loading spinners. A base class avoids repeating these across every page object:

pages/BasePage.ts
// pages/BasePage.ts
import { type Locator, type Page } from '@playwright/test';
export class BasePage {
  readonly page: Page;
  readonly loadingSpinner: Locator;
  readonly toastNotification: Locator;
  constructor(page: Page) {
    this.page = page;
    this.loadingSpinner = page.getByTestId('loading-spinner');
    this.toastNotification = page.getByRole('alert');
  }
  async waitForPageLoad() {
    await this.loadingSpinner.waitFor({ state: 'hidden' });
  }
  async getToastMessage(): Promise<string> {
    return await this.toastNotification.textContent() ?? '';
  }
}

Then extend it in your specific page objects:

pages/SettingsPage.ts
// pages/SettingsPage.ts
import { type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsPage extends BasePage {
  readonly profileNameInput: Locator;
  readonly saveButton: Locator;
  constructor(page: Page) {
    super(page);
    this.profileNameInput = page.getByLabel('Display name');
    this.saveButton = page.getByRole('button', { name: 'Save changes' });
  }
  async updateDisplayName(name: string) {
    await this.profileNameInput.fill(name);
    await this.saveButton.click();
    await this.waitForPageLoad();
  }
}

The SettingsPage inherits waitForPageLoad() and getToastMessage() from BasePage. Every page in your app gets these methods automatically. This inheritance pattern is one of the reasons the playwright POM approach scales well beyond 100 test files.

Returning page objects from navigation methods

When a method triggers navigation to a different page, return the new page object. This creates a fluent API where the test naturally flows from one page to the next:

pages/LoginPage.ts
// pages/LoginPage.ts (with navigation return)
import { type Locator, type Page } from '@playwright/test';
import { DashboardPage } from './DashboardPage';
export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly signInButton: 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' });
  }
  async signIn(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);
  }
}

The test then chains naturally:

tests/login.spec.ts
// tests/login.spec.ts (with fluent API)
test('user signs in and creates a project', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  const dashboard = await loginPage.signIn('[email protected]', 'securePass123');
  await dashboard.createProject('Project Alpha');
});

Handling dynamic elements with page object methods

Some pages have elements that appear based on state. For example, a table with dynamic rows. Handle these with parameterized methods:

pages/ProjectListPage.ts
// pages/ProjectListPage.ts
import { type Locator, type Page } from '@playwright/test';
export class ProjectListPage {
  readonly page: Page;
  readonly projectRows: Locator;
  constructor(page: Page) {
    this.page = page;
    this.projectRows = page.getByTestId('project-row');
  }
  getProjectByName(name: string): Locator {
    return this.projectRows.filter({ hasText: name });
  }
  async deleteProject(name: string) {
    const row = this.getProjectByName(name);
    await row.getByRole('button', { name: 'Delete' }).click();
    await this.page.getByRole('button', { name: 'Confirm' }).click();
  }
  async getProjectCount(): Promise<number> {
    return await this.projectRows.count();
  }
}

This pattern handles dynamic content cleanly without hardcoding row indices or relying on brittle CSS nth-child selectors. It works well alongside Playwright component testing where you need to interact with repeated UI elements. Teams using the playwright pom pattern at scale often rely on parameterized locators like this to keep their page objects flexible.

POM with Playwright test.step for better reporting

Wrapping page object calls in test.step() produces clearer Playwright HTML reports:

tests/settings.spec.ts
// tests/settings.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { SettingsPage } from '../pages/SettingsPage';
test('user updates display name', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const settingsPage = new SettingsPage(page);
  await test.step('Sign in', async () => {
    await loginPage.goto();
    await loginPage.signIn('[email protected]', 'securePass123');
  });
  await test.step('Update profile settings', async () => {
    await page.goto('/settings');
    await settingsPage.updateDisplayName('Jane Doe');
  });
  await test.step('Verify success message', async () => {
    const toast = await settingsPage.getToastMessage();
    expect(toast).toContain('Profile updated');
  });
});

Each step appears as a collapsible section in the HTML report. When a test fails, you can see exactly which step broke without reading through every action. Teams using Playwright reporting tools find this structure invaluable for debugging Playwright tests in CI.

Pattern When to use Benefit
Base page class Shared elements across pages (loaders, toasts, nav) Reduces duplication in page objects
Navigation return types Methods that trigger page transitions Fluent test API, type-safe page transitions
Component composition Reusable UI blocks (navbar, sidebar, modal) Single responsibility per class
Dynamic element methods Tables, lists, repeating elements Handles runtime content without brittle selectors
test.step() wrapping Complex multi-action flows Clear HTML report sections, easier debugging

HTML Reports: Using test.step() creates collapsible sections in your HTML report, making it instantly clear which part of a flow failed.

Common Playwright POM mistakes and how to fix them

Even experienced teams make these mistakes when adopting the playwright page object model. Catching them early saves hours of refactoring later.

Mistake 1: Putting assertions inside page objects

This is the most common anti-pattern. It looks like this:

pages/LoginPage.ts
// BAD: assertions in page object
async signIn(email: string, password: string) {
  await this.emailInput.fill(email);
  await this.passwordInput.fill(password);
  await this.signInButton.click();
  await expect(this.page).toHaveURL(/dashboard/); // Do not do this
}

The problem is that this signIn method can only be used for successful login tests. If you want to test invalid credentials, you need a separate method. Keep assertions in the test file and keep page objects action-only.

Mistake 2: Creating god objects

A god object is a single class that models your entire application. It has 50 locators, 30 methods, and handles everything from login to checkout. This defeats the purpose of POM entirely.

Follow the single-responsibility principle. One class per page or major component. If a class grows beyond 100 lines, consider breaking it into smaller component classes.

Mistake 3: Using fragile selectors

pages/LoginPage.ts
// BAD: fragile selectors
this.submitBtn = page.locator('div.form-container > button:nth-child(3)');
this.emailField = page.locator('#root > div > form > input.email');

These selectors break the moment a developer adds a wrapper div or reorders elements. Use semantic locators instead:

pages/LoginPage.ts
// GOOD: semantic selectors
this.submitBtn = page.getByRole('button', { name: 'Submit' });
this.emailField = page.getByLabel('Email');

Mistake 4: Ignoring the baseURL config

Hardcoding full URLs in page objects creates environment-switching headaches:

pages/LoginPage.ts
// BAD
async goto() {
  await this.page.goto('https://staging.myapp.com/login');
}
// GOOD
async goto() {
  await this.page.goto('/login');
}

Use the baseURL setting in playwright.config.ts and keep your page objects environment-agnostic.

Mistake 5: Not using TypeScript readonly

Without readonly, a test can accidentally overwrite a locator:

example.spec.ts
// Accidental overwrite without readonly
loginPage.emailInput = page.locator('.wrong-selector');

TypeScript readonly properties catch this mistake at compile time. It is a small addition that prevents confusing runtime bugs.

Warning: Avoid taking screenshots in every page method. It slows down tests. Use screenshot: 'only-on-failure' in your config instead.

When not to use POM

POM is not always the right choice. For quick prototypes, one-off smoke tests, or suites with fewer than five specs, the overhead of creating separate class files can slow you down more than it helps. In those cases, inline selectors inside the test file are perfectly fine.

The break-even point is usually around 10-15 tests touching the same page. Once you cross that threshold, the maintenance cost of scattered selectors exceeds the upfront cost of writing a page class. If your project is growing toward that number, start with POM from the beginning rather than migrating later.

Conclusion

The playwright page object model turns a growing test suite from a maintenance nightmare into a well-organized codebase. You have seen how to structure your project, build page classes with resilient locators, wire them with fixtures, and apply advanced patterns like base classes, component composition, and fluent navigation returns.

Here are the key takeaways:

Keep locators in page objects, assertions in test files, and shared logic in fixtures.

Use getByRole and getByLabel instead of CSS selectors.

Break large classes into focused components.

Always set readonly on your locator properties.

Use test.extend() to eliminate boilerplate page object creation.

Start with a simple LoginPage class. Once that pattern feels natural, expand to other pages and introduce fixtures. The playwright test structure scales with you. Whether your suite has 10 tests or 1,000, POM keeps the maintenance cost flat.

And when those tests start running in CI/CD pipelines, the real value of POM shows up in pull request reviews where every change is a clear, one-line update to a single class file.

Monitor your POM-based tests at scale
TestDino tracks test health across every branch and pipeline
Start free CTA Graphic

FAQs

What is the page object model in Playwright?
The page object model (POM) in Playwright is a design pattern where each page of your application is represented by a class. That class contains all the locators and user actions for that page. Tests interact with these classes instead of writing selectors directly. The Playwright official docs recommend this approach for organizing large test suites.
Should I use TypeScript or JavaScript for Playwright POM?
Both work. TypeScript is recommended because it catches locator typos at compile time, provides autocomplete in your IDE, and enforces readonly properties on locators. If your team is already using JavaScript, you can still follow the POM pattern without any issues.
Where should I put assertions when using page objects?
Assertions belong in your test files, never inside page objects. This keeps page objects reusable across different test scenarios. The same loginPage.signIn() method works for testing valid logins, invalid logins, and edge cases.
What is the difference between POM and fixtures in Playwright?
POM is a design pattern for organizing locators and actions into classes. Fixtures are a Playwright feature that automates the creation and injection of those classes into your tests. They work together: you define page objects as classes and then expose them as fixtures using test.extend().
How do I handle components that appear on multiple pages?
Create separate component classes for shared UI elements like navbars, sidebars, or modals. Import and compose them inside your page objects. For example, DashboardPage can have a navbar property that is an instance of NavbarComponent. Tests access it as dashboardPage.navbar.logout().
Can I use page objects with Playwright's codegen tool?
Yes. You can use Playwright codegen to generate the initial locators, then move them into page object classes. The codegen output gives you a starting point, but you should refactor the generated selectors into semantic locators like getByRole and getByLabel for better resilience.
How do page objects help with CI/CD?
Page objects reduce maintenance when tests run in CI/CD pipelines. When a UI element changes, you update one class file instead of every test that interacts with that element. This makes CI failures faster to fix and keeps your pipeline reliable.
How many page objects should I create?
Create one page object per distinct page or major UI section. A typical web app with login, dashboard, settings, and checkout pages needs four page objects plus component classes for shared elements like navbars. Avoid creating a single "god object" that covers your entire app. If a class grows beyond 100 lines, split it into smaller focused classes.
Ayush Mania

Forward Development Engineer

Ayush Mania is a Forward Development Engineer at TestDino, focusing on platform infrastructure, CI workflows, and reliability engineering. His work involves building systems that improve debugging, failure detection, and overall test stability.

He contributes to architecture design, automation pipelines, and quality engineering practices that help teams run efficient development and testing workflows.

Get started fast

Step-by-step guides, real-world examples, and proven strategies to maximize your test reporting success