.agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md
Use all three patterns together. Most projects benefit from a hybrid approach:
If only using one pattern, choose custom fixtures — they handle setup/teardown, compose well, and Playwright is built around them.
| Aspect | Page Objects | Custom Fixtures | Helper Functions |
|---|---|---|---|
| Purpose | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |
| Lifecycle | Manual (constructor/methods) | Built-in (use() with automatic teardown) | None |
| Composability | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |
| Best for | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |
What kind of reusable code?
|
+-- Interacts with browser page/component?
| |
| +-- Has 5+ interactions (fill, click, navigate, assert)?
| | +-- YES: Used in 3+ test files?
| | | +-- YES --> PAGE OBJECT
| | | +-- NO --> Inline or small helper
| | +-- NO --> HELPER FUNCTION
| |
| +-- Needs setup before AND cleanup after test?
| +-- YES --> CUSTOM FIXTURE
| +-- NO --> PAGE OBJECT method or HELPER
|
+-- Manages resource with lifecycle (create/destroy)?
| +-- Examples: auth state, DB connection, API client, test user
| +-- YES --> CUSTOM FIXTURE (always)
|
+-- Stateless utility? (no browser, no side effects)
| +-- Examples: random email, format date, build URL, parse response
| +-- YES --> HELPER FUNCTION
|
+-- Not sure?
+-- Start with HELPER FUNCTION
+-- Promote to PAGE OBJECT when interactions grow
+-- Promote to FIXTURE when lifecycle needed
Best for pages/components with 5+ interactions appearing in 3+ test files.
// page-objects/booking.page.ts
import {type Page, type Locator, expect} from '@playwright/test'
export class BookingPage {
readonly page: Page
readonly dateField: Locator
readonly guestCount: Locator
readonly roomType: Locator
readonly reserveBtn: Locator
readonly totalPrice: Locator
constructor(page: Page) {
this.page = page
this.dateField = page.getByLabel('Check-in date')
this.guestCount = page.getByLabel('Guests')
this.roomType = page.getByLabel('Room type')
this.reserveBtn = page.getByRole('button', {name: 'Reserve'})
this.totalPrice = page.getByTestId('total-price')
}
async goto() {
await this.page.goto('/booking')
}
async fillDetails(opts: {date: string; guests: number; room: string}) {
await this.dateField.fill(opts.date)
await this.guestCount.fill(String(opts.guests))
await this.roomType.selectOption(opts.room)
}
async reserve() {
await this.reserveBtn.click()
await this.page.waitForURL('**/confirmation')
}
async expectPrice(amount: string) {
await expect(this.totalPrice).toHaveText(amount)
}
}
// tests/booking/reservation.spec.ts
import {test, expect} from '@playwright/test'
import {BookingPage} from '../page-objects/booking.page'
test('complete reservation with standard room', async ({page}) => {
const booking = new BookingPage(page)
await booking.goto()
await booking.fillDetails({date: '2026-03-15', guests: 2, room: 'standard'})
await booking.reserve()
await expect(page.getByText('Reservation confirmed')).toBeVisible()
})
Page object principles:
Pagereadonly properties in constructorreserve, fillDetails), not low-level clicksgoto) belong on the page objectBest for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.
// fixtures/base.fixture.ts
import {test as base, expect} from '@playwright/test'
import {BookingPage} from '../page-objects/booking.page'
import {generateMember} from '../helpers/data'
type Fixtures = {
bookingPage: BookingPage
member: {email: string; password: string; id: string}
loggedInPage: import('@playwright/test').Page
}
export const test = base.extend<Fixtures>({
bookingPage: async ({page}, use) => {
await use(new BookingPage(page))
},
member: async ({request}, use) => {
const data = generateMember()
const res = await request.post('/api/test/members', {data})
const member = await res.json()
await use(member)
await request.delete(`/api/test/members/${member.id}`)
},
loggedInPage: async ({page, member}, use) => {
await page.goto('/login')
await page.getByLabel('Email').fill(member.email)
await page.getByLabel('Password').fill(member.password)
await page.getByRole('button', {name: 'Sign in'}).click()
await expect(page).toHaveURL('/dashboard')
await use(page)
},
})
export {expect} from '@playwright/test'
// tests/dashboard/overview.spec.ts
import {test, expect} from '../../fixtures/base.fixture'
test('member sees dashboard widgets', async ({loggedInPage}) => {
await expect(loggedInPage.getByRole('heading', {name: 'Dashboard'})).toBeVisible()
await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible()
})
test('new member sees welcome prompt', async ({loggedInPage, member}) => {
await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible()
})
Fixture principles:
test.extend() — never module-level variablesuse() callback separates setup from teardownBest for stateless utilities — generating test data, formatting values, building URLs, parsing responses.
// helpers/data.ts
import {randomUUID} from 'node:crypto'
export function generateEmail(prefix = 'user'): string {
return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`
}
export function generateMember(overrides: Partial<Member> = {}): Member {
return {
email: generateEmail(),
password: 'SecurePass456!',
name: 'Test Member',
...overrides,
}
}
interface Member {
email: string
password: string
name: string
}
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`
}
// helpers/assertions.ts
import {type Page, expect} from '@playwright/test'
export async function expectNotification(page: Page, message: string): Promise<void> {
const notification = page.getByRole('alert').filter({hasText: message})
await expect(notification).toBeVisible()
await expect(notification).toBeHidden({timeout: 10000})
}
// tests/settings/account.spec.ts
import {test, expect} from '@playwright/test'
import {generateEmail} from '../../helpers/data'
import {expectNotification} from '../../helpers/assertions'
test('update account email', async ({page}) => {
const newEmail = generateEmail('updated')
await page.goto('/settings/account')
await page.getByLabel('Email').fill(newEmail)
await page.getByRole('button', {name: 'Save'}).click()
await expectNotification(page, 'Account updated')
await expect(page.getByLabel('Email')).toHaveValue(newEmail)
})
Helper principles:
page as parameter if neededtests/
+-- fixtures/
| +-- auth.fixture.ts
| +-- db.fixture.ts
| +-- base.fixture.ts
+-- page-objects/
| +-- login.page.ts
| +-- booking.page.ts
| +-- components/
| +-- data-table.component.ts
+-- helpers/
| +-- data.ts
| +-- assertions.ts
+-- e2e/
| +-- auth/
| | +-- login.spec.ts
| +-- booking/
| +-- reservation.spec.ts
playwright.config.ts
Layer responsibilities:
| Layer | Pattern | Responsibility |
|---|---|---|
| Test file | test() | Describes behavior, orchestrates layers |
| Fixtures | test.extend() | Resource lifecycle — setup, provide, teardown |
| Page objects | Classes | UI interaction — navigation, actions, locators |
| Helpers | Functions | Utilities — data generation, formatting, assertions |
// BAD: page object handling API calls and database
class LoginPage {
async createUser() {
/* API call */
}
async deleteUser() {
/* API call */
}
async signIn(email: string, password: string) {
/* UI */
}
}
Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only signIn in the page object.
// BAD: no methods, just locators
class LoginPage {
emailInput = this.page.getByLabel('Email')
passwordInput = this.page.getByLabel('Password')
submitBtn = this.page.getByRole('button', {name: 'Sign in'})
constructor(private page: Page) {}
}
Add intent-revealing methods or skip the page object entirely.
// BAD: one fixture doing everything
test.extend({
everything: async ({page, request}, use) => {
const user = await createUser(request)
const products = await seedProducts(request, 50)
await setupPayment(request, user.id)
await page.goto('/dashboard')
await use({user, products, page})
// massive teardown...
},
})
Break into small, composable fixtures. Each fixture does one thing.
// BAD: module-level state
let createdUserId: string
export async function createTestUser(request: APIRequestContext) {
const res = await request.post('/api/users', {data: {email: '[email protected]'}})
const user = await res.json()
createdUserId = user.id // shared across tests!
return user
}
Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.
// BAD: helper for one-liner
export async function clickButton(page: Page, name: string) {
await page.getByRole('button', {name}).click()
}
Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).