
Playwright Pom
- 219 installs
- 343 repo stars
- Updated July 2, 2026
- testdino-hq/playwright-skill
Use playwright-pom for development tasks
About
playwright-pom: A skill for development. This provides functionality for development workflows.
- playwright-pom
Playwright Pom by the numbers
- 219 all-time installs (skills.sh)
- +11 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,785 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/testdino-hq/playwright-skill --skill playwright-pomAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 219 |
|---|---|
| repo stars | ★ 343 |
| Last updated | July 2, 2026 |
| Repository | testdino-hq/playwright-skill ↗ |
What it does
Use playwright-pom for development tasks
Files
Playwright Page Object Model
Structure your test code for maintainability — know when POM helps and when simpler patterns win.
2 guides covering Page Object Model implementation and the decision framework for choosing between POM, fixtures, and helpers.
Guide Index
| Topic | Guide |
|---|---|
| Page Object Model patterns | page-object-model.md |
| POM vs fixtures vs helpers | pom-vs-fixtures-vs-helpers.md |
Page Object Model
When to use: Any page or component is touched by more than one test file, or a single test file interacts with more than two distinct UI regions.
Page objects encapsulate actions, not locators. A test should read like a user story: await loginPage.login('admin', 'secret'), never await loginPage.usernameInput.fill('admin'). The POM is a boundary between "what the user does" and "how the UI is structured." Assertions stay in tests, never inside page objects.
Quick Reference
| Concept | Rule |
|---|---|
| Locators | Defined as readonly properties in the constructor or as getters. Never exposed raw to tests. |
| Actions | Public methods that perform a user-visible behavior. Return Promise<void> or the next page object. |
| Assertions | Never inside page objects. Tests own all expect() calls. |
| Navigation | Methods that navigate return the destination page object, not void. |
| State | Page objects are stateless. No caching locator text, no tracking "current step." |
| Constructor | Takes Page (or Locator for components). Nothing else. No URLs, no test data. |
| Naming | LoginPage, DashboardPage, NavbarComponent. File: login.page.ts, navbar.component.ts. |
Patterns
1. Basic POM Class
Use when: A page has 3+ interactions across multiple tests. Avoid when: A page is used in a single test file with trivial interactions -- use a helper function instead.
TypeScript
// tests/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}JavaScript
// tests/pages/login.page.js
// @ts-check
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(username, password) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Test usage
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin', 'wrong');
await expect(loginPage.errorMessage).toBeVisible();
await expect(loginPage.errorMessage).toHaveText('Invalid credentials');
});2. Component Objects
Use when: A UI element (navbar, sidebar, modal, form, table) appears on multiple pages. Avoid when: The component is page-specific and never reused.
Components take a Locator (their root container), not a Page. This scopes all queries to the component's DOM subtree and allows composing components into page objects.
TypeScript
// tests/components/navbar.component.ts
import { type Locator } from '@playwright/test';
export class NavbarComponent {
readonly profileMenu: Locator;
readonly searchInput: Locator;
readonly notificationBell: Locator;
constructor(private readonly root: Locator) {
this.profileMenu = root.getByRole('button', { name: 'Profile' });
this.searchInput = root.getByRole('searchbox');
this.notificationBell = root.getByRole('button', { name: 'Notifications' });
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async openProfile() {
await this.profileMenu.click();
}
async openNotifications() {
await this.notificationBell.click();
}
}
// tests/components/modal.component.ts
import { type Locator } from '@playwright/test';
export class ModalComponent {
readonly title: Locator;
readonly closeButton: Locator;
readonly confirmButton: Locator;
readonly cancelButton: Locator;
constructor(private readonly root: Locator) {
this.title = root.getByRole('heading');
this.closeButton = root.getByRole('button', { name: 'Close' });
this.confirmButton = root.getByRole('button', { name: 'Confirm' });
this.cancelButton = root.getByRole('button', { name: 'Cancel' });
}
async confirm() {
await this.confirmButton.click();
}
async cancel() {
await this.cancelButton.click();
}
async close() {
await this.closeButton.click();
}
}
// tests/pages/dashboard.page.ts
import { type Page } from '@playwright/test';
import { NavbarComponent } from '../components/navbar.component';
import { ModalComponent } from '../components/modal.component';
export class DashboardPage {
readonly navbar: NavbarComponent;
readonly deleteModal: ModalComponent;
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole('navigation'));
this.deleteModal = new ModalComponent(page.getByRole('dialog'));
}
async goto() {
await this.page.goto('/dashboard');
}
async deleteItem(name: string) {
await this.page.getByRole('row', { name }).getByRole('button', { name: 'Delete' }).click();
await this.deleteModal.confirm();
}
}JavaScript
// tests/components/navbar.component.js
// @ts-check
export class NavbarComponent {
/** @param {import('@playwright/test').Locator} root */
constructor(root) {
this.root = root;
this.profileMenu = root.getByRole('button', { name: 'Profile' });
this.searchInput = root.getByRole('searchbox');
this.notificationBell = root.getByRole('button', { name: 'Notifications' });
}
async search(query) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}
async openProfile() {
await this.profileMenu.click();
}
async openNotifications() {
await this.notificationBell.click();
}
}
// tests/pages/dashboard.page.js
// @ts-check
import { NavbarComponent } from '../components/navbar.component.js';
import { ModalComponent } from '../components/modal.component.js';
export class DashboardPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page;
this.navbar = new NavbarComponent(page.getByRole('navigation'));
this.deleteModal = new ModalComponent(page.getByRole('dialog'));
}
async goto() {
await this.page.goto('/dashboard');
}
async deleteItem(name) {
await this.page.getByRole('row', { name }).getByRole('button', { name: 'Delete' }).click();
await this.deleteModal.confirm();
}
}3. Page Object with Navigation
Use when: An action on one page navigates to a different page (form submission, link click, redirect). Avoid when: Navigation is uncertain (might fail validation and stay on the same page).
When a method causes navigation, return the destination page object. This creates a typed chain that mirrors the user flow and catches stale page object usage at the call site.
TypeScript
// tests/pages/login.page.ts
import { type Page } from '@playwright/test';
import { DashboardPage } from './dashboard.page';
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('/login');
}
/** Returns DashboardPage on success. Call only when credentials are valid. */
async loginAs(username: string, password: string): Promise<DashboardPage> {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
await this.page.waitForURL('/dashboard');
return new DashboardPage(this.page);
}
/** Use for invalid credential tests -- stays on login page. */
async loginExpectingError(username: string, password: string) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}
// tests/pages/dashboard.page.ts
import { type Page } from '@playwright/test';
import { SettingsPage } from './settings.page';
export class DashboardPage {
constructor(private readonly page: Page) {}
async gotoSettings(): Promise<SettingsPage> {
await this.page.getByRole('link', { name: 'Settings' }).click();
await this.page.waitForURL('/settings');
return new SettingsPage(this.page);
}
}JavaScript
// tests/pages/login.page.js
// @ts-check
import { DashboardPage } from './dashboard.page.js';
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page;
}
async goto() {
await this.page.goto('/login');
}
/** @returns {Promise<DashboardPage>} */
async loginAs(username, password) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
await this.page.waitForURL('/dashboard');
return new DashboardPage(this.page);
}
async loginExpectingError(username, password) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}Test usage showing the navigation chain
// tests/settings.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';
test('user can navigate from login to settings', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
const dashboard = await loginPage.loginAs('admin', 'password123');
const settings = await dashboard.gotoSettings();
await expect(page).toHaveURL('/settings');
});4. Page Object as Fixture
Use when: Page objects are used in 3+ test files. This is the recommended approach for mature test suites. Avoid when: Early prototyping with rapidly changing pages.
Fixtures eliminate boilerplate instantiation, provide automatic cleanup, and make POMs available via destructuring -- identical to built-in page and request fixtures.
TypeScript
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login.page';
import { DashboardPage } from './pages/dashboard.page';
import { SettingsPage } from './pages/settings.page';
type PageObjects = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
settingsPage: SettingsPage;
};
export const test = base.extend<PageObjects>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';JavaScript
// tests/fixtures.js
// @ts-check
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login.page.js';
import { DashboardPage } from './pages/dashboard.page.js';
import { SettingsPage } from './pages/settings.page.js';
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page));
},
});
export { expect } from '@playwright/test';Test usage
// tests/auth.spec.ts
import { test, expect } from './fixtures';
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('admin', 'password123');
await expect(page).toHaveURL('/dashboard');
});Advanced: fixture that performs setup
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from './pages/dashboard.page';
export const test = base.extend<{ authenticatedDashboard: DashboardPage }>({
authenticatedDashboard: async ({ page }, use) => {
// Setup: navigate and authenticate
await page.goto('/login');
await page.getByLabel('Username').fill('admin');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(new DashboardPage(page));
// Teardown (optional): logout, clean up test data
await page.goto('/logout');
},
});
export { expect } from '@playwright/test';5. Factory Functions
Use when: A page has few actions and no shared state. Simpler than a class, still encapsulates actions. Avoid when: The page has 5+ methods or needs component composition.
Factory functions are a lightweight alternative for simple pages. They return an object with action methods, avoiding class ceremony.
TypeScript
// tests/pages/error.page.ts
import { type Page } from '@playwright/test';
export function createErrorPage(page: Page) {
return {
errorHeading: page.getByRole('heading', { name: 'Something went wrong' }),
retryButton: page.getByRole('button', { name: 'Retry' }),
async goto() {
await page.goto('/error');
},
async retry() {
await page.getByRole('button', { name: 'Retry' }).click();
},
async goHome() {
await page.getByRole('link', { name: 'Go home' }).click();
await page.waitForURL('/');
},
};
}JavaScript
// tests/pages/error.page.js
// @ts-check
/** @param {import('@playwright/test').Page} page */
export function createErrorPage(page) {
return {
errorHeading: page.getByRole('heading', { name: 'Something went wrong' }),
retryButton: page.getByRole('button', { name: 'Retry' }),
async goto() {
await page.goto('/error');
},
async retry() {
await page.getByRole('button', { name: 'Retry' }).click();
},
async goHome() {
await page.getByRole('link', { name: 'Go home' }).click();
await page.waitForURL('/');
},
};
}Test usage
// tests/error.spec.ts
import { test, expect } from '@playwright/test';
import { createErrorPage } from './pages/error.page';
test('retry button reloads the page', async ({ page }) => {
const errorPage = createErrorPage(page);
await errorPage.goto();
await errorPage.retry();
await expect(page).not.toHaveURL('/error');
});6. Getter Pattern for Dynamic Locators
Use when: Locators depend on dynamic content (parameterized rows, nth items, content that changes after page load). Avoid when: All locators are static and known at construction time -- use constructor assignment instead.
Getters create the locator fresh on each access. This avoids stale references when the DOM changes after the page object is constructed.
TypeScript
// tests/pages/users.page.ts
import { type Page, type Locator } from '@playwright/test';
export class UsersPage {
constructor(private readonly page: Page) {}
/** Static locators -- fine in constructor or as getters, either works. */
get addUserButton(): Locator {
return this.page.getByRole('button', { name: 'Add user' });
}
get userRows(): Locator {
return this.page.getByRole('row').filter({ hasNot: this.page.getByRole('columnheader') });
}
/** Dynamic locator -- must be a method since it takes a parameter. */
userRow(name: string): Locator {
return this.page.getByRole('row', { name });
}
userDeleteButton(name: string): Locator {
return this.userRow(name).getByRole('button', { name: 'Delete' });
}
async goto() {
await this.page.goto('/admin/users');
}
async deleteUser(name: string) {
await this.userDeleteButton(name).click();
// Wait for row to disappear, confirming deletion
await this.userRow(name).waitFor({ state: 'hidden' });
}
async addUser() {
await this.addUserButton.click();
}
}JavaScript
// tests/pages/users.page.js
// @ts-check
export class UsersPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page;
}
get addUserButton() {
return this.page.getByRole('button', { name: 'Add user' });
}
get userRows() {
return this.page.getByRole('row').filter({ hasNot: this.page.getByRole('columnheader') });
}
userRow(name) {
return this.page.getByRole('row', { name });
}
userDeleteButton(name) {
return this.userRow(name).getByRole('button', { name: 'Delete' });
}
async goto() {
await this.page.goto('/admin/users');
}
async deleteUser(name) {
await this.userDeleteButton(name).click();
await this.userRow(name).waitFor({ state: 'hidden' });
}
async addUser() {
await this.addUserButton.click();
}
}7. Directory Structure
Use this layout. Adapt folder names to your convention, but keep the separation between pages, components, and fixtures.
tests/
fixtures.ts # Custom test with POM fixtures
auth.spec.ts # Test files at top level or in feature folders
dashboard.spec.ts
pages/ # One file per page
login.page.ts
dashboard.page.ts
settings.page.ts
users.page.ts
components/ # Reusable UI components
navbar.component.ts
modal.component.ts
sidebar.component.ts
data-table.component.tsRules:
- One class per file. File name matches the class:
LoginPagelives inlogin.page.ts. - Pages vs components: Pages own a full route (
/login,/dashboard). Components are embedded in pages (NavbarComponent,ModalComponent). - No barrel files (
index.tsre-exporting everything). Import directly from the file. Barrel files create circular dependency traps and slow down IDE tooling. - `fixtures.ts` at `tests/` root. Every spec imports
testandexpectfrom./fixturesinstead of@playwright/test.
8. Handling Async Initialization
Use when: A page object needs async setup before it is usable (waiting for API data to load, waiting for animations to settle, waiting for a specific element to appear). Avoid when: The page is ready immediately after goto().
Never put await in a constructor. Use a static factory method instead.
TypeScript
// tests/pages/analytics.page.ts
import { type Page, type Locator } from '@playwright/test';
export class AnalyticsPage {
readonly chart: Locator;
readonly dateRange: Locator;
private constructor(private readonly page: Page) {
this.chart = page.locator('[data-testid="analytics-chart"]');
this.dateRange = page.getByLabel('Date range');
}
/** Use this instead of `new AnalyticsPage()`. Waits for chart data to load. */
static async create(page: Page): Promise<AnalyticsPage> {
const analyticsPage = new AnalyticsPage(page);
await page.goto('/analytics');
// Wait for the async content that makes this page "ready"
await analyticsPage.chart.waitFor({ state: 'visible' });
return analyticsPage;
}
async selectDateRange(range: string) {
await this.dateRange.click();
await this.page.getByRole('option', { name: range }).click();
// Wait for chart to update after date change
await this.chart.waitFor({ state: 'visible' });
}
}JavaScript
// tests/pages/analytics.page.js
// @ts-check
export class AnalyticsPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page;
this.chart = page.locator('[data-testid="analytics-chart"]');
this.dateRange = page.getByLabel('Date range');
}
/** @param {import('@playwright/test').Page} page */
static async create(page) {
const analyticsPage = new AnalyticsPage(page);
await page.goto('/analytics');
await analyticsPage.chart.waitFor({ state: 'visible' });
return analyticsPage;
}
async selectDateRange(range) {
await this.dateRange.click();
await this.page.getByRole('option', { name: range }).click();
await this.chart.waitFor({ state: 'visible' });
}
}As a fixture
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { AnalyticsPage } from './pages/analytics.page';
export const test = base.extend<{ analyticsPage: AnalyticsPage }>({
analyticsPage: async ({ page }, use) => {
const analyticsPage = await AnalyticsPage.create(page);
await use(analyticsPage);
},
});
export { expect } from '@playwright/test';Decision Guide
How complex is the page?
│
├── 1-2 interactions, single test file
│ └── No POM. Inline locators in the test are fine.
│
├── 3-5 interactions OR 2+ test files use it
│ ├── Few methods, no composition needed
│ │ └── Factory function (Pattern 5)
│ └── Multiple methods, needs component composition
│ └── Full POM class (Pattern 1 + 2)
│
├── Used across 3+ test files
│ └── POM class + fixture injection (Pattern 4)
│
└── Page requires async setup before usable
└── Static factory method (Pattern 8) + fixture| Factor | Simple helper | Factory function | POM class | POM + fixture |
|---|---|---|---|---|
| Page complexity | 1-2 actions | 3-5 actions | 5+ actions | 5+ actions |
| Reuse across files | 1 file | 1-2 files | 2+ files | 3+ files |
| Component composition | No | No | Yes | Yes |
| Team size | Solo | Small | Any | Any |
| Setup ceremony | None | Low | Medium | Medium (once) |
| Recommended for | Quick prototypes | Simple pages | Standard choice | Mature suites |
Anti-Patterns
God Object
One page object for the entire application or a page object with 30+ methods covering unrelated features.
// BAD: one class for everything
class AppPage {
async login() { /* ... */ }
async addToCart() { /* ... */ }
async checkout() { /* ... */ }
async changePassword() { /* ... */ }
async viewAnalytics() { /* ... */ }
// 40 more methods...
}Fix: Split by page or feature. One class per logical page. Compose shared UI into component objects.
Assertions Inside Page Objects
// BAD: page object owns the assertion
class LoginPage {
async loginAndVerify(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
// This assertion belongs in the test, not here
await expect(this.page).toHaveURL('/dashboard');
}
}
// GOOD: page object performs action, test asserts
class LoginPage {
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// In test:
await loginPage.login('admin', 'pass');
await expect(page).toHaveURL('/dashboard');Exception: waitForURL() or waitFor() calls inside POM methods that ensure navigation completed are fine -- those are synchronization, not assertion.
Deeply Nested Inheritance
// BAD: inheritance chain
class BasePage { /* ... */ }
class AuthenticatedPage extends BasePage { /* ... */ }
class AdminPage extends AuthenticatedPage { /* ... */ }
class SuperAdminPage extends AdminPage { /* ... */ }Fix: Use composition. Create component objects for shared UI (navbar, sidebar) and compose them into page objects. Prefer has-a over is-a.
// GOOD: composition
class AdminPage {
readonly navbar: NavbarComponent;
readonly sidebar: AdminSidebarComponent;
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole('navigation'));
this.sidebar = new AdminSidebarComponent(page.getByRole('complementary'));
}
}Storing State in Page Objects
// BAD: tracking state
class CartPage {
private itemCount = 0;
async addItem(name: string) {
await this.page.getByRole('button', { name: `Add ${name}` }).click();
this.itemCount++; // State goes stale if the UI changes independently
}
getCount() {
return this.itemCount; // Lies if another tab or API call modified the cart
}
}Fix: Read state from the DOM when needed. The page is the source of truth, not your JavaScript variable.
// GOOD: read from DOM
class CartPage {
async addItem(name: string) {
await this.page.getByRole('button', { name: `Add ${name}` }).click();
}
get itemCount(): Locator {
return this.page.getByTestId('cart-count');
}
}
// In test:
await cartPage.addItem('Widget');
await expect(cartPage.itemCount).toHaveText('1');Exposing Raw Locators as the Primary API
// BAD: test manipulates locators directly
const loginPage = new LoginPage(page);
await loginPage.usernameInput.fill('admin');
await loginPage.passwordInput.fill('secret');
await loginPage.submitButton.click();
// GOOD: test calls an action
const loginPage = new LoginPage(page);
await loginPage.login('admin', 'secret');Exposing locators as readonly properties for assertions is acceptable (expect(loginPage.errorMessage).toBeVisible()). The anti-pattern is requiring tests to orchestrate multi-step interactions through raw locators.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Locator times out but element exists | POM constructed before navigation; locator attached to wrong page state | Move goto() before POM construction, or use getter pattern (Pattern 6) |
| Circular import between page objects | LoginPage imports DashboardPage which imports LoginPage | Break the cycle: use lazy import() in the navigation method, or have the test create the destination POM |
| Fixture not available in test | Test imports test from @playwright/test instead of ./fixtures | Always import test and expect from your custom fixtures file |
| TypeScript error: property does not exist on fixture | Fixture type not declared in test.extend<>() generic | Add the POM type to the generic parameter: test.extend<{ loginPage: LoginPage }>() |
| POM methods feel duplicated across similar pages | Multiple pages share the same form structure | Extract a component object for the shared form, compose into each page |
page.goto() in constructor causes "cannot use await" | Constructors cannot be async | Use static factory method (Pattern 8) |
Related
- core/fixtures-and-hooks.md -- how
test.extend()works in depth, fixture scoping, teardown - core/locators.md -- choosing the right locator strategy for POM properties
- core/pom-vs-fixtures-vs-helpers.md -- expanded decision framework for when to use each approach
- core/test-organization.md -- file and folder conventions for the broader test suite
Page Objects vs Fixtures vs Helpers
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 neededWhen 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.tsHow 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