NashTech Blog

Building Maintainable UI Tests with Page Object Model in Playwright

Picture of nhileyen
nhileyen
Table of Contents

“Want Playwright tests that don’t break every time the UI changes?
Use Page Object Model (POM)! It turns messy selectors into clean, reusable page classes.

“If you’re tired of brittle tests, POM + Playwright = automation that lasts.

1. Introduction

Modern web applications are complex, dynamic, and constantly evolving. Testing them reliably requires tools that are fast, flexible, and developer-friendly. Playwright, an open-source framework by Microsoft, has quickly become one of the most popular choices for end-to-end (E2E) testing because it offers:

  • Cross-browser support (Chromium, Firefox, WebKit)
  • Fast execution with parallelization
  • Rich APIs for handling UI interactions, network requests, and more
  • Built-in resilience against flaky tests with auto-waiting and smart selectors

However, as your test suite grows, writing raw Playwright scripts can become hard to maintain. You’ll often see duplicated selectors, scattered waits, and tests that look more like low-level click instructions than business workflows. This is where Page Object Model (POM) comes in.

2. What is Page Object Model?

Page Object Model (POM) is a design pattern widely used in test automation to make UI tests cleaner, more maintainable, and scalable. Instead of writing raw selectors and actions directly in your test scripts, POM organizes them into page-specific classes. Each class represents a page (or component) and exposes semantic methods like:

  • Locators for UI elements
  • Reusable actions (e.g., login(), searchBook(), deleteBook())
  • Encapsulated waits for stability

This approach hides the complexity of locators and waits inside reusable methods, so your tests focus on business intent, not technical details.

3. Why Does POM Matter?

By adopting POM, you gain:

  • Maintainability: Update selectors in one place, fix all tests.
  • Readability: Tests read like user journeys, not click scripts.
  • Scalability: Add more tests without adding chaos.
  • Reusability: Common flows shared across scenarios.
  • Stability: Fewer flaky tests thanks to centralized waits and robust locators.
  • Speed: Combine API for setup + UI for experience validation.
  • Team Productivity: Easier onboarding, faster reviews, cleaner code.

4. Implementing POM in Playwright

Project structure, for example:

DemoPOM-in-playwright/ 
├─ pages/ 
│ ├─ LoginPage.ts 
│ ├─ DashboardPage.ts 
│ └─ ...
├─  api/
│ └─ api-example.ts
├─ tests/ 
│ │ └─ test.spec.ts 
├─ playwright.config.ts
├─  .env
├─ ...

How to create a Page class:

  • Define selectors
  • Add reusable methods (e.g., login(username, password))

5. The Practical Benefits – Through Real Scenarios

Let’s continue with a deep, practical section on the Benefits of Using POM in Playwright, with examples from two Search Book and Delete Book scenarios.

5.1) Maintainability: Update Selectors in One Place

As the application evolves, selectors change (IDs, classes, button text). Without POM, you’d fix dozens of failing tests. With POM, you centralize locators inside page classes—change them once, and all tests recover.

Example: When #searchBox changes to .search-input, only update the locator in BooksPage

// booksPage.ts
export class BooksPage extends BasePage {
  // Before:
  // readonly searchInput = this.page.locator('#searchBox');
  // After:
  readonly searchInput = this.page.locator('.search-input');
```

Impact on scenarios:

  • Select Book: No test edits; booksPage.searchBook() still works.
  • Delete Book: Any book row locator changes only in ProfilePage, not in specs.

5.2) Readability: Tests Describe User Intent

Raw scripts are full of low-level “fill → click → wait” lines. POM exposes business-level methods so tests read like user journeys and are easier to review.

Without POM, tests often look like this:

await page.fill('#userName', process.env.DEMO_USER!);
await page.fill('#password', process.env.DEMO_PASS!);
await page.click('#login');
await page.fill('#searchBox', 'Learning JavaScript Design Patterns');
await page.click('text=Learning JavaScript Design Patterns');
await page.click('button:has-text("Add Collection")');

This works—but it’s hard to maintain. If the selector #searchBox changes, you must update every test that uses it. And the test reads like a script, not a user journey.

With POM, the same flow becomes:

await loginPage.login(process.env.DEMO_USER!, process.env.DEMO_PASS!);
await booksPage.searchBook('Learning JavaScript Design Patterns');
await booksPage.addBookToCollection('Learning JavaScript Design Patterns');
```

Impact on scenarios:

  • Select Book: Much cleaner, right? Now your tests tell the clear story—login → search → add

5.3) Reusability: DRY Across All Specs

Common flows (login, navigate to Books/Profile, search, delete) are reusable. You avoid copy-paste and keep behavior consistent across scenarios.

Reusable methods:

// loginPage.ts
async login(username: string, password: string) { /* ... */ }

// booksPage.ts
async searchBook(title: string) { /* ... */ }
async addBookToCollection(title: string) { /* ... */ }

// profilePage.ts
async deleteBook(title: string) { /* ... */ }
``

Impact on scenarios:

  • Select Book & Delete Book: Both reuse searchBook(title) and bookRow(title), so you maintain one implementation.

5.4) Scalability: Grow Your Suite Without Chaos

Focused page classes, clean fixtures, and clear separation of concerns let you add scenarios fast—negative cases, edge cases, bulk flows—without bloating your tests. Fixture injection (clean scale), for example:

// fixtures/testWithPages.ts
export const test = base.extend({
  loginPage: async ({ page }, use) => use(new LoginPage(page)),
  booksPage: async ({ page }, use) => use(new BooksPage(page)),
  profilePage: async ({ page }, use) => use(new ProfilePage(page)),
});

Impact on scenarios:

  • Add variants (e.g., “cannot add duplicate book”, “bulk delete”) by composing existing methods.

5.5) Stability: Encapsulated Waits & Resilient Locators

Playwright’s strength is auto-waiting and accessible locators. POM encapsulates waits inside methods and uses robust locators to reduce flakes.

// booksPage.ts
async gotoBooks() {
await this.goto(‘/books’);
await expect(this.searchInput).toBeVisible();
}

bookRow(title: string) {
return this.page.locator(.rt-tr-group >> text=${title});
}

async openBookDetails(title: string) {
const row = this.bookRow(title);
await expect(row).toBeVisible(); // wait inside POM
await row.click();
await this.expectUrlContains(‘/books?book=’);
}

Impact on scenarios:

  • Select Book: Reduced “element not found” errors when details page loads.
  • Delete Book: UI confirmation waits baked in; fewer race conditions.

5.6) Speed & Reliability: Integrate API for Setup/Cleanup

Use API for preconditions and teardown to avoid slow, flaky UI setup. Validate user experience via UI, but prepare data via API for robustness.

Impact on scenarios:

  • Delete Book: Always starts with known state—book present—so the test focuses on UX behavior, not setup.

5.7) Team Productivity: Easier Onboarding & Reviews

New contributors can understand page classes quickly and write tests that read like user workflows. Code reviews focus on business correctness, not click sequences.

Impact on scenarios:

  • Teams add new flows (search variations, multiple deletions) by calling clear methods (searchBook, deleteBook), reducing ramp-up time.

6. Best Practices – What Makes POM Truly Effective

  • Keep Assertions in Tests, Not in Page Objects
    • POM methods perform actions and return handles/data; specs assert behavior. This separation minimizes coupling and speeds refactors.
  • Prefer Accessibility-First Locators
    • Use getByRole, getByLabel, getByText where unique. They’re more robust than deep CSS/XPath.
  • Encapsulate Waits Inside POM
    • Each method should verify readiness (toBeVisible, toHaveCount, toHaveURL) before acting. Tests become simpler and more reliable.
  • Manage Secrets Properly
    • Use environment variables (BASE_URL, DEMOQA_USER, DEMOQA_PASS). Never hardcode credentials in code/examples.
  • Small, Focused Classes
    • One page (or component) per class. If you have shared parts (header, modal), create component objects and compose them.
  • Idempotent Setup/Cleanup
    • API client should tolerate existing state (e.g., delete if exists, add if missing) to avoid test inter-dependencies.
  • Tracing & Diagnostics
    • Configure Playwright tracing/screenshots/videos. POM gives structure; observability gives insight.
// playwright.config.ts
use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }
  • Naming for Business Intent
    • Prefer addBookToCollection, deleteBook, searchBook over clickBtn1. Reflect user behavior.
  • Avoid Workflow Logic in POM
    • POM should not own multi-page orchestration. Keep flow composition in tests (or thin “service” layers), not in page classes.
  • Parallelization Support
    • POM + fixtures isolate state per test, making parallel execution safe and predictable.

7. Conclusion

Using POM with Playwright transforms your UI tests from brittle scripts into reliable, maintainable, and scalable automation. In every flow, a well-structured POM—combined with API-driven preconditions—makes your suite faster, cleaner, and easier to grow.

Picture of nhileyen

nhileyen

Leave a Comment

Suggested Article

Discover more from NashTech Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading