Page Object Model
When to use: Any page or component is touched by more than one test file, or a single test file interacts with more than two distinct UI regions.
Page objects encapsulate actions, not locators. A test should read like a user story: await loginPage.login('admin', 'secret'), never await loginPage.usernameInput.fill('admin'). The POM is a boundary between "what the user does" and "how the UI is structured." Assertions stay in tests, never inside page objects.
Quick Reference
| Concept | Rule |
|---|---|
| Locators | Defined as readonly properties in the constructor or as getters. Never exposed raw to tests. |
| Actions | Public methods that perform a user-visible behavior. Return Promise<void> or the next page object. |
| Assertions | Never inside page objects. Tests own all expect() calls. |
| Navigation | Methods that navigate return the destination page object, not void. |
| State | Page objects are stateless. No caching locator text, no tracking "current step." |
| Constructor | Takes Page (or Locator for components). Nothing else. No URLs, no test data. |
| Naming | LoginPage, DashboardPage, NavbarComponent. File: login.page.ts, navbar.component.ts. |
Patterns
1. Basic POM Class
Use when: A page has 3+ interactions across multiple tests. Avoid when: A page is used in a single test file with trivial interactions -- use a helper function instead.
TypeScript
// tests/pages/login.page.ts
import { type Page, type Locator } from "@playwright/test"
export class LoginPage {
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(private readonly page: Page) {
this.usernameInput = page.getByLabel("Username")
this.passwordInput = page.getByLabel("Password")
this.submitButton = page.getByRole("button", { name: "Sign in" })
this.errorMessage = page.getByRole("alert")
}
async goto() {
await this.page.goto("/login")
}
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}JavaScript
// tests/pages/login.page.js
// @ts-check
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.usernameInput = page.getByLabel("Username")
this.passwordInput = page.getByLabel("Password")
this.submitButton = page.getByRole("button", { name: "Sign in" })
this.errorMessage = page.getByRole("alert")
}
async goto() {
await this.page.goto("/login")
}
async login(username, password) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}Test usage
// tests/auth.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
test("successful login redirects to dashboard", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login("admin", "password123")
await expect(page).toHaveURL("/dashboard")
})
test("invalid credentials show error", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login("admin", "wrong")
await expect(loginPage.errorMessage).toBeVisible()
await expect(loginPage.errorMessage).toHaveText("Invalid credentials")
})2. Component Objects
Use when: A UI element (navbar, sidebar, modal, form, table) appears on multiple pages. Avoid when: The component is page-specific and never reused.
Components take a Locator (their root container), not a Page. This scopes all queries to the component's DOM subtree and allows composing components into page objects.
TypeScript
// tests/components/navbar.component.ts
import { type Locator } from "@playwright/test"
export class NavbarComponent {
readonly profileMenu: Locator
readonly searchInput: Locator
readonly notificationBell: Locator
constructor(private readonly root: Locator) {
this.profileMenu = root.getByRole("button", { name: "Profile" })
this.searchInput = root.getByRole("searchbox")
this.notificationBell = root.getByRole("button", { name: "Notifications" })
}
async search(query: string) {
await this.searchInput.fill(query)
await this.searchInput.press("Enter")
}
async openProfile() {
await this.profileMenu.click()
}
async openNotifications() {
await this.notificationBell.click()
}
}
// tests/components/modal.component.ts
import { type Locator } from "@playwright/test"
export class ModalComponent {
readonly title: Locator
readonly closeButton: Locator
readonly confirmButton: Locator
readonly cancelButton: Locator
constructor(private readonly root: Locator) {
this.title = root.getByRole("heading")
this.closeButton = root.getByRole("button", { name: "Close" })
this.confirmButton = root.getByRole("button", { name: "Confirm" })
this.cancelButton = root.getByRole("button", { name: "Cancel" })
}
async confirm() {
await this.confirmButton.click()
}
async cancel() {
await this.cancelButton.click()
}
async close() {
await this.closeButton.click()
}
}
// tests/pages/dashboard.page.ts
import { type Page } from "@playwright/test"
import { NavbarComponent } from "../components/navbar.component"
import { ModalComponent } from "../components/modal.component"
export class DashboardPage {
readonly navbar: NavbarComponent
readonly deleteModal: ModalComponent
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.deleteModal = new ModalComponent(page.getByRole("dialog"))
}
async goto() {
await this.page.goto("/dashboard")
}
async deleteItem(name: string) {
await this.page.getByRole("row", { name }).getByRole("button", { name: "Delete" }).click()
await this.deleteModal.confirm()
}
}JavaScript
// tests/components/navbar.component.js
// @ts-check
export class NavbarComponent {
/** @param {import('@playwright/test').Locator} root */
constructor(root) {
this.root = root
this.profileMenu = root.getByRole("button", { name: "Profile" })
this.searchInput = root.getByRole("searchbox")
this.notificationBell = root.getByRole("button", { name: "Notifications" })
}
async search(query) {
await this.searchInput.fill(query)
await this.searchInput.press("Enter")
}
async openProfile() {
await this.profileMenu.click()
}
async openNotifications() {
await this.notificationBell.click()
}
}
// tests/pages/dashboard.page.js
// @ts-check
import { NavbarComponent } from "../components/navbar.component.js"
import { ModalComponent } from "../components/modal.component.js"
export class DashboardPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.deleteModal = new ModalComponent(page.getByRole("dialog"))
}
async goto() {
await this.page.goto("/dashboard")
}
async deleteItem(name) {
await this.page.getByRole("row", { name }).getByRole("button", { name: "Delete" }).click()
await this.deleteModal.confirm()
}
}3. Page Object with Navigation
Use when: An action on one page navigates to a different page (form submission, link click, redirect). Avoid when: Navigation is uncertain (might fail validation and stay on the same page).
When a method causes navigation, return the destination page object. This creates a typed chain that mirrors the user flow and catches stale page object usage at the call site.
TypeScript
// tests/pages/login.page.ts
import { 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")
}
/** Returns DashboardPage on success. Call only when credentials are valid. */
async loginAs(username: string, password: string): Promise<DashboardPage> {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
await this.page.waitForURL("/dashboard")
return new DashboardPage(this.page)
}
/** Use for invalid credential tests -- stays on login page. */
async loginExpectingError(username: string, password: string) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
}
}
// tests/pages/dashboard.page.ts
import { type Page } from "@playwright/test"
import { SettingsPage } from "./settings.page"
export class DashboardPage {
constructor(private readonly page: Page) {}
async gotoSettings(): Promise<SettingsPage> {
await this.page.getByRole("link", { name: "Settings" }).click()
await this.page.waitForURL("/settings")
return new SettingsPage(this.page)
}
}JavaScript
// tests/pages/login.page.js
// @ts-check
import { DashboardPage } from "./dashboard.page.js"
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
}
async goto() {
await this.page.goto("/login")
}
/** @returns {Promise<DashboardPage>} */
async loginAs(username, password) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
await this.page.waitForURL("/dashboard")
return new DashboardPage(this.page)
}
async loginExpectingError(username, password) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
}
}Test usage showing the navigation chain
// tests/settings.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
test("user can navigate from login to settings", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
const dashboard = await loginPage.loginAs("admin", "password123")
const settings = await dashboard.gotoSettings()
await expect(page).toHaveURL("/settings")
})4. Page Object as Fixture
Use when: Page objects are used in 3+ test files. This is the recommended approach for mature test suites. Avoid when: Early prototyping with rapidly changing pages.
Fixtures eliminate boilerplate instantiation, provide automatic cleanup, and make POMs available via destructuring -- identical to built-in page and request fixtures.
TypeScript
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
import { DashboardPage } from "./pages/dashboard.page"
import { SettingsPage } from "./pages/settings.page"
type PageObjects = {
loginPage: LoginPage
dashboardPage: DashboardPage
settingsPage: SettingsPage
}
export const test = base.extend<PageObjects>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page))
},
})
export { expect } from "@playwright/test"JavaScript
// tests/fixtures.js
// @ts-check
import { test as base } from "@playwright/test"
import { LoginPage } from "./pages/login.page.js"
import { DashboardPage } from "./pages/dashboard.page.js"
import { SettingsPage } from "./pages/settings.page.js"
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page))
},
})
export { expect } from "@playwright/test"Test usage
// tests/auth.spec.ts
import { test, expect } from "./fixtures"
test("successful login redirects to dashboard", async ({ loginPage, page }) => {
await loginPage.goto()
await loginPage.login("admin", "password123")
await expect(page).toHaveURL("/dashboard")
})Advanced: fixture that performs setup
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { DashboardPage } from "./pages/dashboard.page"
export const test = base.extend<{ authenticatedDashboard: DashboardPage }>({
authenticatedDashboard: async ({ page }, use) => {
// Setup: navigate and authenticate
await page.goto("/login")
await page.getByLabel("Username").fill("admin")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(new DashboardPage(page))
// Teardown (optional): logout, clean up test data
await page.goto("/logout")
},
})
export { expect } from "@playwright/test"5. Factory Functions
Use when: A page has few actions and no shared state. Simpler than a class, still encapsulates actions. Avoid when: The page has 5+ methods or needs component composition.
Factory functions are a lightweight alternative for simple pages. They return an object with action methods, avoiding class ceremony.
TypeScript
// tests/pages/error.page.ts
import { type Page } from "@playwright/test"
export function createErrorPage(page: Page) {
return {
errorHeading: page.getByRole("heading", { name: "Something went wrong" }),
retryButton: page.getByRole("button", { name: "Retry" }),
async goto() {
await page.goto("/error")
},
async retry() {
await page.getByRole("button", { name: "Retry" }).click()
},
async goHome() {
await page.getByRole("link", { name: "Go home" }).click()
await page.waitForURL("/")
},
}
}JavaScript
// tests/pages/error.page.js
// @ts-check
/** @param {import('@playwright/test').Page} page */
export function createErrorPage(page) {
return {
errorHeading: page.getByRole("heading", { name: "Something went wrong" }),
retryButton: page.getByRole("button", { name: "Retry" }),
async goto() {
await page.goto("/error")
},
async retry() {
await page.getByRole("button", { name: "Retry" }).click()
},
async goHome() {
await page.getByRole("link", { name: "Go home" }).click()
await page.waitForURL("/")
},
}
}Test usage
// tests/error.spec.ts
import { test, expect } from "@playwright/test"
import { createErrorPage } from "./pages/error.page"
test("retry button reloads the page", async ({ page }) => {
const errorPage = createErrorPage(page)
await errorPage.goto()
await errorPage.retry()
await expect(page).not.toHaveURL("/error")
})6. Getter Pattern for Dynamic Locators
Use when: Locators depend on dynamic content (parameterized rows, nth items, content that changes after page load). Avoid when: All locators are static and known at construction time -- use constructor assignment instead.
Getters create the locator fresh on each access. This avoids stale references when the DOM changes after the page object is constructed.
TypeScript
// tests/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test"
export class UsersPage {
constructor(private readonly page: Page) {}
/** Static locators -- fine in constructor or as getters, either works. */
get addUserButton(): Locator {
return this.page.getByRole("button", { name: "Add user" })
}
get userRows(): Locator {
return this.page.getByRole("row").filter({ hasNot: this.page.getByRole("columnheader") })
}
/** Dynamic locator -- must be a method since it takes a parameter. */
userRow(name: string): Locator {
return this.page.getByRole("row", { name })
}
userDeleteButton(name: string): Locator {
return this.userRow(name).getByRole("button", { name: "Delete" })
}
async goto() {
await this.page.goto("/admin/users")
}
async deleteUser(name: string) {
await this.userDeleteButton(name).click()
// Wait for row to disappear, confirming deletion
await this.userRow(name).waitFor({ state: "hidden" })
}
async addUser() {
await this.addUserButton.click()
}
}JavaScript
// tests/pages/users.page.js
// @ts-check
export class UsersPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
}
get addUserButton() {
return this.page.getByRole("button", { name: "Add user" })
}
get userRows() {
return this.page.getByRole("row").filter({ hasNot: this.page.getByRole("columnheader") })
}
userRow(name) {
return this.page.getByRole("row", { name })
}
userDeleteButton(name) {
return this.userRow(name).getByRole("button", { name: "Delete" })
}
async goto() {
await this.page.goto("/admin/users")
}
async deleteUser(name) {
await this.userDeleteButton(name).click()
await this.userRow(name).waitFor({ state: "hidden" })
}
async addUser() {
await this.addUserButton.click()
}
}7. Directory Structure
Use this layout. Adapt folder names to your convention, but keep the separation between pages, components, and fixtures.
tests/
fixtures.ts # Custom test with POM fixtures
auth.spec.ts # Test files at top level or in feature folders
dashboard.spec.ts
pages/ # One file per page
login.page.ts
dashboard.page.ts
settings.page.ts
users.page.ts
components/ # Reusable UI components
navbar.component.ts
modal.component.ts
sidebar.component.ts
data-table.component.tsRules:
- One class per file. File name matches the class:
LoginPagelives inlogin.page.ts. - Pages vs components: Pages own a full route (
/login,/dashboard). Components are embedded in pages (NavbarComponent,ModalComponent). - No barrel files (
index.tsre-exporting everything). Import directly from the file. Barrel files create circular dependency traps and slow down IDE tooling. fixtures.tsattests/root. Every spec importstestandexpectfrom./fixturesinstead of@playwright/test.
8. Handling Async Initialization
Use when: A page object needs async setup before it is usable (waiting for API data to load, waiting for animations to settle, waiting for a specific element to appear). Avoid when: The page is ready immediately after goto().
Never put await in a constructor. Use a static factory method instead.
TypeScript
// tests/pages/analytics.page.ts
import { type Page, type Locator } from "@playwright/test"
export class AnalyticsPage {
readonly chart: Locator
readonly dateRange: Locator
private constructor(private readonly page: Page) {
this.chart = page.locator('[data-testid="analytics-chart"]')
this.dateRange = page.getByLabel("Date range")
}
/** Use this instead of `new AnalyticsPage()`. Waits for chart data to load. */
static async create(page: Page): Promise<AnalyticsPage> {
const analyticsPage = new AnalyticsPage(page)
await page.goto("/analytics")
// Wait for the async content that makes this page "ready"
await analyticsPage.chart.waitFor({ state: "visible" })
return analyticsPage
}
async selectDateRange(range: string) {
await this.dateRange.click()
await this.page.getByRole("option", { name: range }).click()
// Wait for chart to update after date change
await this.chart.waitFor({ state: "visible" })
}
}JavaScript
// tests/pages/analytics.page.js
// @ts-check
export class AnalyticsPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.chart = page.locator('[data-testid="analytics-chart"]')
this.dateRange = page.getByLabel("Date range")
}
/** @param {import('@playwright/test').Page} page */
static async create(page) {
const analyticsPage = new AnalyticsPage(page)
await page.goto("/analytics")
await analyticsPage.chart.waitFor({ state: "visible" })
return analyticsPage
}
async selectDateRange(range) {
await this.dateRange.click()
await this.page.getByRole("option", { name: range }).click()
await this.chart.waitFor({ state: "visible" })
}
}As a fixture
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { AnalyticsPage } from "./pages/analytics.page"
export const test = base.extend<{ analyticsPage: AnalyticsPage }>({
analyticsPage: async ({ page }, use) => {
const analyticsPage = await AnalyticsPage.create(page)
await use(analyticsPage)
},
})
export { expect } from "@playwright/test"Decision Guide
How complex is the page?
│
├── 1-2 interactions, single test file
│ └── No POM. Inline locators in the test are fine.
│
├── 3-5 interactions OR 2+ test files use it
│ ├── Few methods, no composition needed
│ │ └── Factory function (Pattern 5)
│ └── Multiple methods, needs component composition
│ └── Full POM class (Pattern 1 + 2)
│
├── Used across 3+ test files
│ └── POM class + fixture injection (Pattern 4)
│
└── Page requires async setup before usable
└── Static factory method (Pattern 8) + fixture| Factor | Simple helper | Factory function | POM class | POM + fixture |
|---|---|---|---|---|
| Page complexity | 1-2 actions | 3-5 actions | 5+ actions | 5+ actions |
| Reuse across files | 1 file | 1-2 files | 2+ files | 3+ files |
| Component composition | No | No | Yes | Yes |
| Team size | Solo | Small | Any | Any |
| Setup ceremony | None | Low | Medium | Medium (once) |
| Recommended for | Quick prototypes | Simple pages | Standard choice | Mature suites |
Anti-Patterns
God Object
One page object for the entire application or a page object with 30+ methods covering unrelated features.
// BAD: one class for everything
class AppPage {
async login() {
/* ... */
}
async addToCart() {
/* ... */
}
async checkout() {
/* ... */
}
async changePassword() {
/* ... */
}
async viewAnalytics() {
/* ... */
}
// 40 more methods...
}Fix: Split by page or feature. One class per logical page. Compose shared UI into component objects.
Assertions Inside Page Objects
// BAD: page object owns the assertion
class LoginPage {
async loginAndVerify(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
// This assertion belongs in the test, not here
await expect(this.page).toHaveURL("/dashboard")
}
}
// GOOD: page object performs action, test asserts
class LoginPage {
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
// In test:
await loginPage.login("admin", "pass")
await expect(page).toHaveURL("/dashboard")Exception: waitForURL() or waitFor() calls inside POM methods that ensure navigation completed are fine -- those are synchronization, not assertion.
Deeply Nested Inheritance
// BAD: inheritance chain
class BasePage {
/* ... */
}
class AuthenticatedPage extends BasePage {
/* ... */
}
class AdminPage extends AuthenticatedPage {
/* ... */
}
class SuperAdminPage extends AdminPage {
/* ... */
}Fix: Use composition. Create component objects for shared UI (navbar, sidebar) and compose them into page objects. Prefer has-a over is-a.
// GOOD: composition
class AdminPage {
readonly navbar: NavbarComponent
readonly sidebar: AdminSidebarComponent
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.sidebar = new AdminSidebarComponent(page.getByRole("complementary"))
}
}Storing State in Page Objects
// BAD: tracking state
class CartPage {
private itemCount = 0
async addItem(name: string) {
await this.page.getByRole("button", { name: `Add ${name}` }).click()
this.itemCount++ // State goes stale if the UI changes independently
}
getCount() {
return this.itemCount // Lies if another tab or API call modified the cart
}
}Fix: Read state from the DOM when needed. The page is the source of truth, not your JavaScript variable.
// GOOD: read from DOM
class CartPage {
async addItem(name: string) {
await this.page.getByRole("button", { name: `Add ${name}` }).click()
}
get itemCount(): Locator {
return this.page.getByTestId("cart-count")
}
}
// In test:
await cartPage.addItem("Widget")
await expect(cartPage.itemCount).toHaveText("1")Exposing Raw Locators as the Primary API
// BAD: test manipulates locators directly
const loginPage = new LoginPage(page)
await loginPage.usernameInput.fill("admin")
await loginPage.passwordInput.fill("secret")
await loginPage.submitButton.click()
// GOOD: test calls an action
const loginPage = new LoginPage(page)
await loginPage.login("admin", "secret")Exposing locators as readonly properties for assertions is acceptable (expect(loginPage.errorMessage).toBeVisible()). The anti-pattern is requiring tests to orchestrate multi-step interactions through raw locators.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Locator times out but element exists | POM constructed before navigation; locator attached to wrong page state | Move goto() before POM construction, or use getter pattern (Pattern 6) |
| Circular import between page objects | LoginPage imports DashboardPage which imports LoginPage | Break the cycle: use lazy import() in the navigation method, or have the test create the destination POM |
| Fixture not available in test | Test imports test from @playwright/test instead of ./fixtures | Always import test and expect from your custom fixtures file |
| TypeScript error: property does not exist on fixture | Fixture type not declared in test.extend<>() generic | Add the POM type to the generic parameter: test.extend<\{ loginPage: LoginPage \}>() |
| POM methods feel duplicated across similar pages | Multiple pages share the same form structure | Extract a component object for the shared form, compose into each page |
page.goto() in constructor causes "cannot use await" | Constructors cannot be async | Use static factory method (Pattern 8) |
Related
- core/fixtures-and-hooks.md -- how
test.extend()works in depth, fixture scoping, teardown - core/locators.md -- choosing the right locator strategy for POM properties
- core/pom-vs-fixtures-vs-helpers.md -- expanded decision framework for when to use each approach
- core/test-organization.md -- file and folder conventions for the broader test suite