GUIDE / automation
Page Object Model: A Pattern for Maintainable Tests
Learn the page object model for maintainable UI automation tests, with Playwright examples, best practices, and common POM mistakes to avoid.
If you are learning the page object model, you are learning the pattern most teams use to keep UI automation readable after the first dozen tests. The Page Object Model (POM) turns fragile scripts full of selectors and clicks into focused tests that describe user intent, while page classes own locators and interactions. When a button label or form layout changes, you update one place instead of hunting through an entire suite.
This guide explains what the page object model is, why it matters, how to implement it in Playwright with practical TypeScript examples, how it compares with alternatives, and the mistakes that make POM suites hard to maintain. You will leave with a structure you can use on a real project, not just a definition.
What Is the Page Object Model?
The page object model is a design pattern for UI test automation. Each important page or reusable UI area becomes a class. That class stores the locators for the page and exposes methods that represent user actions or readable queries.
A test should rarely say:
click #email
fill #password
click button[type=submit]
expect url to contain /dashboard
A test should more often say:
loginPage.loginAs(user)
expect(dashboardPage.heading).toBeVisible()
The first style couples every test to HTML structure. The second style couples tests to product behavior. HTML will change. Product behavior is what your suite is supposed to protect.
POM is not a framework by itself. It is an organization pattern. You can use it with Playwright, Selenium, Cypress, WebdriverIO, or Appium. The idea is the same: separate what the test is proving from how the UI is operated.
Pages vs Components
A common beginner mistake is creating only full page classes. Modern apps are built from reusable components: header nav, toast notifications, cart drawer, date pickers, modal dialogs. Treat those as component objects when they appear in multiple flows.
Examples:
| Object type | Represents | Example methods |
|---|---|---|
| Page object | A route or major screen | goto(), submitForm(), getErrorText() |
| Component object | A reusable UI region | Header.signOut(), Toast.expectSuccess() |
| Flow helper | A multi-page business journey | checkoutAsGuest(cart) |
Keep the boundaries intentional. If a class starts knowing about three routes and five unrelated widgets, it is no longer a page object. It is a dumping ground.
Why the Page Object Model Matters
Automation fails more often from maintenance cost than from tool choice. Teams pick a modern runner, write twenty happy path scripts, then watch the suite rot as the product evolves. The page object model exists to slow that decay.
Maintainability
When the login form gains a new MFA step, you want one update path. If every test hardcodes selectors for email, password, and submit, every test becomes a change request. With POM, the login page class absorbs the new step and most tests stay readable.
Readability
Business stakeholders and new engineers can read a test that says inventoryPage.filterByStatus("out of stock"). They cannot easily read a chain of CSS selectors and wait conditions. Readable tests get reviewed. Unreadable tests become tribal knowledge.
Reuse Without Copy Paste
The same checkout form may appear in guest checkout, logged in checkout, and reorder flows. A single CheckoutPage can serve all three. Reuse should live in page methods and shared components, not duplicated test bodies.
Stable Change Boundaries
Good automation has clear layers:
- Test specs describe scenarios and assertions.
- Page or component objects describe UI interaction.
- Fixtures and test data helpers prepare state.
- Utilities handle low level browser or API helpers.
POM sits in layer two. It is the boundary that protects tests from UI churn.
If you are choosing tools before structure, compare runners first in Selenium vs Playwright vs Cypress, then come back to pattern design. Tool choice and object design solve different problems.
Core Principles of a Good Page Object Model
1. Locators Live With the Page, Not the Test
Tests should not invent selectors. If a test needs a new element, add the locator to the page class and expose a method or readable property.
2. Methods Represent User Intent
Prefer loginAs(user) over fillEmail() plus fillPassword() plus clickSubmit() unless the test is specifically validating those field level behaviors. Intent methods make tests shorter and more stable.
3. One Responsibility Per Object
LoginPage should not also own admin report exports. Split objects when responsibilities diverge. Small focused classes are easier to test and easier to rename.
4. Prefer Stable Locators
Use roles, labels, and data-testid attributes when you control the app. Avoid brittle absolute XPath and long CSS chains based on layout. For a deeper selector comparison, see CSS selectors vs XPath.
5. Return the Next Page When Navigation Is Clear
Some teams like fluent navigation:
const dashboard = await loginPage.loginAs(user);
await dashboard.openProfile();
This can improve flow readability. Do not force it when navigation is conditional or when the method may stay on the same page after validation errors.
6. Keep Assertions Mostly in Tests
Page objects can help retrieve state, but the test should still make the decision that matters. When every assertion is hidden inside page methods, failures become harder to interpret and methods become overloaded with scenario specific expectations.
How to Implement Page Object Model in Playwright
Playwright works especially well with POM because locators are lazy and auto waiting. The page object can store locators once and reuse them safely.
Project Sketch
tests/
auth/
login.spec.ts
checkout/
guest-checkout.spec.ts
pages/
login.page.ts
dashboard.page.ts
checkout.page.ts
components/
header.component.ts
toast.component.ts
fixtures/
test-user.ts
Example: Login Page Object
import { type Locator, type Page, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByTestId("login-email");
this.passwordInput = page.getByTestId("login-password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByTestId("login-error");
}
async goto() {
await this.page.goto("/login");
}
async loginAs(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectInvalidCredentials() {
await expect(this.errorMessage).toContainText("Invalid email or password");
}
}
Example: Test Using the Page Object
import { test, expect } from "@playwright/test";
import { LoginPage } from "../../pages/login.page";
import { DashboardPage } from "../../pages/dashboard.page";
test("registered user can sign in and reach dashboard", async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.loginAs("buyer@example.com", "ValidPass#2026");
await expect(dashboardPage.heading).toHaveText("Dashboard");
await expect(page).toHaveURL(/\/dashboard/);
});
Notice what the test does not contain: CSS selector noise, fill order trivia, or duplicated locator strings. It states the scenario and the outcome.
Example: Component Object
import { type Locator, type Page, expect } from "@playwright/test";
export class Toast {
readonly root: Locator;
constructor(page: Page) {
this.root = page.getByTestId("toast");
}
async expectSuccess(message: string) {
await expect(this.root).toContainText(message);
await expect(this.root).toHaveAttribute("data-variant", "success");
}
}
Components keep shared UI behavior out of every page class. Headers, sidebars, dialogs, and notifications are usually better as components than as duplicated private methods.
Building Methods That Age Well
Not every method should mirror a single click. Design methods around the reason a user interacts with the page.
Weak Methods
async clickEmail() {}
async typeEmail(value: string) {}
async clickPassword() {}
async typePassword(value: string) {}
async clickSubmit() {}
These methods look organized, but tests still assemble low level choreography. You have moved noise without reducing it.
Stronger Methods
async loginAs(email: string, password: string) {}
async loginWithInvalidPassword(email: string) {}
async requestPasswordReset(email: string) {}
The stronger methods describe outcomes the product cares about. Field level methods can still exist for validation focused tests, but they should not be the default public API.
Parameterize Behavior, Not Implementation
Good:
await productsPage.filter({ category: "Shoes", inStock: true });
Weak:
await productsPage.clickFilterSidebar();
await productsPage.openCategoryDropdown();
await productsPage.selectOptionNumber(3);
await productsPage.toggleCheckboxByIndex(1);
The weak version breaks when option order changes. The strong version breaks only when the product capability changes.
For scenarios where the same user intent must run across many data rows, combine POM with data driven testing in Selenium rather than duplicating page flows.
Page Object Model With data-testid Locators
Many teams standardize on data-testid for critical automation hooks. That strategy pairs cleanly with POM.
Why it helps:
- Design system class names can change without breaking tests.
- Translated visible text can change without breaking every locator.
- Engineers can mark intentional test contracts in the app code.
Example convention:
<input data-testid="login-email" />
<button data-testid="login-submit">Sign in</button>
In the page object:
this.emailInput = page.getByTestId("login-email");
this.submitButton = page.getByTestId("login-submit");
Do not put test ids on every span in the app. Put them on interactive controls and key status regions that automation must trust. Also prefer accessible role and label locators when they are already stable. Test ids are a contract, not a substitute for accessible UI.
POM vs Screenplay Pattern
Teams eventually ask whether page objects are enough. The Screenplay pattern is the common alternative.
| Dimension | Page Object Model | Screenplay |
|---|---|---|
| Main abstraction | Pages and components | Actors, tasks, interactions, questions |
| Best fit | UI structured around screens | Complex multi-user business workflows |
| Learning curve | Lower for most QA teams | Higher, more ceremony |
| Test readability | High for page based flows | High for domain language |
| Risk | Fat page objects | Over engineering simple suites |
A practical rule: start with POM. If your suite has many actors, roles, and reusable business tasks that span pages, extract task style helpers or move hotspots toward Screenplay style composition. You do not need a full pattern rewrite to get the benefits of better domain language.
Example of a lightweight task helper without full Screenplay ceremony:
export async function purchaseInStockItem(page: Page, itemName: string) {
const catalog = new CatalogPage(page);
const cart = new CartPage(page);
const checkout = new CheckoutPage(page);
await catalog.goto();
await catalog.addToCart(itemName);
await cart.checkout();
await checkout.payWithSavedCard();
}
This keeps tests declarative while still using page objects underneath.
How POM Fits a Larger Framework
POM is one layer inside a broader automation design. When teams say their framework is "just page objects," they usually lack fixtures, data factories, reporting standards, and CI conventions. For the surrounding architecture, use how to build a test automation framework from scratch.
A healthy stack looks like this:
| Layer | Responsibility | Example |
|---|---|---|
| Specs | Scenarios and assertions | login.spec.ts |
| Page objects | UI interactions | LoginPage |
| Fixtures | Setup and shared context | authenticated user fixture |
| Test data | Factories and builders | createBuyer() |
| API helpers | Fast state setup | seed order via API |
| CI | Execution and artifacts | GitHub Actions workflow |
Use UI page objects for UI proof. Use APIs to arrange data when the UI setup is slow or flaky. That hybrid approach keeps the suite fast without abandoning end to end confidence.
Worked Example: Checkout Flow With POM
Requirement:
A logged in buyer can open cart, apply a valid coupon, pay with a saved card, and see an order confirmation.
Page Objects Involved
CartPageCheckoutPageOrderConfirmationPageHeadercomponent for cart count
Cart Page
export class CartPage {
constructor(private readonly page: Page) {}
get couponInput() {
return this.page.getByTestId("coupon-input");
}
get applyCouponButton() {
return this.page.getByRole("button", { name: "Apply coupon" });
}
get checkoutButton() {
return this.page.getByRole("button", { name: "Checkout" });
}
async applyCoupon(code: string) {
await this.couponInput.fill(code);
await this.applyCouponButton.click();
}
async proceedToCheckout() {
await this.checkoutButton.click();
}
}
Test
test("buyer can complete checkout with coupon", async ({ page, buyer }) => {
const cart = new CartPage(page);
const checkout = new CheckoutPage(page);
const confirmation = new OrderConfirmationPage(page);
await page.goto("/cart");
await cart.applyCoupon("SAVE20");
await cart.proceedToCheckout();
await checkout.payWithSavedCard();
await expect(confirmation.heading).toHaveText("Order confirmed");
await expect(confirmation.orderTotal).toContainText("$");
});
The test reads like a product walkthrough. The page objects hide the mechanical details. If the coupon field moves from cart to checkout, you relocate one method and re-run the suite.
Common Mistakes With the Page Object Model
Mistake 1: Giant God Page Classes
A BasePage that owns navigation, authentication, tables, modals, waits, and assertions becomes unmaintainable. Split by page and component. Share utilities carefully.
Mistake 2: Exposing Locators Everywhere
If tests call loginPage.emailInput.fill() directly all the time, the page object is only a locator bag. Encapsulate common interactions so changes stay local.
Mistake 3: Assertions Only Inside Pages
Hidden assertions make failures opaque. A method named submit() that also asserts success will surprise tests that intentionally submit invalid data.
Mistake 4: One Object Per Tiny Element
Creating EmailFieldPage, PasswordFieldPage, and SubmitButtonPage adds ceremony without value. Group by meaningful user facing regions.
Mistake 5: Duplicating Waits Carelessly
Playwright already auto waits for actionability. Extra fixed waitForTimeout(3000) calls inside page methods create flaky, slow suites. Prefer condition based waits. If your suite is already unstable, read how to fix flaky tests.
Mistake 6: Coupling Page Objects to Test Data Files
Page objects should accept data as parameters. They should not hardcode production users or import global spreadsheets deep inside methods. That coupling makes reuse and parallel execution harder.
Mistake 7: Ignoring Mobile or Multi Surface Variants
If the mobile nav is different, model that difference explicitly. Do not pretend one desktop page object covers every viewport without checking.
Page Object Model Best Practices Checklist
Use this checklist when reviewing a pull request that adds page objects:
- Each class maps to one page or reusable component.
- Locators use roles, labels, or stable test ids where possible.
- Public methods express user intent.
- Tests own scenario level assertions.
- No hard coded sleeps for normal interactions.
- Navigation methods are explicit about where the user ends up.
- Shared widgets live in component objects.
- Sensitive credentials are not embedded in page classes.
- New UI contracts are documented when test ids are added.
- Naming is consistent:
LoginPage, notloginpageorLogin_Object.
Anti-Patterns That Look Clean but Rot Fast
Thin Wrapper Hell
async clickSave() {
await this.saveButton.click();
}
A one line wrapper for every click can be fine in moderation, but a suite of hundreds of empty wrappers adds indirection without design. Wrap sequences and meaningful actions, not every atomic event by default.
Business Logic in Page Objects
Do not compute prices, tax rules, or permission matrices inside page classes unless you are verifying that the UI shows those values. Keep domain calculation in helpers or expected data builders so UI objects stay focused on interaction.
Copying Selenium Tutorials Blindly Into Playwright
Older Selenium POM examples often include explicit waits in every method, driver static singletons, and inheritance heavy base pages. Playwright fixtures and locator auto waiting replace much of that ceremony. Adapt the pattern to the tool instead of transplanting legacy structure unchanged. A solid starter path is the Playwright tutorial.
When Not to Use Heavy POM
POM is useful, but not mandatory for every script.
Skip heavy structure when:
- You are spike testing a prototype that will be thrown away.
- You have one or two smoke scripts and no shared UI yet.
- You are writing a quick repro for a bug outside the main suite.
Adopt POM when:
- Multiple tests share screens.
- More than one engineer maintains the suite.
- UI changes weekly and selectors churn.
- You need onboarding friendly tests.
The goal is maintainable signal, not pattern purity.
Refactoring Existing Scripts Into Page Objects
If your suite is a pile of raw tests, do not stop the world for a rewrite. Refactor incrementally.
- Identify the most duplicated screen, usually login or navigation.
- Extract locators and one or two intent methods.
- Update the highest value tests first.
- Add component objects for headers and toasts.
- Ban new raw selectors in specs through code review.
- Delete dead helper methods as you go.
A month of incremental extraction usually beats a heroic rewrite that freezes feature delivery.
Example Folder Conventions That Scale
src/
pages/
auth/
login.page.ts
reset-password.page.ts
shop/
catalog.page.ts
cart.page.ts
checkout.page.ts
components/
header.component.ts
pagination.component.ts
flows/
purchase.flow.ts
data/
users.ts
utils/
money.ts
tests/
smoke/
regression/
Group by product area, not by technical trivia. Engineers should find checkout.page.ts without memorizing a clever abstract factory layout.
How to Review a Page Object Pull Request
Ask these questions:
- Does the new method name match user language?
- Are locators resilient enough for the next redesign?
- Did the author add scenario specific assertions that belong in the test?
- Is there already a component that should own this behavior?
- Will this object still make sense if the route splits into two screens?
Good review culture matters as much as good initial design. POM fails quietly when every PR adds "just one more method" to a bloated class.
Practice Path on QABattle
Theory sticks when you practice under constraints. Open the automation challenges in QABattle battles and rebuild one arena flow with page objects:
- Write a raw script that passes.
- Extract a page class for the main screen.
- Extract a component for shared feedback messages.
- Rewrite the test to call intent methods.
- Change one button label and confirm you only update the page object.
That exercise teaches the real value of POM faster than another diagram.
Final Takeaway
The page object model is not about making tests more object oriented for fashion. It is about protecting your suite from UI volatility while keeping scenarios readable. Put locators and interactions behind page and component classes. Keep tests focused on behavior and risk. Prefer stable selectors. Avoid god classes, hidden assertions, and hard coded sleeps.
If you apply only one rule, apply this one: a test should read like a specification of user behavior, and a page object should absorb the mechanical details that make that behavior possible in the browser.
Use POM as the backbone of UI automation, then surround it with solid fixtures, data setup, and CI discipline. That combination is what turns a demo suite into a system the team still trusts six months later.
FAQ
Questions testers ask
What is the Page Object Model in test automation?
The Page Object Model is a design pattern that represents each application page or significant UI component as a class. Locators and page interactions live in that class, while tests call high level methods. This keeps tests readable and reduces duplication when the UI changes.
What are the advantages of using POM?
POM improves maintainability, readability, and reuse. When a selector or workflow changes, you update one page class instead of many tests. Tests stay focused on business intent, new engineers ramp faster, and UI churn becomes less expensive across large automation suites.
How do you implement Page Object Model in Playwright?
Create a class per page or component, accept a Playwright Page in the constructor, define locators as fields, and expose methods for user actions and assertions helpers. Tests instantiate the page object and call methods like loginAs(user) instead of chaining low level clicks.
Is Page Object Model still useful with Playwright locators?
Yes. Playwright locators improve stability, but they do not organize large suites by themselves. POM still centralizes navigation, forms, and workflows so tests avoid repeating locator and flow details across files.
What is the difference between POM and the Screenplay pattern?
POM models pages and components. Screenplay models actors, tasks, and questions, which can scale better for complex business workflows. Many teams start with POM and adopt Screenplay style tasks only where multi-step domain language becomes more important than page structure.
Should assertions live inside page objects?
Prefer keeping hard assertions in tests so failure intent stays visible. Page objects can return state or provide soft query methods, but burying every expect call inside page classes makes failures harder to read and can hide what the test was trying to prove.
RELATED GUIDES
Continue the route
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
How to Build a Test Automation Framework from Scratch
Learn how to build a test automation framework from scratch with layers, design patterns, reporting, CI/CD hooks, and a practical starter architecture.
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.
Selenium vs Playwright vs Cypress in 2026
Compare Selenium vs Playwright vs Cypress in 2026 for speed, browsers, CI, flakiness, ecosystem, and which web automation framework beginners should choose.
CSS Selectors vs XPath: A Cheat Sheet for Testers
Compare CSS selectors vs XPath for test automation, with a cheat sheet, speed notes, Playwright locator advice, and stable data-testid practices.