# 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()` 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 ? P : never testUser: { email: string; password: string; id: string } apiClient: { get: (path: string) => Promise post: (path: string, data: unknown) => Promise } } export const test = base.extend({ // 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 { 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 { 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 { await page.waitForLoadState("networkidle", { timeout }) } /** * Assert that a toast notification appears and then disappears. */ export async function expectToast(page: Page, message: string): Promise { 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({ // 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