Page Objects vs Fixtures vs Helpers
February 13, 2026 · View on GitHub
When to use: When deciding how to organize reusable test code in a Playwright project. Prerequisites: core/page-object-model.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
// 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);
}
}
// 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
// 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 };
// 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
readonlyproperties 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
// 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';
// 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
// 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 };
// 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 (
authenticatedPagedepends ontestUser) - 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
// 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();
}
// 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 });
}
// 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
// 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 };
// 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 };
// 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
pageshould 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
// 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';
// 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
// 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 };
// 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
// 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
// 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.
// 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
// 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.
// 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
// 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
// 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 -- detailed page object patterns and examples
- core/fixtures-and-hooks.md -- complete guide to Playwright fixtures and lifecycle hooks
- core/test-organization.md -- file structure and naming conventions
- core/test-architecture.md -- when to write E2E vs API vs component tests