Page Objects vs Fixtures — Structuring Playwright Tests
Compare the classic Page Object Model with Playwright's fixture-based composition. Learn when each pattern wins, how to avoid god objects, and structure test code for maintainability at scale.
This post is part of the Playwright Essentials series. Part 2 covered locator strategy. This post explores how to structure test code so it stays maintainable as the suite grows.
The Problem — Repetition and Fragility
After writing a few tests, you’ll notice patterns repeating:
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('user can view profile after login', async ({ page }) => {
// Same login steps duplicated
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible();
});
This violates DRY (Don’t Repeat Yourself). When the login form changes, you update it in ten places. When a selector breaks, you fix it ten times.
Two patterns solve this: Page Object Model (POM) and Playwright Fixtures.
Page Object Model (POM) — The Classic Pattern
The Page Object Model encapsulates page-specific locators and actions in a class. Tests interact with the page object, not raw Playwright APIs.
Basic POM Example
// pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
// Locators
private emailInput = () => this.page.getByLabel('Email');
private passwordInput = () => this.page.getByLabel('Password');
private loginButton = () => this.page.getByRole('button', { name: 'Log in' });
private welcomeMessage = () => this.page.getByText('Welcome back');
// Actions
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput().fill(email);
await this.passwordInput().fill(password);
await this.loginButton().click();
}
async expectWelcomeMessage() {
await expect(this.welcomeMessage()).toBeVisible();
}
}
Using the Page Object
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectWelcomeMessage();
});
test('user can view profile after login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible();
});
Benefits of POM
- Encapsulation — All login-related selectors live in one place
- Reusability —
login()method used across multiple tests - Maintainability — Change the login form once, fix in one place
- Readability — Tests read like user actions, not Playwright API calls
The Problem with POM — God Objects
POM works well until it doesn’t. As applications grow, page objects grow. You end up with:
Anti-Pattern: The God Object
export class DashboardPage {
// 500 lines of methods
async clickProfile() {}
async clickSettings() {}
async clickLogout() {}
async filterByDate() {}
async exportToPDF() {}
async createNewOrder() {}
async deleteOrder() {}
async approveOrder() {}
async rejectOrder() {}
// ... 30 more methods
}
This is a maintenance nightmare:
- Fragile — one page object touches too many features, breaks often
- Hard to test — the page object itself becomes complex enough to need tests
- Poor abstraction — the page object mirrors the UI structure, not user goals
- Difficult to navigate — 500-line classes are hard to reason about
The solution: composition over inheritance. Break large page objects into smaller, focused components.
Playwright Fixtures — Composition-Based Testing
Playwright fixtures are Playwright’s answer to POM’s limitations. Instead of classes, you compose reusable test helpers using dependency injection.
Basic Fixture Example
// fixtures/auth.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Setup: Log in
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/dashboard');
// Provide the logged-in page to the test
await use(page);
// Teardown: (optional) log out
await page.getByRole('button', { name: 'Log out' }).click();
},
});
Using the Fixture
import { test } from './fixtures/auth';
import { expect } from '@playwright/test';
test('user can view profile', async ({ authenticatedPage }) => {
// Test starts already logged in
await authenticatedPage.getByRole('link', { name: 'Profile' }).click();
await expect(authenticatedPage.getByRole('heading', { name: 'User Profile' })).toBeVisible();
});
test('user can view orders', async ({ authenticatedPage }) => {
await authenticatedPage.getByRole('link', { name: 'Orders' }).click();
await expect(authenticatedPage.getByRole('heading', { name: 'My Orders' })).toBeVisible();
});
Every test using authenticatedPage starts with the user already logged in. No duplication. No manual setup in each test.
Fixtures vs POM — When to Use Each
| Concern | Page Object Model | Fixtures |
|---|---|---|
| Encapsulation of locators | ✅ Excellent | ⚠️ Requires discipline (can leak into tests) |
| Setup/teardown lifecycle | ❌ Manual (beforeEach/afterEach) | ✅ Built-in (setup before use(), teardown after) |
| Composability | ⚠️ Inheritance-based (rigid) | ✅ Composition-based (flexible) |
| Dependency injection | ❌ Manual (pass page object everywhere) | ✅ Automatic (fixture system handles it) |
| Testability | ⚠️ Page objects themselves need tests if complex | ✅ Fixtures are simple, rarely need tests |
| Learning curve | ✅ Familiar (classes, methods) | ⚠️ Steeper (fixture pattern is new to many) |
| Large codebases | ❌ Tends toward god objects | ✅ Scales well with composition |
When to Use POM
- Small to medium test suites — POM is simple and effective for < 50 tests
- Team unfamiliar with fixtures — POM is easier to onboard (just classes)
- Heavy locator encapsulation — If your main goal is hiding selectors, POM works
When to Use Fixtures
- Reusable setup/teardown — Fixtures excel at “start logged in,” “with test data,” “with admin permissions”
- Composition over inheritance — You need multiple, independent, combinable setup steps
- Large test suites — Fixtures scale better as complexity grows
- Advanced patterns — Parallel execution, worker-scoped state, custom test environments
:::tip[Hybrid Approach]
You don’t have to choose. Use POM for locator encapsulation and actions, and fixtures for setup/teardown. Example: a LoginPage class used inside an authenticatedPage fixture.
:::
Example — Combining POM and Fixtures
Here’s a hybrid pattern that uses the strengths of both:
Page Object (Encapsulates Locators and Actions)
// pages/OrderPage.ts
import { Page } from '@playwright/test';
export class OrderPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/orders');
}
async createOrder(productName: string, quantity: number) {
await this.page.getByRole('button', { name: 'New Order' }).click();
await this.page.getByLabel('Product').fill(productName);
await this.page.getByLabel('Quantity').fill(String(quantity));
await this.page.getByRole('button', { name: 'Submit' }).click();
}
async expectOrderInList(productName: string) {
await expect(
this.page.getByRole('row').filter({ hasText: productName })
).toBeVisible();
}
}
Fixture (Handles Setup and Provides Dependencies)
// fixtures/orders.ts
import { test as base } from '@playwright/test';
import { OrderPage } from '../pages/OrderPage';
type OrderFixtures = {
orderPage: OrderPage;
};
export const test = base.extend<OrderFixtures>({
orderPage: async ({ page }, use) => {
// Setup: Navigate to orders page
const orderPage = new OrderPage(page);
await orderPage.goto();
await use(orderPage);
// Teardown: (if needed)
},
});
Test (Clean and Readable)
import { test } from './fixtures/orders';
import { expect } from '@playwright/test';
test('user can create order', async ({ orderPage }) => {
await orderPage.createOrder('Laptop', 2);
await orderPage.expectOrderInList('Laptop');
});
This combines:
- POM — encapsulates locators and order-specific actions
- Fixtures — handles navigation and dependency injection
- Test — focuses purely on the scenario being tested
Anti-Pattern Checklist
Avoid these common mistakes when structuring test code:
❌ God Page Objects
// BAD: 1000-line page object doing everything
export class ApplicationPage {
async login() {}
async createOrder() {}
async deleteUser() {}
async exportReport() {}
// ...100 more methods
}
Fix: Break into smaller, focused page objects (LoginPage, OrderPage, UserManagementPage).
❌ Tests Depending on Execution Order
// BAD: Test B assumes Test A ran first
test('create user', async ({ page }) => {
// creates user ID 123
});
test('delete user 123', async ({ page }) => {
// expects user 123 to exist
});
Fix: Each test creates its own data via fixtures or API setup.
❌ Hardcoded Test Data in Page Objects
// BAD: Page object contains test data
export class LoginPage {
private readonly testEmail = 'test@example.com';
async loginAsTestUser() {
await this.login(this.testEmail, 'password');
}
}
Fix: Pass test data as arguments. Let the test control its data.
❌ Page Objects Doing Assertions
// BAD: Assertion logic inside page object
export class LoginPage {
async loginAndVerifySuccess(email: string, password: string) {
await this.login(email, password);
await expect(this.page.getByText('Welcome')).toBeVisible(); // ❌ Don't do this
}
}
Fix: Page objects perform actions, tests perform assertions. Keep them separate.
// GOOD: Page object does action, test does assertion
await loginPage.login(email, password);
await expect(page.getByText('Welcome')).toBeVisible(); // ✅ Assertion in test
Structuring a Large Test Suite
For a 200+ test suite, here’s a recommended folder structure:
tests/
├── fixtures/
│ ├── auth.ts # Authentication fixtures
│ ├── orders.ts # Order-related fixtures
│ └── api.ts # API client fixtures
├── pages/
│ ├── LoginPage.ts # POM for login
│ ├── OrderPage.ts # POM for orders
│ └── ProfilePage.ts # POM for profile
├── helpers/
│ ├── dataFactory.ts # Test data generation
│ └── assertions.ts # Custom assertion helpers
└── specs/
├── auth.spec.ts # Auth-related tests
├── orders.spec.ts # Order-related tests
└── profile.spec.ts # Profile-related tests
Why this works:
- Fixtures — Reusable setup/teardown logic
- Pages — Locator encapsulation
- Helpers — Utilities that don’t fit fixtures or page objects
- Specs — Tests grouped by feature area
Real-World Example — E-Commerce Test Suite
Let’s build a small e-commerce test using both patterns.
Page Object
// pages/CheckoutPage.ts
export class CheckoutPage {
constructor(private page: Page) {}
async fillShippingDetails(name: string, address: string) {
await this.page.getByLabel('Full name').fill(name);
await this.page.getByLabel('Address').fill(address);
}
async selectPaymentMethod(method: 'card' | 'paypal') {
await this.page.getByRole('radio', { name: method }).check();
}
async submitOrder() {
await this.page.getByRole('button', { name: 'Place Order' }).click();
}
}
Fixture
// fixtures/cart.ts
export const test = base.extend({
cartWithItems: async ({ page }, use) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).first().click();
await page.getByRole('link', { name: 'Cart' }).click();
await use(page);
},
});
Test
import { test } from './fixtures/cart';
import { CheckoutPage } from './pages/CheckoutPage';
import { expect } from '@playwright/test';
test('user can complete checkout', async ({ cartWithItems }) => {
await cartWithItems.getByRole('button', { name: 'Checkout' }).click();
const checkoutPage = new CheckoutPage(cartWithItems);
await checkoutPage.fillShippingDetails('John Doe', '123 Main St');
await checkoutPage.selectPaymentMethod('card');
await checkoutPage.submitOrder();
await expect(cartWithItems.getByText('Order confirmed')).toBeVisible();
});
Clean, readable, maintainable. The test says what it does, not how it does it.
Conclusion
Both POM and fixtures are valid patterns. The best choice depends on your team’s preferences, test suite size, and complexity.
General guidance:
- Start with POM if your team is new to Playwright (easier to learn)
- Adopt fixtures when setup/teardown logic starts duplicating across tests
- Use both for large suites (fixtures for lifecycle, POM for locators)
The goal is not to pick the “right” pattern — it’s to keep test code maintainable, readable, and resistant to change.
Action for this week: Identify the most duplicated setup in your test suite (login, navigation, data creation). Refactor it into either a page object method or a fixture. Measure before/after: how many lines of code did you eliminate?
Previous: Part 2 — Locators That Don’t Break
Next: Part 4 — Auto-Waiting, Assertions, and Killing Flakes