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

AspectPage ObjectsCustom FixturesHelper Functions
Primary purposeEncapsulate UI interactions for a page or componentProvide shared resources with setup/teardown lifecycleStateless utility operations
Setup/teardownManual (constructor / methods)Built-in (use() callback with automatic teardown)None (stateless)
Type safetyFull class-based typingFull typing via test.extend<T>() genericsFull typing via function signatures
ComposabilityCompose via constructor injection or fixture wiringCompose by depending on other fixturesCompose by calling other functions
Learning curveLow (plain classes)Medium (Playwright-specific test.extend API)Lowest (plain functions)
Test readabilityHigh -- loginPage.signIn(user) reads like intentHigh -- { adminPage } declares dependenciesMedium -- utility calls mixed with test logic
Debugging experienceGood -- step into class methodsBest -- fixtures appear in traces and reportsGood -- step into functions
IDE supportExcellent -- autocomplete on class methodsExcellent -- autocomplete on fixture parametersExcellent -- autocomplete on function params
State managementHolds page reference and locators as instance stateManages resource lifecycle (create, provide, cleanup)No state (pure functions)
Best forPages with many interactions reused across filesResources that need setup AND teardownSimple, 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 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

// 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 (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

// 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 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

// 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:

LayerPatternResponsibility
Test filetest()Describes behavior, orchestrates the three layers
Fixturestest.extend()Resource lifecycle -- setup, provide, teardown
Page objectsClassesUI interaction -- navigation, actions, locators
HelpersFunctionsUtilities -- 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.