July 3, 2026
Playwright Page Object Model Tutorial
Learn the Playwright Page Object Model tutorial with practical TypeScript examples, locator patterns, fixtures, and maintenance tradeoffs for SDETs and QA engineers.
If you have written enough Playwright tests, you eventually hit the same wall: the first few specs are clean and readable, then locators start repeating, selectors drift, and small UI changes turn into noisy updates across half the suite. That is usually the point where the Page Object Model starts to look less like an academic pattern and more like a practical way to keep test code from becoming a copy-paste farm.
This article is my Playwright Page Object Model tutorial from the perspective of an SDET who cares about maintainability, not just getting a green run once. I will show how I structure page objects in Playwright with TypeScript, where the pattern helps, where it becomes ceremony, and what I do differently when the app has dynamic UI, nested components, or a lot of shared flows.
For a quick reference on Playwright itself, the official docs are the best starting point. I will assume you already know how to write a basic Playwright test and are trying to decide how to organize a real suite.
What the Page Object Model is actually for
The Page Object Model, often shortened to POM, is a way to separate test intent from UI details. In practice, that means your test should read like a business flow, while the page object contains locators and reusable actions.
A good page object does three things:
- Centralizes selectors in one place.
- Exposes meaningful actions, not raw clicks everywhere.
- Hides brittle UI mechanics from the test body.
The goal is not to put every browser interaction behind a class. The goal is to keep UI changes localized.
That distinction matters. A lot of teams create page objects that simply wrap every click and getter one-to-one. That is usually too thin to be useful. At the other extreme, some teams overload page objects until they become mini frameworks with business logic, branching, and assertions everywhere. That also becomes hard to reason about.
A practical Playwright POM sits in the middle, with page objects representing screens, and sometimes smaller component objects for reusable widgets like nav bars, modals, or date pickers.
When Playwright POM helps, and when it does not
I reach for Playwright page objects when I see one or more of these patterns:
- The same locators are repeated across multiple specs.
- The same flow appears in several tests, like login, checkout, or adding a team member.
- UI structure changes often enough that raw selectors are expensive to maintain.
- The team wants tests that read at a higher level than
page.locator(...).click()chains.
I am more cautious when:
- The test is very short and only uses one or two locators.
- The UI is still being prototyped and the markup changes daily.
- The object starts hiding important control flow, making the test harder to debug.
A useful rule of thumb is this:
Use Playwright POM to remove duplication and isolate change, not to make every file look object-oriented.
A simple example app flow
Let us use a login flow because it is easy to understand and common in real suites. We will create:
- a
LoginPagepage object, - a
DashboardPagereturned after successful login, - one spec that uses both.
The example uses Playwright with TypeScript because that is the most common setup in teams that want strong code reuse and type safety.
Basic Playwright Page Object Model structure
A typical folder layout looks like this:
tests/
login.spec.ts
pages/
login-page.ts
dashboard-page.ts
You can organize this differently, but I prefer keeping page objects separate from specs. If the project grows, I usually add components/ for reusable widgets and fixtures/ for shared setup.
Login page object
import { expect, type Page } from '@playwright/test';
import { DashboardPage } from './dashboard-page';
export class LoginPage { constructor(private readonly page: Page) {}
async goto() { await this.page.goto(‘/login’); }
async login(username: string, password: string) { await this.page.getByLabel(‘Email’).fill(username); await this.page.getByLabel(‘Password’).fill(password); await this.page.getByRole(‘button’, { name: ‘Sign in’ }).click(); return new DashboardPage(this.page); }
async assertVisible() { await expect(this.page.getByRole(‘heading’, { name: ‘Sign in’ })).toBeVisible(); } }
Dashboard page object
import { expect, type Page } from '@playwright/test';
export class DashboardPage { constructor(private readonly page: Page) {}
async assertLoaded() { await expect(this.page.getByRole(‘heading’, { name: ‘Dashboard’ })).toBeVisible(); } }
Spec using the page objects
import { test } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
test('user can sign in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.assertVisible();
const dashboardPage = await loginPage.login(‘user@example.com’, ‘secret-password’); await dashboardPage.assertLoaded(); });
This is intentionally simple, but it already shows the core advantage. If the login form changes, I update one class, not every test.
Choosing the right locator strategy inside page objects
Playwright gives you several locator options, and POM works best when you choose stable selectors. My default preference is:
getByRolegetByLabelgetByTestId- text locators, when the text is stable and meaningful
- CSS selectors, only when necessary
This is not about dogma, it is about resilience.
If your app is accessible, getByRole and getByLabel make page objects much easier to read and maintain. They also tend to produce better failure messages.
Example of a stronger locator style:
typescript
await page.getByRole('button', { name: 'Save changes' }).click();
await page.getByLabel('First name').fill('Ada');
Example of a more brittle style:
typescript
await page.locator('.card .actions button:nth-child(2)').click();
If your team uses test IDs, getByTestId is fine. In fact, for highly dynamic components, it is often the most practical option. Just keep the convention consistent.
Page objects should expose behavior, not every DOM detail
A common mistake is to expose every field as a property and make tests stitch everything together:
typescript // not ideal for many teams
await loginPage.email.fill('user@example.com');
await loginPage.password.fill('secret');
await loginPage.submit.click();
That is not terrible, but it moves too much knowledge back into the test. The test still knows about fields and buttons rather than a business action like login().
A better pattern is to expose a behavior when the action is always used together:
typescript
await loginPage.login('user@example.com', 'secret');
But if the page has several valid interaction paths, it is okay to expose a few well-named methods. For example, a settings page might reasonably have updateDisplayName(), changePassword(), and deleteAccount().
Handling navigation and returning page objects
A nice part of Playwright POM is that a method can return the next page object after a navigation event. That keeps flow readable and reduces guesswork.
typescript
async submit(): Promise<DashboardPage> {
await this.page.getByRole('button', { name: 'Sign in' }).click();
await this.page.waitForURL('**/dashboard');
return new DashboardPage(this.page);
}
You do not always need a navigation wait if the next assertion already waits for the new state, but explicit URL waits can make transitions clearer in some apps.
A useful guideline is to wait on the thing that proves the transition happened, not on arbitrary sleep calls.
Using component objects for reusable widgets
Once your suite grows, some things are not really pages. They are reusable components, like a header, date picker, cookie banner, or modal. I usually extract those into component objects when the same structure appears on multiple screens.
For example, a shared NavBar component might look like this:
import { type Page, expect } from '@playwright/test';
export class NavBar { constructor(private readonly page: Page) {}
async openProfile() { await this.page.getByRole(‘link’, { name: ‘Profile’ }).click(); }
async assertVisible() { await expect(this.page.getByRole(‘navigation’)).toBeVisible(); } }
Then a page object can compose it:
import { type Page } from '@playwright/test';
import { NavBar } from './components/nav-bar';
export class DashboardPage { readonly navBar: NavBar;
constructor(private readonly page: Page) { this.navBar = new NavBar(page); } }
This is useful because the navigation is not a page, but it is a real unit of UI behavior that deserves one definition.
Page Object Model with Playwright fixtures
If you want cleaner tests, fixtures can help instantiate page objects in a shared way. This is especially useful in larger TypeScript suites.
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
type MyFixtures = { loginPage: LoginPage; };
export const test = base.extend
Then in a test:
import { test } from './fixtures';
test('shows login page', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.assertVisible();
});
I like this in larger suites because it reduces boilerplate. The downside is that it adds one more abstraction layer, so I only introduce fixtures after I see a real benefit.
Common mistakes in Playwright POM
1. Creating one class per HTML page without thinking about user flows
Your app might have a single-page flow with tabs, dialogs, or panels. In those cases, a “page object” that blindly maps to route names can be awkward. Model the UI around user tasks and reusable components, not just URLs.
2. Putting assertions everywhere
A page object can include small state checks, but if it becomes a place where all assertions live, your tests lose clarity. I usually keep a few focused assertion helpers, like assertVisible() or assertErrorMessage(), and leave full scenario assertions in the spec.
3. Overusing waitForTimeout
This is not really a POM issue, but it appears often in page object code. Avoid hard sleeps unless you are dealing with a known animation or a genuinely unavoidable edge case. Prefer Playwright auto-waiting, assertions, and explicit waits for real transitions.
4. Hiding too much behind magic methods
If a method name like completeCheckout() does 12 UI actions, retries, and conditional branching, debugging can get painful. Keep methods small enough that failure points are easy to identify.
5. Mixing test data setup with UI actions
Page objects should describe UI interactions. Test data setup often belongs in API helpers, fixtures, or backend factories. If you mix both too much, the object becomes a dumping ground.
How I decide whether a page object is worth it
I usually ask three questions:
- Will this screen, flow, or component be used by more than one test?
- Are the locators likely to change as the UI evolves?
- Does a named method make the test easier to understand?
If the answer is yes to at least two, I generally create a page object or component object.
If the answer is no, I often keep the test direct and lightweight. Not every screen deserves a class.
POM is a maintenance strategy, not a law of nature. The suite should stay easy to change, not just look organized.
A note on parallelism, state, and flaky tests
In Playwright, page objects do not automatically solve flaky tests. They help you manage UI interaction code, but your suite still needs good isolation, deterministic test data, and sensible state setup.
A few practical habits I rely on:
- Keep each test independent.
- Use fresh browser contexts when needed.
- Avoid sharing mutable page object state between tests.
- Keep page objects stateless or close to stateless, apart from the
Pagereference.
If a page object stores data from a previous interaction and later reuses it silently, debugging gets harder. I prefer to pass values into methods rather than caching too much internal state.
TypeScript tips that make Playwright POM nicer
Playwright and TypeScript work well together, especially if you lean on types without getting too clever.
A few things I find useful:
- Mark
Pageasreadonlyin constructors. - Return the next page object after navigation when it helps flow.
- Keep method names verbs, like
submit,search,openProfile. - Use explicit return types on public methods in shared libraries.
Example:
typescript
async search(term: string): Promise<void> {
await this.page.getByRole('searchbox').fill(term);
await this.page.keyboard.press('Enter');
}
This is simple, but simple code is easier for a team to maintain than “clever” abstractions that save a few lines and cost an hour during debugging.
When a simpler alternative may be better
For teams that want end-to-end coverage without spending time maintaining a TypeScript framework, Playwright POM can feel like overhead. That is one reason some teams look at Endtest vs Playwright. Endtest is a managed, agentic AI Test automation platform, so users do not need to maintain page object classes for every screen or own the underlying test framework and setup.
That does not make POM wrong. It just means the tradeoff changes. If your team has developers or SDETs who want code-level control, Playwright page objects are a strong option. If you want broader team participation and less framework maintenance, a lower-code path can be easier to sustain.
If you are also evaluating how AI fits into test creation and maintenance, I have seen teams find my article on whether AI Playwright testing is a shortcut or a maintenance trap useful as a companion read.
Example: a more realistic checkout page object
Let us end with a slightly more realistic example. This is the kind of object I actually keep in a suite.
import { expect, type Page } from '@playwright/test';
export class CheckoutPage { constructor(private readonly page: Page) {}
async open() { await this.page.goto(‘/checkout’); }
async fillShippingAddress(email: string, address: string) { await this.page.getByLabel(‘Email’).fill(email); await this.page.getByLabel(‘Shipping address’).fill(address); }
async applyCoupon(code: string) { await this.page.getByLabel(‘Coupon code’).fill(code); await this.page.getByRole(‘button’, { name: ‘Apply’ }).click(); }
async placeOrder() { await this.page.getByRole(‘button’, { name: ‘Place order’ }).click(); }
async assertOrderSummaryVisible() { await expect(this.page.getByRole(‘heading’, { name: ‘Order summary’ })).toBeVisible(); } }
And the spec:
import { test } from '@playwright/test';
import { CheckoutPage } from '../pages/checkout-page';
test('customer can complete checkout', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.open();
await checkout.assertOrderSummaryVisible();
await checkout.fillShippingAddress('user@example.com', '123 Main St');
await checkout.applyCoupon('WELCOME10');
await checkout.placeOrder();
});
This style works because the spec focuses on scenario intent, while the page object owns the UI mechanics.
My practical checklist for Playwright POM
Before I add or refactor a page object, I check the following:
- Is this code repeated in at least two specs?
- Does the object represent a meaningful screen or component?
- Are the locators stable enough to centralize?
- Does the API read like user behavior?
- Will this abstraction reduce change cost, not increase it?
If the answer is yes, I create the object. If the answer is mixed, I keep the test direct until the need becomes obvious.
Final thoughts
A solid Playwright page object model is less about architecture purity and more about reducing friction. The best Playwright page objects make tests easier to read, easier to update, and less sensitive to UI churn. The worst ones just add files.
If you are building a Playwright TypeScript tutorial into a real test suite, start small, extract only repeated and meaningful interactions, and prefer readable methods over deep inheritance or over-engineered abstractions. That approach keeps your tests maintainable without turning the framework into a second application.
For teams who want the same end-to-end coverage but do not want to own a TypeScript framework and page object layer for every screen, a managed platform like Endtest can be worth a look. For teams that do want code-level control, Playwright POM remains one of the most practical patterns you can use.