Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
architecture/pom-vs-fixtures.md
1# Organizing Reusable Test Code23## Table of Contents451. [Pattern Comparison](#pattern-comparison)62. [Selection Flowchart](#selection-flowchart)73. [Page Objects](#page-objects)84. [Custom Fixtures](#custom-fixtures)95. [Helper Functions](#helper-functions)106. [Combined Project Structure](#combined-project-structure)117. [Anti-Patterns](#anti-patterns)1213Use all three patterns together. Most projects benefit from a hybrid approach:1415- **Page objects** for UI interaction (pages/components with 5+ interactions)16- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)17- **Helper functions** for stateless utilities (generate data, format values, simple waits)1819If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.2021## Pattern Comparison2223| Aspect | Page Objects | Custom Fixtures | Helper Functions |24|---|---|---|---|25| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |26| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None |27| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |28| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |2930## Selection Flowchart3132```text33What kind of reusable code?34|35+-- Interacts with browser page/component?36| |37| +-- Has 5+ interactions (fill, click, navigate, assert)?38| | +-- YES: Used in 3+ test files?39| | | +-- YES --> PAGE OBJECT40| | | +-- NO --> Inline or small helper41| | +-- NO --> HELPER FUNCTION42| |43| +-- Needs setup before AND cleanup after test?44| +-- YES --> CUSTOM FIXTURE45| +-- NO --> PAGE OBJECT method or HELPER46|47+-- Manages resource with lifecycle (create/destroy)?48| +-- Examples: auth state, DB connection, API client, test user49| +-- YES --> CUSTOM FIXTURE (always)50|51+-- Stateless utility? (no browser, no side effects)52| +-- Examples: random email, format date, build URL, parse response53| +-- YES --> HELPER FUNCTION54|55+-- Not sure?56+-- Start with HELPER FUNCTION57+-- Promote to PAGE OBJECT when interactions grow58+-- Promote to FIXTURE when lifecycle needed59```6061## Page Objects6263Best for pages/components with 5+ interactions appearing in 3+ test files.6465```typescript66// page-objects/booking.page.ts67import { type Page, type Locator, expect } from '@playwright/test';6869export class BookingPage {70readonly page: Page;71readonly dateField: Locator;72readonly guestCount: Locator;73readonly roomType: Locator;74readonly reserveBtn: Locator;75readonly totalPrice: Locator;7677constructor(page: Page) {78this.page = page;79this.dateField = page.getByLabel('Check-in date');80this.guestCount = page.getByLabel('Guests');81this.roomType = page.getByLabel('Room type');82this.reserveBtn = page.getByRole('button', { name: 'Reserve' });83this.totalPrice = page.getByTestId('total-price');84}8586async goto() {87await this.page.goto('/booking');88}8990async fillDetails(opts: { date: string; guests: number; room: string }) {91await this.dateField.fill(opts.date);92await this.guestCount.fill(String(opts.guests));93await this.roomType.selectOption(opts.room);94}9596async reserve() {97await this.reserveBtn.click();98await this.page.waitForURL('**/confirmation');99}100101async expectPrice(amount: string) {102await expect(this.totalPrice).toHaveText(amount);103}104}105```106107```typescript108// tests/booking/reservation.spec.ts109import { test, expect } from '@playwright/test';110import { BookingPage } from '../page-objects/booking.page';111112test('complete reservation with standard room', async ({ page }) => {113const booking = new BookingPage(page);114await booking.goto();115await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' });116await booking.reserve();117await expect(page.getByText('Reservation confirmed')).toBeVisible();118});119```120121**Page object principles:**122- One class per logical page/component, not per URL123- Constructor takes `Page`124- Locators as `readonly` properties in constructor125- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks126- Navigation methods (`goto`) belong on the page object127128## Custom Fixtures129130Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.131132```typescript133// fixtures/base.fixture.ts134import { test as base, expect } from '@playwright/test';135import { BookingPage } from '../page-objects/booking.page';136import { generateMember } from '../helpers/data';137138type Fixtures = {139bookingPage: BookingPage;140member: { email: string; password: string; id: string };141loggedInPage: import('@playwright/test').Page;142};143144export const test = base.extend<Fixtures>({145bookingPage: async ({ page }, use) => {146await use(new BookingPage(page));147},148149member: async ({ request }, use) => {150const data = generateMember();151const res = await request.post('/api/test/members', { data });152const member = await res.json();153await use(member);154await request.delete(`/api/test/members/${member.id}`);155},156157loggedInPage: async ({ page, member }, use) => {158await page.goto('/login');159await page.getByLabel('Email').fill(member.email);160await page.getByLabel('Password').fill(member.password);161await page.getByRole('button', { name: 'Sign in' }).click();162await expect(page).toHaveURL('/dashboard');163await use(page);164},165});166167export { expect } from '@playwright/test';168```169170```typescript171// tests/dashboard/overview.spec.ts172import { test, expect } from '../../fixtures/base.fixture';173174test('member sees dashboard widgets', async ({ loggedInPage }) => {175await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();176await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible();177});178179test('new member sees welcome prompt', async ({ loggedInPage, member }) => {180await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible();181});182```183184**Fixture principles:**185- Use `test.extend()` — never module-level variables186- `use()` callback separates setup from teardown187- Teardown runs even if test fails188- Fixtures compose: one can depend on another189- Fixtures are lazy: created only when requested190- Wrap page objects in fixtures for lifecycle management191192## Helper Functions193194Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses.195196```typescript197// helpers/data.ts198import { randomUUID } from 'node:crypto';199200export function generateEmail(prefix = 'user'): string {201return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`;202}203204export function generateMember(overrides: Partial<Member> = {}): Member {205return {206email: generateEmail(),207password: 'SecurePass456!',208name: 'Test Member',209...overrides,210};211}212213interface Member {214email: string;215password: string;216name: string;217}218219export function formatPrice(cents: number): string {220return `$${(cents / 100).toFixed(2)}`;221}222```223224```typescript225// helpers/assertions.ts226import { type Page, expect } from '@playwright/test';227228export async function expectNotification(page: Page, message: string): Promise<void> {229const notification = page.getByRole('alert').filter({ hasText: message });230await expect(notification).toBeVisible();231await expect(notification).toBeHidden({ timeout: 10000 });232}233```234235```typescript236// tests/settings/account.spec.ts237import { test, expect } from '@playwright/test';238import { generateEmail } from '../../helpers/data';239import { expectNotification } from '../../helpers/assertions';240241test('update account email', async ({ page }) => {242const newEmail = generateEmail('updated');243await page.goto('/settings/account');244await page.getByLabel('Email').fill(newEmail);245await page.getByRole('button', { name: 'Save' }).click();246await expectNotification(page, 'Account updated');247await expect(page.getByLabel('Email')).toHaveValue(newEmail);248});249```250251**Helper principles:**252- Pure functions with no side effects253- No browser state — take `page` as parameter if needed254- Promote to fixture if setup/teardown needed255- Promote to page object if many page interactions grow256- Keep small and focused257258## Combined Project Structure259260```text261tests/262+-- fixtures/263| +-- auth.fixture.ts264| +-- db.fixture.ts265| +-- base.fixture.ts266+-- page-objects/267| +-- login.page.ts268| +-- booking.page.ts269| +-- components/270| +-- data-table.component.ts271+-- helpers/272| +-- data.ts273| +-- assertions.ts274+-- e2e/275| +-- auth/276| | +-- login.spec.ts277| +-- booking/278| +-- reservation.spec.ts279playwright.config.ts280```281282**Layer responsibilities:**283284| Layer | Pattern | Responsibility |285|---|---|---|286| **Test file** | `test()` | Describes behavior, orchestrates layers |287| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown |288| **Page objects** | Classes | UI interaction — navigation, actions, locators |289| **Helpers** | Functions | Utilities — data generation, formatting, assertions |290291## Anti-Patterns292293### Page object managing resources294295```typescript296// BAD: page object handling API calls and database297class LoginPage {298async createUser() { /* API call */ }299async deleteUser() { /* API call */ }300async signIn(email: string, password: string) { /* UI */ }301}302```303304Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.305306### Locator-only page objects307308```typescript309// BAD: no methods, just locators310class LoginPage {311emailInput = this.page.getByLabel('Email');312passwordInput = this.page.getByLabel('Password');313submitBtn = this.page.getByRole('button', { name: 'Sign in' });314constructor(private page: Page) {}315}316```317318Add intent-revealing methods or skip the page object entirely.319320### Monolithic fixtures321322```typescript323// BAD: one fixture doing everything324test.extend({325everything: async ({ page, request }, use) => {326const user = await createUser(request);327const products = await seedProducts(request, 50);328await setupPayment(request, user.id);329await page.goto('/dashboard');330await use({ user, products, page });331// massive teardown...332},333});334```335336Break into small, composable fixtures. Each fixture does one thing.337338### Helpers with side effects339340```typescript341// BAD: module-level state342let createdUserId: string;343344export async function createTestUser(request: APIRequestContext) {345const res = await request.post('/api/users', { data: { email: '[email protected]' } });346const user = await res.json();347createdUserId = user.id; // shared across tests!348return user;349}350```351352Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.353354### Over-abstracting simple operations355356```typescript357// BAD: helper for one-liner358export async function clickButton(page: Page, name: string) {359await page.getByRole('button', { name }).click();360}361```362363Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).364