# 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` 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** ```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** ```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** ```typescript // 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** ```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** ```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** ```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 { 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 { await this.page.getByRole("link", { name: "Settings" }).click() await this.page.waitForURL("/settings") return new SettingsPage(this.page) } } ``` **JavaScript** ```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} */ 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** ```typescript // 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** ```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({ 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** ```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** ```typescript // 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** ```typescript // 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** ```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** ```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** ```typescript // 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** ```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** ```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.ts ``` Rules: - **One class per file.** File name matches the class: `LoginPage` lives in `login.page.ts`. - **Pages vs components**: Pages own a full route (`/login`, `/dashboard`). Components are embedded in pages (`NavbarComponent`, `ModalComponent`). - **No barrel files** (`index.ts` re-exporting everything). Import directly from the file. Barrel files create circular dependency traps and slow down IDE tooling. - **`fixtures.ts` at `tests/` root.** Every spec imports `test` and `expect` from `./fixtures` instead 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** ```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 { 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** ```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** ```typescript // 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. ```typescript // 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 ```typescript // 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 ```typescript // 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`. ```typescript // 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 ```typescript // 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. ```typescript // 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 ```typescript // 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](../core/fixtures-and-hooks.md) -- how `test.extend()` works in depth, fixture scoping, teardown - [core/locators.md](../core/locators.md) -- choosing the right locator strategy for POM properties - [core/pom-vs-fixtures-vs-helpers.md](../core/pom-vs-fixtures-vs-helpers.md) -- expanded decision framework for when to use each approach - [core/test-organization.md](../core/test-organization.md) -- file and folder conventions for the broader test suite