“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)andbookRow(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,getByTextwhere unique. They’re more robust than deep CSS/XPath.
- Use
- Encapsulate Waits Inside POM
- Each method should verify readiness (
toBeVisible,toHaveCount,toHaveURL) before acting. Tests become simpler and more reliable.
- Each method should verify readiness (
- Manage Secrets Properly
- Use environment variables (
BASE_URL,DEMOQA_USER,DEMOQA_PASS). Never hardcode credentials in code/examples.
- Use environment variables (
- 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,searchBookoverclickBtn1. Reflect user behavior.
- Prefer
- 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.