Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›Playwright›Page Object Model (POM) design pattern

Playwright Best Practices and Ecosystem

Page Object Model (POM) design pattern

The Page Object Model (POM) is a design pattern that abstracts UI interactions into dedicated classes, representing specific pages or components of an application. By decoupling test logic from technical implementation details, it transforms brittle automation scripts into maintainable, readable codebases. You should adopt this pattern as soon as your test suite expands beyond a few simple scenarios to prevent redundant element selectors and logic duplication.

The Core Philosophy of Abstraction

At its heart, the Page Object Model is about managing change. When you write automation scripts by directly targeting selectors inside your tests, you create a rigid dependency between the test logic and the application's underlying HTML structure. If a developer changes a class name or refactors a form ID, every test referencing those elements breaks simultaneously, leading to a maintenance nightmare. By encapsulating elements within a class, you create a single source of truth for the structure of that page. This abstraction allows you to interact with the page using high-level methods that describe user intent, such as 'login' or 'submitForm', rather than low-level technical commands. Consequently, if the UI implementation evolves, you only need to update the selector in one location. This promotes a clean separation of concerns, ensuring that test files remain focused on business scenarios and validation, while page classes remain focused on the technical mechanics of the application interface.

class LoginPage {
  constructor(page) {
    this.page = page;
    // Define locators for page elements
    this.usernameInput = page.locator('#user-name');
    this.passwordInput = page.locator('#password');
    this.loginButton = page.locator('#login-button');
  }

  async login(user, pass) {
    await this.usernameInput.fill(user);
    await this.passwordInput.fill(pass);
    await this.loginButton.click();
  }
}

Encapsulating Complex Interactions

Beyond simple element selection, POM allows you to wrap complex chains of interactions into readable methods that mimic actual user behavior. Frequently, completing a single task in a web application requires multiple steps, such as clicking a dropdown, waiting for a transition animation, selecting an item, and verifying a notification. If these steps are repeated across multiple test suites, you invite inconsistency. By placing these routines inside the Page Object, you ensure that the interaction pattern is standardized across your entire test infrastructure. This makes your test scripts significantly easier to read, as the intent of the code becomes immediately apparent to anyone reviewing the suite. Furthermore, it allows you to handle synchronization issues internally. For instance, a method that clicks a submit button can automatically incorporate the necessary waiting logic or assertions to ensure the page has reached a stable state before returning control to the test script, effectively hiding technical complexity from the test scenario author.

class CheckoutPage {
  constructor(page) {
    this.page = page;
    this.cartItems = page.locator('.cart_item');
  }

  async removeItem(productName) {
    // Encapsulating complex finding and clicking logic
    const item = this.cartItems.filter({ hasText: productName });
    await item.getByRole('button', { name: 'Remove' }).click();
  }
}

Implementing Composition for Reusability

As applications grow, pages often share common UI components such as headers, navigation menus, or footers. Repeating the code for these elements in every individual Page Object class results in a violation of the DRY (Don't Repeat Yourself) principle. Instead, you can utilize the power of composition by creating separate classes for these shared components and injecting them into your main Page Objects. By treating each section of the interface as an independent object, you gain the ability to test smaller pieces of the application in isolation and then orchestrate them within larger page models. This modular approach makes it easier to track which parts of the application are covered by your tests. If a shared navigation header is updated, you modify a single component class, and all Page Objects utilizing that component automatically inherit the change. This compositional design mimics how modern frontend frameworks structure their own component libraries, creating a logical mapping between your test code and the underlying UI architecture.

class NavigationBar {
  constructor(page) {
    this.logoutButton = page.locator('#logout-sidebar-link');
  }
}

class DashboardPage {
  constructor(page) {
    this.nav = new NavigationBar(page); // Composing the component
  }
}

Managing State and Context

A critical aspect of effective test design is the management of state. Page Objects provide an ideal location to maintain the context required for a specific page, such as the current URL or expected page title. By including assertion methods directly within the class, you can verify the validity of the application state as part of the action. For example, a 'navigate' method can automatically verify that the URL matches the expected pattern after loading. This proactively catches navigation errors or broken redirects before the rest of your test execution begins. Furthermore, you can use these classes to manage session-specific data, such as a user object that holds information about the currently logged-in persona. This contextual awareness ensures that your test scripts remain robust even when navigating through complex flows where page states change rapidly. By offloading the state verification to the Page Object, you move error detection closer to the source, significantly reducing the debugging time required when a test fails due to an unexpected application state.

class InventoryPage {
  constructor(page) {
    this.page = page;
  }

  async verifyLoaded() {
    // Self-contained state validation
    await this.page.waitForURL(/.*inventory.html/);
    return this.page.locator('.title').textContent();
  }
}

Advanced Factory Patterns

To take the Page Object Model to the next level, you can implement a factory pattern or a centralized fixture approach to manage your Page Object instances. Manually instantiating every Page Object class at the beginning of each test can lead to excessive boilerplate code. By leveraging test fixtures, you can automatically instantiate the necessary Page Objects and inject them into your test functions, ensuring they are always ready to use. This setup allows you to create a base class or a custom test configuration that pre-configures your browser context and attaches the initialized page models. This architecture eliminates the need for redundant setup steps and keeps your individual test files extremely lean. Consequently, engineers can focus entirely on defining test steps rather than managing object lifecycles. This highly structured environment is the standard for professional-grade automation, enabling teams to scale their test suites to thousands of scenarios while maintaining strict control over the test infrastructure, performance, and reliability across different testing environments and user roles.

// Custom fixture injection setup
export const test = base.extend({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  inventoryPage: async ({ page }, use) => {
    await use(new InventoryPage(page));
  }
});

Key points

  • The Page Object Model abstracts UI selectors into dedicated classes to improve maintainability.
  • Decoupling test logic from UI implementation allows for easier updates when the application changes.
  • Modular composition helps reuse common UI components across multiple page models.
  • Page objects should encapsulate both element locators and high-level user actions.
  • Embedding state validation within page methods helps identify bugs at the exact moment they occur.
  • Centralized management via fixtures reduces boilerplate code and streamlines test execution.
  • The pattern encourages the DRY principle by preventing redundant selector definitions.
  • Page objects improve the readability of tests by describing user intent rather than technical actions.

Common mistakes

  • Mistake: Including assertions inside Page Object methods. Why it's wrong: POM should only encapsulate interaction logic; assertions make the test coupled to specific outcome logic, reducing reusability. Fix: Return the element state or value to the test file and perform assertions there.
  • Mistake: Over-abstracting by creating a method for every single UI interaction. Why it's wrong: This leads to 'fat' page objects that are hard to maintain. Fix: Only encapsulate complex actions or groups of interactions that represent a high-level business process.
  • Mistake: Hardcoding test data inside the Page Object methods. Why it's wrong: It forces the page object to change whenever the test data needs variation. Fix: Pass test data as arguments to the Page Object methods.
  • Mistake: Navigating the URL inside the constructor. Why it's wrong: Constructors run automatically, making it difficult to control the sequence of navigation in multi-step test flows. Fix: Use a dedicated 'navigate()' method to trigger page navigation explicitly.
  • Mistake: Exposing internal locators (selectors) publicly. Why it's wrong: If the locator changes, you have to fix every test file that touches it. Fix: Make locators private or protected and expose public methods that perform actions using them.

Interview questions

What is the Page Object Model (POM) in the context of Playwright, and why is it considered a best practice?

The Page Object Model is a design pattern in Playwright where you create classes that represent specific pages or components of your web application. Instead of hardcoding selectors and actions directly into your test files, you encapsulate these elements within dedicated classes. This is a best practice because it drastically improves maintainability; if a button's locator changes, you update it in one class instead of across dozens of test files, making your automation suite much more robust.

How do you implement a basic Page Object class in Playwright?

To implement a basic Page Object, you define a class that accepts a Playwright 'page' object in its constructor. You then define your locators as class properties and create methods that perform actions on those elements. For example: class LoginPage { constructor(page) { this.page = page; this.usernameInput = page.locator('#user'); } async login(user) { await this.usernameInput.fill(user); } }. This structure keeps your test logic clean, readable, and highly reusable across different test scenarios, which is essential for scaling a complex automation framework.

Compare the 'Page Object Model' approach against 'writing raw locator tests' in Playwright. Why would you choose one over the other?

Writing raw locator tests involves placing selectors directly in test blocks, which is quick for prototyping but creates significant technical debt. If your UI updates, you must search and replace those strings everywhere. In contrast, the Page Object Model abstracts these locators, providing a central source of truth. You choose POM when you need a long-term, maintainable suite, whereas raw locators might be acceptable for a single-page, throwaway script that will never require future updates or expansion.

How do you handle complex navigation between different pages when using the Page Object Model in Playwright?

Handling navigation often involves chaining or returning new Page Object instances within your methods. For example, a 'submitLogin' method in a LoginPage class might return a 'DashboardPage' object once navigation completes. This pattern, often called the 'Fluent Interface,' allows you to write readable, chainable code like: await loginPage.login(user).then(dashboard => dashboard.verifyHeader()). This approach ensures that your test flows are declarative and clearly describe the user journey through the application without exposing the underlying implementation details.

In Playwright, should you include assertions inside your Page Objects, or keep them strictly for the test files?

It is generally better to keep assertions out of Page Objects, focusing them instead on actions, while test files focus on validation. Including assertions inside a Page Object often makes it too rigid and creates 'tight coupling,' where one page object is forced to validate specific business states. By keeping Page Objects focused on 'how to interact' and tests focused on 'what to expect,' your framework becomes much more flexible for testing different edge cases and negative scenarios.

How can you leverage Playwright's 'Fixtures' to enhance your Page Object Model implementation?

Fixtures allow you to automatically instantiate and inject your Page Objects into your tests, eliminating the need to manually instantiate classes in every test block. You define a custom fixture in your test configuration that creates the Page Object once and provides it to the test. This reduces boilerplate code and ensures that all dependencies are cleanly managed, automatically handling setup and teardown tasks across your entire test suite effectively.

All Playwright interview questions →

Check yourself

1. When refactoring a Playwright test to use POM, what is the primary benefit of returning a new Page Object instance from a method?

  • A.To satisfy the memory management requirements of the Playwright runner
  • B.To enable a fluent 'chaining' API that makes the test flow easier to read
  • C.To bypass the need for page fixtures in the test file
  • D.To ensure the browser context is re-initialized for every action
Show answer

B. To enable a fluent 'chaining' API that makes the test flow easier to read
Returning the next page object enables method chaining (e.g., login.navigate().submitForm()), which makes the test script read like a natural language. The other options are incorrect because they describe irrelevant architectural concerns or misunderstand how Playwright manages objects.

2. A tester has moved all locator logic into a central Page Object class but still finds they are updating 10 files every time a CSS class name changes. Why?

  • A.They are using locators directly in the test scripts instead of calling Page Object methods
  • B.They did not include the 'await' keyword on their method calls
  • C.They are initializing the Page Object in every single test case
  • D.They are not using the 'page' fixture globally
Show answer

A. They are using locators directly in the test scripts instead of calling Page Object methods
If changing a locator requires updating multiple files, the testers are likely still referencing selectors directly in their tests rather than strictly using the encapsulated methods provided by the POM. The other choices deal with initialization or syntax, which would cause runtime errors, not maintenance overhead.

3. What is the correct way to handle elements that might not be visible immediately in a Page Object model?

  • A.Use a 'wait' hardcoded sleep function inside the locator constructor
  • B.Let Playwright's built-in auto-waiting handle it within the action methods
  • C.Add a 'page.waitForLoadState' in every single getter method
  • D.Create an 'isLoaded' boolean flag that is checked before every interaction
Show answer

B. Let Playwright's built-in auto-waiting handle it within the action methods
Playwright is designed with auto-waiting, so the POM should rely on these built-in features for clicks and visibility. Hardcoding sleeps or manual waiting flags adds unnecessary complexity and flakiness, which defeats the purpose of Playwright's architecture.

4. Which of the following describes the best practice for handling dynamic user-specific data in a Page Object?

  • A.Storing the data as instance variables in the constructor
  • B.Passing the data as parameters to the specific methods that perform the action
  • C.Writing the data to a shared JSON file that the Page Object reads before every click
  • D.Using global environment variables for all input fields
Show answer

B. Passing the data as parameters to the specific methods that perform the action
Methods should remain pure and reusable; passing data as parameters ensures that the same Page Object can be used for different test scenarios (e.g., different users). The other methods create unwanted coupling, persistent state, or rely on external global state that is hard to debug.

5. Why is it discouraged to perform assertions inside the Page Object class?

  • A.Because Playwright cannot perform assertions outside of the test file
  • B.Because it makes the Page Object act as both an actor and a verifier, violating the Single Responsibility Principle
  • C.Because assertions in Page Objects cause the Playwright test runner to crash
  • D.Because it prevents the use of browser context fixtures
Show answer

B. Because it makes the Page Object act as both an actor and a verifier, violating the Single Responsibility Principle
Separating interaction (Page Object) from verification (Test file) ensures that the Page Object describes *what* can be done, while the test describes *what should happen*. Options 1, 3, and 4 are technically false regarding how Playwright works.

Take the full Playwright quiz →

← PreviousWriting maintainable and reusable test codeNext →Custom fixtures and test hooks

Playwright

37 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app