# Page Objects vs Fixtures vs Helpers

> **When to use**: When deciding how to organize reusable test code in a Playwright project.
> **Prerequisites**: [core/page-object-model.md](../core/page-object-model.md), [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md)

## Quick Answer

**Use all three.** Most projects benefit from a hybrid approach:

- **Page objects** for UI interaction (pages and components with 5+ interactions)
- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)
- **Helper functions** for stateless utilities (generate data, format values, simple waits)

If you are only going to use one pattern, use **custom fixtures** -- they handle setup/teardown, compose well, and Playwright is built around them. But you should use all three.

## Comparison Table

| Aspect                   | Page Objects                                        | Custom Fixtures                                        | Helper Functions                              |
| ------------------------ | --------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- |
| **Primary purpose**      | Encapsulate UI interactions for a page or component | Provide shared resources with setup/teardown lifecycle | Stateless utility operations                  |
| **Setup/teardown**       | Manual (constructor / methods)                      | Built-in (`use()` callback with automatic teardown)    | None (stateless)                              |
| **Type safety**          | Full class-based typing                             | Full typing via `test.extend<T>()` generics            | Full typing via function signatures           |
| **Composability**        | Compose via constructor injection or fixture wiring | Compose by depending on other fixtures                 | Compose by calling other functions            |
| **Learning curve**       | Low (plain classes)                                 | Medium (Playwright-specific `test.extend` API)         | Lowest (plain functions)                      |
| **Test readability**     | High -- `loginPage.signIn(user)` reads like intent  | High -- `{ adminPage }` declares dependencies          | Medium -- utility calls mixed with test logic |
| **Debugging experience** | Good -- step into class methods                     | Best -- fixtures appear in traces and reports          | Good -- step into functions                   |
| **IDE support**          | Excellent -- autocomplete on class methods          | Excellent -- autocomplete on fixture parameters        | Excellent -- autocomplete on function params  |
| **State management**     | Holds page reference and locators as instance state | Manages resource lifecycle (create, provide, cleanup)  | No state (pure functions)                     |
| **Best for**             | Pages with many interactions reused across files    | Resources that need setup AND teardown                 | Simple, reusable logic with no side effects   |

## Decision Flowchart

```
What kind of reusable code are you writing?
|
+-- Does it interact with a page or component in the browser?
|   |
|   +-- Does the page/component have 5+ interactions (fill, click, navigate, assert)?
|   |   |
|   |   +-- YES: Is it used in 3+ test files?
|   |   |   +-- YES --> PAGE OBJECT
|   |   |   +-- NO --> Inline the interactions or use a small helper function
|   |   |
|   |   +-- NO (< 5 interactions) --> HELPER FUNCTION
|   |
|   +-- Does it need setup before the test AND cleanup after?
|       +-- YES --> CUSTOM FIXTURE (wraps a page object or resource)
|       +-- NO --> PAGE OBJECT method or HELPER FUNCTION
|
+-- Does it manage a resource with lifecycle (create/destroy)?
|   |
|   +-- Examples: auth state, database connection, API client,
|   |   test user, browser context, temporary files
|   |
|   +-- YES --> CUSTOM FIXTURE (always)
|
+-- Is it a stateless utility? (no browser, no side effects)
|   |
|   +-- Examples: generate random email, format date, build URL,
|   |   create test data object, parse response
|   |
|   +-- YES --> HELPER FUNCTION
|
+-- Not sure?
    +-- Start with a HELPER FUNCTION
    +-- Promote to PAGE OBJECT when interactions grow
    +-- Promote to FIXTURE when lifecycle management is needed
```

## When to Use Page Objects

**Best for**: Pages or components with 5+ interactions that appear in 3+ test files. Encapsulate navigation, form filling, action sequences, and element locators into a single class that reads like user intent.

**TypeScript**

```typescript
// page-objects/checkout.page.ts
import { type Page, type Locator, expect } from "@playwright/test"

export class CheckoutPage {
  readonly page: Page
  readonly cardNumber: Locator
  readonly expiry: Locator
  readonly cvc: Locator
  readonly payButton: Locator
  readonly promoCodeInput: Locator
  readonly orderTotal: Locator

  constructor(page: Page) {
    this.page = page
    this.cardNumber = page.getByLabel("Card number")
    this.expiry = page.getByLabel("Expiry")
    this.cvc = page.getByLabel("CVC")
    this.payButton = page.getByRole("button", { name: "Pay now" })
    this.promoCodeInput = page.getByLabel("Promo code")
    this.orderTotal = page.getByTestId("order-total")
  }

  async goto() {
    await this.page.goto("/checkout")
  }

  async fillPaymentDetails(card: { number: string; expiry: string; cvc: string }) {
    await this.cardNumber.fill(card.number)
    await this.expiry.fill(card.expiry)
    await this.cvc.fill(card.cvc)
  }

  async pay() {
    await this.payButton.click()
    await this.page.waitForURL("**/confirmation")
  }

  async applyPromoCode(code: string) {
    await this.promoCodeInput.fill(code)
    await this.page.getByRole("button", { name: "Apply" }).click()
    await expect(this.page.getByText("Code applied")).toBeVisible()
  }

  async expectTotal(amount: string) {
    await expect(this.orderTotal).toHaveText(amount)
  }
}
```

```typescript
// tests/checkout/payment.spec.ts
import { test, expect } from "@playwright/test"
import { CheckoutPage } from "../page-objects/checkout.page"

test("should complete checkout with valid card", async ({ page }) => {
  const checkout = new CheckoutPage(page)
  await checkout.goto()
  await checkout.fillPaymentDetails({
    number: "4242424242424242",
    expiry: "12/28",
    cvc: "123",
  })
  await checkout.pay()
  await expect(page.getByText("Payment successful")).toBeVisible()
})

test("should apply promo code and update total", async ({ page }) => {
  const checkout = new CheckoutPage(page)
  await checkout.goto()
  await checkout.applyPromoCode("SAVE20")
  await checkout.expectTotal("$79.99")
})
```

**JavaScript**

```javascript
// page-objects/checkout.page.js
const { expect } = require("@playwright/test")

class CheckoutPage {
  constructor(page) {
    this.page = page
    this.cardNumber = page.getByLabel("Card number")
    this.expiry = page.getByLabel("Expiry")
    this.cvc = page.getByLabel("CVC")
    this.payButton = page.getByRole("button", { name: "Pay now" })
    this.promoCodeInput = page.getByLabel("Promo code")
    this.orderTotal = page.getByTestId("order-total")
  }

  async goto() {
    await this.page.goto("/checkout")
  }

  async fillPaymentDetails(card) {
    await this.cardNumber.fill(card.number)
    await this.expiry.fill(card.expiry)
    await this.cvc.fill(card.cvc)
  }

  async pay() {
    await this.payButton.click()
    await this.page.waitForURL("**/confirmation")
  }

  async applyPromoCode(code) {
    await this.promoCodeInput.fill(code)
    await this.page.getByRole("button", { name: "Apply" }).click()
    await expect(this.page.getByText("Code applied")).toBeVisible()
  }

  async expectTotal(amount) {
    await expect(this.orderTotal).toHaveText(amount)
  }
}

module.exports = { CheckoutPage }
```

```javascript
// tests/checkout/payment.spec.js
const { test, expect } = require("@playwright/test")
const { CheckoutPage } = require("../page-objects/checkout.page")

test("should complete checkout with valid card", async ({ page }) => {
  const checkout = new CheckoutPage(page)
  await checkout.goto()
  await checkout.fillPaymentDetails({
    number: "4242424242424242",
    expiry: "12/28",
    cvc: "123",
  })
  await checkout.pay()
  await expect(page.getByText("Payment successful")).toBeVisible()
})

test("should apply promo code and update total", async ({ page }) => {
  const checkout = new CheckoutPage(page)
  await checkout.goto()
  await checkout.applyPromoCode("SAVE20")
  await checkout.expectTotal("$79.99")
})
```

**Key principles for page objects:**

- One class per logical page or component, not per URL
- Constructor takes `Page` (and optionally other page objects for composition)
- Locators are `readonly` properties assigned in the constructor
- Methods represent user-intent actions (`signIn`, `addToCart`), not low-level clicks
- Navigation methods (`goto`) belong on the page object
- Assertions can live on the page object when they verify page-specific state

## When to Use Custom Fixtures

**Best for**: Shared resources that need setup before the test and teardown after -- auth state, database connections, API clients, test users, seeded data, custom browser contexts. Anything with a lifecycle belongs in a fixture.

**TypeScript**

```typescript
// fixtures/base.fixture.ts
import { test as base, expect } from "@playwright/test"
import { CheckoutPage } from "../page-objects/checkout.page"
import { DashboardPage } from "../page-objects/dashboard.page"

// Define types for all custom fixtures
type MyFixtures = {
  checkoutPage: CheckoutPage
  dashboardPage: DashboardPage
  authenticatedPage: ReturnType<(typeof base)["page"]> extends Promise<infer P> ? P : never
  testUser: { email: string; password: string; id: string }
  apiClient: {
    get: (path: string) => Promise<Response>
    post: (path: string, data: unknown) => Promise<Response>
  }
}

export const test = base.extend<MyFixtures>({
  // Fixture that wraps a page object — created fresh for each test
  checkoutPage: async ({ page }, use) => {
    const checkoutPage = new CheckoutPage(page)
    await use(checkoutPage)
    // No teardown needed — page closes automatically
  },

  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page)
    await use(dashboardPage)
  },

  // Fixture with real setup AND teardown — creates a test user, cleans up after
  testUser: async ({ request }, use) => {
    // Setup: create a user via API
    const response = await request.post("/api/test/users", {
      data: {
        email: `test-${Date.now()}@example.com`,
        password: "TestPass123!",
      },
    })
    const user = await response.json()

    // Provide the user to the test
    await use(user)

    // Teardown: delete the user (runs even if test fails)
    await request.delete(`/api/test/users/${user.id}`)
  },

  // Fixture that depends on another fixture
  authenticatedPage: async ({ page, testUser }, use) => {
    await page.goto("/login")
    await page.getByLabel("Email").fill(testUser.email)
    await page.getByLabel("Password").fill(testUser.password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await expect(page).toHaveURL("/dashboard")

    await use(page)
    // Teardown: logout (optional — browser context is destroyed anyway)
  },

  // API client fixture with lifecycle
  apiClient: async ({ request }, use) => {
    const client = {
      get: (path: string) => request.get(`/api${path}`),
      post: (path: string, data: unknown) => request.post(`/api${path}`, { data }),
    }

    await use(client)
    // Teardown runs automatically when request fixture tears down
  },
})

export { expect } from "@playwright/test"
```

```typescript
// tests/dashboard/widgets.spec.ts
import { test, expect } from "../../fixtures/base.fixture"

// testUser and authenticatedPage are set up automatically by the fixture system
test("authenticated user sees dashboard widgets", async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(authenticatedPage.getByTestId("revenue-widget")).toBeVisible()
  await expect(authenticatedPage.getByTestId("orders-widget")).toBeVisible()
})

test("user can refresh dashboard data", async ({ authenticatedPage, dashboardPage }) => {
  await authenticatedPage.getByRole("button", { name: "Refresh" }).click()
  await expect(authenticatedPage.getByText("Updated just now")).toBeVisible()
})

// testUser fixture creates AND cleans up the user automatically
test("new user sees onboarding prompt", async ({ authenticatedPage, testUser }) => {
  await expect(authenticatedPage.getByText(`Welcome, ${testUser.email}`)).toBeVisible()
  await expect(authenticatedPage.getByRole("dialog", { name: "Get started" })).toBeVisible()
})
```

**JavaScript**

```javascript
// fixtures/base.fixture.js
const { test: base, expect } = require("@playwright/test")
const { CheckoutPage } = require("../page-objects/checkout.page")
const { DashboardPage } = require("../page-objects/dashboard.page")

const test = base.extend({
  checkoutPage: async ({ page }, use) => {
    const checkoutPage = new CheckoutPage(page)
    await use(checkoutPage)
  },

  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page)
    await use(dashboardPage)
  },

  testUser: async ({ request }, use) => {
    const response = await request.post("/api/test/users", {
      data: {
        email: `test-${Date.now()}@example.com`,
        password: "TestPass123!",
      },
    })
    const user = await response.json()

    await use(user)

    await request.delete(`/api/test/users/${user.id}`)
  },

  authenticatedPage: async ({ page, testUser }, use) => {
    await page.goto("/login")
    await page.getByLabel("Email").fill(testUser.email)
    await page.getByLabel("Password").fill(testUser.password)
    await page.getByRole("button", { name: "Sign in" }).click()
    await expect(page).toHaveURL("/dashboard")

    await use(page)
  },

  apiClient: async ({ request }, use) => {
    const client = {
      get: (path) => request.get(`/api${path}`),
      post: (path, data) => request.post(`/api${path}`, { data }),
    }

    await use(client)
  },
})

module.exports = { test, expect }
```

```javascript
// tests/dashboard/widgets.spec.js
const { test, expect } = require("../../fixtures/base.fixture")

test("authenticated user sees dashboard widgets", async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole("heading", { name: "Dashboard" })).toBeVisible()
  await expect(authenticatedPage.getByTestId("revenue-widget")).toBeVisible()
  await expect(authenticatedPage.getByTestId("orders-widget")).toBeVisible()
})

test("new user sees onboarding prompt", async ({ authenticatedPage, testUser }) => {
  await expect(authenticatedPage.getByText(`Welcome, ${testUser.email}`)).toBeVisible()
  await expect(authenticatedPage.getByRole("dialog", { name: "Get started" })).toBeVisible()
})
```

**Key principles for custom fixtures:**

- Use `test.extend()` to define fixtures -- never use module-level variables
- The `use()` callback separates setup (before) from teardown (after)
- Teardown runs even if the test fails or crashes -- this is the main advantage over `afterEach`
- Fixtures compose: one fixture can depend on another (`authenticatedPage` depends on `testUser`)
- Fixtures are lazy: they are only created when a test actually requests them
- Wrap page objects in fixtures to get automatic lifecycle management
- Fixtures appear in Playwright traces, making debugging easier

## When to Use Helper Functions

**Best for**: Stateless utility operations -- generating test data, formatting values, building URLs, parsing responses, creating simple wait conditions. Pure functions with no side effects and no browser interaction.

**TypeScript**

```typescript
// helpers/test-data.ts
import { randomUUID } from "node:crypto"

export function generateEmail(prefix = "test"): string {
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@example.com`
}

export function generateUser(overrides: Partial<TestUser> = {}): TestUser {
  return {
    email: generateEmail(),
    password: "TestPass123!",
    firstName: "Test",
    lastName: "User",
    ...overrides,
  }
}

interface TestUser {
  email: string
  password: string
  firstName: string
  lastName: string
}

export function formatCurrency(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`
}

export function buildApiUrl(path: string, params: Record<string, string> = {}): string {
  const url = new URL(path, "http://localhost:3000")
  Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value))
  return url.toString()
}
```

```typescript
// helpers/assertions.ts
import { type Page, expect } from "@playwright/test"

/**
 * Wait for all network requests to settle.
 * Use sparingly — prefer specific assertions over blanket network waits.
 */
export async function waitForNetworkIdle(page: Page, timeout = 5000): Promise<void> {
  await page.waitForLoadState("networkidle", { timeout })
}

/**
 * Assert that a toast notification appears and then disappears.
 */
export async function expectToast(page: Page, message: string): Promise<void> {
  const toast = page.getByRole("status").filter({ hasText: message })
  await expect(toast).toBeVisible()
  await expect(toast).toBeHidden({ timeout: 10000 })
}
```

```typescript
// tests/settings/profile.spec.ts
import { test, expect } from "@playwright/test"
import { generateEmail, generateUser } from "../../helpers/test-data"
import { expectToast } from "../../helpers/assertions"

test("should update user email", async ({ page }) => {
  const newEmail = generateEmail("updated")

  await page.goto("/settings/profile")
  await page.getByLabel("Email").fill(newEmail)
  await page.getByRole("button", { name: "Save" }).click()

  await expectToast(page, "Profile updated")
  await expect(page.getByLabel("Email")).toHaveValue(newEmail)
})

test("should display user info", async ({ page }) => {
  const user = generateUser({ firstName: "Jane", lastName: "Doe" })
  // ... use user data in test setup
})
```

**JavaScript**

```javascript
// helpers/test-data.js
const { randomUUID } = require("node:crypto")

function generateEmail(prefix = "test") {
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@example.com`
}

function generateUser(overrides = {}) {
  return {
    email: generateEmail(),
    password: "TestPass123!",
    firstName: "Test",
    lastName: "User",
    ...overrides,
  }
}

function formatCurrency(cents) {
  return `$${(cents / 100).toFixed(2)}`
}

function buildApiUrl(path, params = {}) {
  const url = new URL(path, "http://localhost:3000")
  Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value))
  return url.toString()
}

module.exports = { generateEmail, generateUser, formatCurrency, buildApiUrl }
```

```javascript
// helpers/assertions.js
const { expect } = require("@playwright/test")

async function waitForNetworkIdle(page, timeout = 5000) {
  await page.waitForLoadState("networkidle", { timeout })
}

async function expectToast(page, message) {
  const toast = page.getByRole("status").filter({ hasText: message })
  await expect(toast).toBeVisible()
  await expect(toast).toBeHidden({ timeout: 10000 })
}

module.exports = { waitForNetworkIdle, expectToast }
```

```javascript
// tests/settings/profile.spec.js
const { test, expect } = require("@playwright/test")
const { generateEmail, generateUser } = require("../../helpers/test-data")
const { expectToast } = require("../../helpers/assertions")

test("should update user email", async ({ page }) => {
  const newEmail = generateEmail("updated")

  await page.goto("/settings/profile")
  await page.getByLabel("Email").fill(newEmail)
  await page.getByRole("button", { name: "Save" }).click()

  await expectToast(page, "Profile updated")
  await expect(page.getByLabel("Email")).toHaveValue(newEmail)
})
```

**Key principles for helper functions:**

- Pure functions with no side effects -- given the same input, always the same output
- No browser state -- helpers that need `page` should take it as a parameter, not store it
- If a helper grows to need setup/teardown, promote it to a fixture
- If a helper grows to encapsulate many page interactions, promote it to a page object
- Keep helpers small and focused -- one function, one job

## Combining Approaches

The recommended hybrid approach for real projects. This is what a well-organized Playwright project looks like.

**Project structure:**

```
tests/
+-- fixtures/
|   +-- auth.fixture.ts       # Custom fixtures: authenticatedPage, testUser, adminUser
|   +-- db.fixture.ts          # Custom fixtures: database, seedData
|   +-- base.fixture.ts        # Combines all fixtures, re-exports test and expect
+-- page-objects/
|   +-- login.page.ts          # Page object: LoginPage
|   +-- dashboard.page.ts      # Page object: DashboardPage
|   +-- checkout.page.ts       # Page object: CheckoutPage
|   +-- components/
|       +-- data-table.component.ts  # Reusable component object
|       +-- modal.component.ts
+-- helpers/
|   +-- test-data.ts            # Helper: generateEmail, generateUser, generateOrder
|   +-- assertions.ts           # Helper: expectToast, expectTableRowCount
|   +-- api.ts                  # Helper: createProductViaApi, deleteAllTestData
+-- e2e/
|   +-- auth/
|   |   +-- login.spec.ts
|   |   +-- signup.spec.ts
|   +-- checkout/
|   |   +-- cart.spec.ts
|   |   +-- payment.spec.ts
|   +-- dashboard/
|       +-- widgets.spec.ts
playwright.config.ts
```

**How the three approaches work together:**

**TypeScript**

```typescript
// fixtures/base.fixture.ts — the glue layer
import { test as base } from "@playwright/test"
import { LoginPage } from "../page-objects/login.page"
import { DashboardPage } from "../page-objects/dashboard.page"
import { CheckoutPage } from "../page-objects/checkout.page"
import { generateUser } from "../helpers/test-data"

type Fixtures = {
  loginPage: LoginPage
  dashboardPage: DashboardPage
  checkoutPage: CheckoutPage
  testUser: { email: string; password: string; id: string }
  authenticatedPage: import("@playwright/test").Page
}

export const test = base.extend<Fixtures>({
  // Page objects wrapped in fixtures for convenience
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page))
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page))
  },

  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page))
  },

  // Resource fixture with lifecycle — uses helpers for data generation
  testUser: async ({ request }, use) => {
    const userData = generateUser()
    const response = await request.post("/api/test/users", { data: userData })
    const user = await response.json()
    await use(user)
    await request.delete(`/api/test/users/${user.id}`)
  },

  // Composed fixture: depends on page object + resource fixture
  authenticatedPage: async ({ loginPage, testUser }, use) => {
    await loginPage.goto()
    await loginPage.signIn(testUser.email, testUser.password)
    await use(loginPage.page)
  },
})

export { expect } from "@playwright/test"
```

```typescript
// tests/e2e/checkout/payment.spec.ts — clean test using all three
import { test, expect } from "../../../fixtures/base.fixture"
import { generateEmail } from "../../../helpers/test-data"
import { expectToast } from "../../../helpers/assertions"

test.describe("Checkout Payment", () => {
  test("authenticated user completes purchase", async ({ authenticatedPage, checkoutPage }) => {
    // authenticatedPage: fixture handled login + user creation + cleanup
    // checkoutPage: page object encapsulates checkout UI interactions
    await checkoutPage.goto()
    await checkoutPage.fillPaymentDetails({
      number: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    })
    await checkoutPage.pay()
    await expectToast(authenticatedPage, "Payment successful") // helper for common assertion
  })

  test("guest user enters email at checkout", async ({ checkoutPage }) => {
    const email = generateEmail("guest") // helper for test data
    await checkoutPage.goto()
    await checkoutPage.page.getByLabel("Guest email").fill(email)
    await checkoutPage.fillPaymentDetails({
      number: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    })
    await checkoutPage.pay()
  })
})
```

**JavaScript**

```javascript
// fixtures/base.fixture.js
const { test: base } = require("@playwright/test")
const { LoginPage } = require("../page-objects/login.page")
const { DashboardPage } = require("../page-objects/dashboard.page")
const { CheckoutPage } = require("../page-objects/checkout.page")
const { generateUser } = require("../helpers/test-data")

const test = base.extend({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page))
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page))
  },

  checkoutPage: async ({ page }, use) => {
    await use(new CheckoutPage(page))
  },

  testUser: async ({ request }, use) => {
    const userData = generateUser()
    const response = await request.post("/api/test/users", { data: userData })
    const user = await response.json()
    await use(user)
    await request.delete(`/api/test/users/${user.id}`)
  },

  authenticatedPage: async ({ loginPage, testUser }, use) => {
    await loginPage.goto()
    await loginPage.signIn(testUser.email, testUser.password)
    await use(loginPage.page)
  },
})

module.exports = { test, expect: require("@playwright/test").expect }
```

```javascript
// tests/e2e/checkout/payment.spec.js
const { test, expect } = require("../../../fixtures/base.fixture")
const { generateEmail } = require("../../../helpers/test-data")
const { expectToast } = require("../../../helpers/assertions")

test.describe("Checkout Payment", () => {
  test("authenticated user completes purchase", async ({ authenticatedPage, checkoutPage }) => {
    await checkoutPage.goto()
    await checkoutPage.fillPaymentDetails({
      number: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    })
    await checkoutPage.pay()
    await expectToast(authenticatedPage, "Payment successful")
  })

  test("guest user enters email at checkout", async ({ checkoutPage }) => {
    const email = generateEmail("guest")
    await checkoutPage.goto()
    await checkoutPage.page.getByLabel("Guest email").fill(email)
    await checkoutPage.fillPaymentDetails({
      number: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    })
    await checkoutPage.pay()
  })
})
```

**Summary of the hybrid approach:**

| Layer            | Pattern         | Responsibility                                              |
| ---------------- | --------------- | ----------------------------------------------------------- |
| **Test file**    | `test()`        | Describes behavior, orchestrates the three layers           |
| **Fixtures**     | `test.extend()` | Resource lifecycle -- setup, provide, teardown              |
| **Page objects** | Classes         | UI interaction -- navigation, actions, locators             |
| **Helpers**      | Functions       | Utilities -- data generation, formatting, common assertions |

## Anti-Patterns

### Using only page objects for everything

```typescript
// BAD: page object managing auth state, database, AND UI
class LoginPage {
  async createTestUser() {
    /* API call to create user */
  }
  async deleteTestUser() {
    /* API call to delete user */
  }
  async seedDatabase() {
    /* database setup */
  }
  async signIn(email: string, password: string) {
    /* UI interaction */
  }
}
```

**Problem:** Page objects should only encapsulate UI interaction. Resource lifecycle (create user, seed database) belongs in fixtures where teardown is guaranteed.

**Fix:** Move `createTestUser` and `deleteTestUser` into a `testUser` fixture. Keep only `signIn` in the page object.

---

### Page objects that are just locator containers

```typescript
// BAD: no methods, no encapsulation — just a bag of locators
class LoginPage {
  emailInput = this.page.getByLabel("Email")
  passwordInput = this.page.getByLabel("Password")
  submitButton = this.page.getByRole("button", { name: "Sign in" })

  constructor(private page: Page) {}
}

// Test still does all the work
test("login", async ({ page }) => {
  const login = new LoginPage(page)
  await login.emailInput.fill("user@example.com")
  await login.passwordInput.fill("pass")
  await login.submitButton.click()
})
```

**Problem:** This adds a layer of indirection with no benefit. The test is not more readable than using locators directly.

**Fix:** Add intent-revealing methods. If the page is simple enough that locators suffice, skip the page object entirely.

```typescript
// GOOD: methods express user intent
class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto("/login")
  }

  async signIn(email: string, password: string) {
    await this.page.getByLabel("Email").fill(email)
    await this.page.getByLabel("Password").fill(password)
    await this.page.getByRole("button", { name: "Sign in" }).click()
    await this.page.waitForURL("**/dashboard")
  }
}
```

---

### Fixtures that do too much

```typescript
// BAD: one giant fixture that sets up everything
test.extend({
  everything: async ({ page, request }, use) => {
    // Creates user, seeds products, sets up payment method, navigates to dashboard,
    // opens settings modal, enables feature flags, creates admin user...
    const user = await createUser(request)
    const products = await seedProducts(request, 50)
    await setupPayment(request, user.id)
    await page.goto("/dashboard")
    await enableFeatureFlags(request, ["new-ui", "beta-checkout"])
    const admin = await createAdminUser(request)

    await use({ user, products, admin, page })

    // Massive teardown
    await deleteUser(request, user.id)
    await deleteUser(request, admin.id)
    await deleteProducts(request, products)
    await disableFeatureFlags(request, ["new-ui", "beta-checkout"])
  },
})
```

**Problem:** Every test pays the cost of full setup even when it only needs a user. Debugging is hard because the fixture does too many things.

**Fix:** Break into small, composable fixtures. Each fixture does one thing.

```typescript
// GOOD: small, composable fixtures
test.extend({
  testUser: async ({ request }, use) => {
    /* create + cleanup user */
  },
  adminUser: async ({ request }, use) => {
    /* create + cleanup admin */
  },
  seededProducts: async ({ request }, use) => {
    /* seed + cleanup products */
  },
  featureFlags: async ({ request }, use) => {
    /* enable + disable flags */
  },
})
```

---

### Helpers with side effects

```typescript
// BAD: "helper" that modifies database and stores state
let createdUserId: string

export async function createTestUser(request: APIRequestContext) {
  const response = await request.post("/api/users", { data: { email: "test@example.com" } })
  const user = await response.json()
  createdUserId = user.id // Module-level state — shared across tests!
  return user
}

export async function cleanupTestUser(request: APIRequestContext) {
  await request.delete(`/api/users/${createdUserId}`)
}
```

**Problem:** Module-level state leaks between parallel tests. Cleanup is not guaranteed if the test crashes. This is a fixture pretending to be a helper.

**Fix:** If it has side effects and needs cleanup, make it a fixture.

---

### Over-abstracting simple operations

```typescript
// BAD: helper for a one-liner that adds no clarity
export async function clickButton(page: Page, name: string) {
  await page.getByRole("button", { name }).click()
}

// BAD: page object for a page with 2 interactions
class ConfirmationPage {
  constructor(private page: Page) {}
  async expectSuccess() {
    await expect(this.page.getByText("Success")).toBeVisible()
  }
}
```

**Problem:** Adds indirection without improving readability or reducing duplication. `page.getByRole('button', { name: 'Submit' }).click()` is already clear.

**Fix:** Only abstract when there is real duplication (3+ usages) or real complexity (5+ interactions). Inline simple operations.

## Related

- [core/page-object-model.md](../core/page-object-model.md) -- detailed page object patterns and examples
- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md) -- complete guide to Playwright fixtures and lifecycle hooks
- [core/test-organization.md](../core/test-organization.md) -- file structure and naming conventions
- [core/test-architecture.md](../core/test-architecture.md) -- when to write E2E vs API vs component tests
