# 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**

```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<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**

```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**

```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<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**

```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<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**

```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
