Page Object Model (POM)

The industry-standard design pattern for organizing test automation code. Learn how to build maintainable, scalable, and reusable test suites.

What is the Page Object Model?

The Page Object Model (POM) is a design pattern used in test automation that creates an object-oriented representation of your web application's pages. Each page (or significant component) of your application gets its own class that contains the locators and methods for interacting with that page.

Instead of writing selectors and interactions directly in your test files, you encapsulate them in dedicated Page Object classes. Your tests then call clean, readable methods like loginPage.login(username, password) instead of dealing with raw selectors and clicks.

🛠
Maintainability
When the UI changes, you only update the Page Object — not every test that uses it.
â™»
Reusability
Write interactions once, use them across dozens of tests. DRY principle applied to testing.
📖
Readability
Tests read like business scenarios, not technical implementation details.
Test Files (.spec.ts)
↓
Page Objects
↓
Web Application
Tests interact with Page Objects, which interact with the browser.

Problems Without POM

Before learning POM, let's see what happens when you write tests without any design pattern. Here's a typical example of a test that logs in and checks the dashboard:

bad-example.spec.tstypescript
1import { test, expect } from '@playwright/test';
2
3// Test 1: Check dashboard after login
4test('should show dashboard after login', async ({ page }) => {
5  await page.goto('https://myapp.com/login');
6  await page.locator('#username').fill('admin');
7  await page.locator('#password').fill('secret123');
8  await page.locator('button[type="submit"]').click();
9  await expect(page.locator('.dashboard-title')).toHaveText('Welcome');
10});
11
12// Test 2: Check profile page after login
13test('should show profile page', async ({ page }) => {
14  // Duplicated login code!
15  await page.goto('https://myapp.com/login');
16  await page.locator('#username').fill('admin');
17  await page.locator('#password').fill('secret123');
18  await page.locator('button[type="submit"]').click();
19
20  await page.locator('.nav-profile').click();
21  await expect(page.locator('.profile-name')).toBeVisible();
22});
23
24// Test 3: Check settings after login
25test('should access settings', async ({ page }) => {
26  // Same login code AGAIN!
27  await page.goto('https://myapp.com/login');
28  await page.locator('#username').fill('admin');
29  await page.locator('#password').fill('secret123');
30  await page.locator('button[type="submit"]').click();
31
32  await page.locator('.nav-settings').click();
33  await expect(page.locator('.settings-panel')).toBeVisible();
34});
What's wrong here?
  • Code Duplication: The login logic is repeated in every test. If the login form changes (e.g., the selector changes from #username to #email), you must update every single test.
  • Hard to Maintain: With 50+ tests, changing one selector means modifying dozens of files.
  • Poor Readability: Tests are full of raw selectors and implementation details instead of business logic.
  • No Reusability: You can't share interactions between test files.

POM Structure

A well-organized POM project follows a clear folder structure. Here's the recommended layout:

project-root/
├── tests/
│ ├── pages/
│ │ ├── BasePage.ts   # Abstract base class
│ │ ├── LoginPage.ts   # Login page object
│ │ ├── DashboardPage.ts   # Dashboard page object
│ │ └── ProfilePage.ts   # Profile page object
│ ├── login.spec.ts   # Login tests
│ ├── dashboard.spec.ts   # Dashboard tests
│ └── profile.spec.ts   # Profile tests
├── playwright.config.ts
├── tsconfig.json
└── package.json

The Core Principles

  • Locators are defined as class properties or getters — never hardcoded in tests
  • Actions are encapsulated as public methods (e.g., login(), search())
  • Helper logic stays as private methods inside the Page Object
  • Tests only call Page Object methods — they never touch selectors directly

Creating a Page Object

Let's build a proper Page Object step by step. We'll create a LoginPage that encapsulates all the login functionality:

tests/pages/LoginPage.tstypescript
1import { Page, Locator } from '@playwright/test';
2
3export class LoginPage {
4  // The page instance from Playwright
5  private readonly page: Page;
6
7  // Locators defined as class properties
8  private readonly usernameInput: Locator;
9  private readonly passwordInput: Locator;
10  private readonly submitButton: Locator;
11  private readonly errorMessage: Locator;
12  private readonly rememberMeCheckbox: Locator;
13
14  constructor(page: Page) {
15    this.page = page;
16
17    // Initialize all locators in the constructor
18    this.usernameInput = page.getByLabel('Username');
19    this.passwordInput = page.getByLabel('Password');
20    this.submitButton = page.getByRole('button', { name: 'Sign In' });
21    this.errorMessage = page.locator('.error-message');
22    this.rememberMeCheckbox = page.getByLabel('Remember me');
23  }
24
25  // Public action methods
26  async goto(): Promise<void> {
27    await this.page.goto('/login');
28  }
29
30  async login(username: string, password: string): Promise<void> {
31    await this.usernameInput.fill(username);
32    await this.passwordInput.fill(password);
33    await this.submitButton.click();
34  }
35
36  async loginWithRememberMe(username: string, password: string): Promise<void> {
37    await this.usernameInput.fill(username);
38    await this.passwordInput.fill(password);
39    await this.rememberMeCheckbox.check();
40    await this.submitButton.click();
41  }
42
43  // Methods that return data
44  async getErrorMessage(): Promise<string> {
45    return await this.errorMessage.textContent() || '';
46  }
47
48  async isErrorVisible(): Promise<boolean> {
49    return await this.errorMessage.isVisible();
50  }
51}

Now look how clean the test becomes:

tests/login.spec.tstypescript
1import { test, expect } from '@playwright/test';
2import { LoginPage } from './pages/LoginPage';
3
4test('successful login redirects to dashboard', async ({ page }) => {
5  const loginPage = new LoginPage(page);
6
7  await loginPage.goto();
8  await loginPage.login('admin', 'password123');
9
10  await expect(page).toHaveURL('/dashboard');
11});
12
13test('invalid credentials show error', async ({ page }) => {
14  const loginPage = new LoginPage(page);
15
16  await loginPage.goto();
17  await loginPage.login('wrong', 'credentials');
18
19  const error = await loginPage.getErrorMessage();
20  expect(error).toContain('Invalid credentials');
21});
22
23test('remember me keeps user logged in', async ({ page }) => {
24  const loginPage = new LoginPage(page);
25
26  await loginPage.goto();
27  await loginPage.loginWithRememberMe('admin', 'password123');
28
29  await expect(page).toHaveURL('/dashboard');
30});
Key TakeawayNotice how the tests read like plain English: "go to login page, login with these credentials, expect to be on dashboard." No selectors, no implementation details — just clean business logic.

Best Practices

✔

One Page Object = One Page or Component

Each class should represent a single page or a significant UI component. Don't create a "GodObject" that handles everything.

✔

No Assertions in Page Objects

Page Objects should only contain locators and actions. All assertions (expect()) belong in test files. This keeps Page Objects reusable across different test scenarios.

✔

Use Descriptive Method Names

Methods should describe the business action: addToCart(), searchForProduct(), not clickButton() or fillInput().

✔

Return Promises from All Async Methods

Always type your return values. Use Promise<void> for actions and Promise<string>, Promise<boolean>, etc. for data retrieval methods.

✘

Don't Expose Raw Locators

Avoid making locators public. Instead, provide methods that use the locators internally. If a test needs to check visibility, provide a method like isErrorVisible().

✘

Don't Use Hardcoded Waits

Avoid page.waitForTimeout() in production code. Use Playwright's built-in auto-waiting or explicit wait conditions like waitForLoadState().

Base Page Pattern

In enterprise projects, you'll notice many pages share common functionality: navigation, waiting for page load, handling cookies, etc. The Base Page pattern extracts this shared logic into an abstract parent class.

tests/pages/BasePage.tstypescript
1import { Page, Locator } from '@playwright/test';
2
3export abstract class BasePage {
4  protected readonly page: Page;
5
6  // Common locators shared across all pages
7  protected readonly header: Locator;
8  protected readonly footer: Locator;
9  protected readonly loadingSpinner: Locator;
10
11  constructor(page: Page) {
12    this.page = page;
13    this.header = page.locator('header');
14    this.footer = page.locator('footer');
15    this.loadingSpinner = page.locator('.loading-spinner');
16  }
17
18  // Abstract method - each child MUST define its own URL
19  abstract getUrl(): string;
20
21  // Shared navigation
22  async navigate(): Promise<void> {
23    await this.page.goto(this.getUrl());
24    await this.waitForPageLoad();
25  }
26
27  // Wait for the page to be fully loaded
28  async waitForPageLoad(): Promise<void> {
29    await this.page.waitForLoadState('domcontentloaded');
30    // Wait for loading spinner to disappear
31    await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 10000 })
32      .catch(() => {}); // Ignore if spinner doesn't exist
33  }
34
35  // Common: get page title
36  async getPageTitle(): Promise<string> {
37    return await this.page.title();
38  }
39
40  // Common: get current URL
41  getCurrentUrl(): string {
42    return this.page.url();
43  }
44
45  // Common: take screenshot
46  async takeScreenshot(name: string): Promise<void> {
47    await this.page.screenshot({ path: `screenshots/${name}.png` });
48  }
49}

Now every page object extends BasePage and inherits all shared functionality:

tests/pages/DashboardPage.tstypescript
1import { Page, Locator } from '@playwright/test';
2import { BasePage } from './BasePage';
3
4export class DashboardPage extends BasePage {
5  private readonly welcomeMessage: Locator;
6  private readonly statsCards: Locator;
7  private readonly recentActivity: Locator;
8
9  constructor(page: Page) {
10    super(page);  // Call parent constructor
11    this.welcomeMessage = page.locator('.welcome-msg');
12    this.statsCards = page.locator('.stats-card');
13    this.recentActivity = page.locator('.recent-activity');
14  }
15
16  // Implement the abstract method
17  getUrl(): string {
18    return '/dashboard';
19  }
20
21  async getWelcomeText(): Promise<string> {
22    return await this.welcomeMessage.textContent() || '';
23  }
24
25  async getStatsCount(): Promise<number> {
26    return await this.statsCards.count();
27  }
28
29  async isRecentActivityVisible(): Promise<boolean> {
30    return await this.recentActivity.isVisible();
31  }
32}
tests/dashboard.spec.tstypescript
1import { test, expect } from '@playwright/test';
2import { DashboardPage } from './pages/DashboardPage';
3
4test('dashboard loads correctly', async ({ page }) => {
5  const dashboard = new DashboardPage(page);
6
7  // navigate() and waitForPageLoad() come from BasePage!
8  await dashboard.navigate();
9
10  const welcome = await dashboard.getWelcomeText();
11  expect(welcome).toContain('Welcome');
12
13  const statsCount = await dashboard.getStatsCount();
14  expect(statsCount).toBeGreaterThan(0);
15});
Inheritance ChainDashboardPage → extends BasePage → gives younavigate(), waitForPageLoad(), getPageTitle(),takeScreenshot() — all for free, without writing any extra code.

Real-World Example: FutbinPage

Let's look at a real Page Object from an actual project. ThisFutbinPage class interacts with the FUTBIN website to scrape popular player data. Pay attention to how it organizes locators, actions, and helpers.

tests/pages/FutbinPage.tstypescript
1import { Page } from '@playwright/test';
2import * as fs from 'fs';
3import * as path from 'path';
4
5export class FutbinPage {
6    // Store the page instance via constructor shorthand
7    constructor(private page: Page) { }
8
9    // Navigate to the site and handle cookies
10    async goto(): Promise<void> {
11        await this.page.goto('https://www.futbin.com');
12        await this.page.waitForTimeout(2000);
13        await this.acceptCookies();
14    }
15
16    // Private-like helper: handle cookie consent popups
17    async acceptCookies(): Promise<void> {
18        const selectors = [
19            '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
20            'button:has-text("Allow all")',
21            '#CybotCookiebotDialogBodyButtonAccept',
22            '.fc-cta-consent',
23            'button:has-text("Accept")'
24        ];
25
26        for (const selector of selectors) {
27            const btn = this.page.locator(selector).first();
28            if (await btn.isVisible({ timeout: 1000 }).catch(() => false)) {
29                await btn.click();
30                await this.page.waitForTimeout(1000);
31                return;
32            }
33        }
34    }
35
36    // Navigate to the popular players section
37    async goToPopularPlayers(): Promise<void> {
38        await this.page.locator('a:has-text("Players")').first().hover();
39        await this.page.waitForTimeout(500);
40        await this.page.locator('a[href="/popular"]').first().click();
41        await this.page.waitForLoadState('domcontentloaded');
42        await this.page.waitForTimeout(2000);
43    }
44
45    // Extract player data from the page
46    async getPopularPlayers(): Promise<{ name: string; price: string }[]> {
47        const players = await this.page.evaluate(() => {
48            const results: { name: string; price: string }[] = [];
49            const playerCards = document.querySelectorAll(
50                'a.playercard-wrapper[href*="/player/"]'
51            );
52
53            playerCards.forEach((card: Element) => {
54                const href = card.getAttribute('href') || '';
55                const match = href.match(/\/player\/\d+\/([^/?]+)/);
56                if (match) {
57                    const nameFromUrl = match[1]
58                        .replace(/-/g, ' ')
59                        .replace(/\b\w/g, c => c.toUpperCase());
60
61                    const priceEl = card.querySelector('.platform-pc-only');
62                    let price = 'N/A';
63                    if (priceEl) {
64                        const priceText = priceEl.textContent?.trim() || '';
65                        const priceMatch = priceText.match(/(\d{1,3}(,\d{3})*)/);
66                        if (priceMatch) price = priceMatch[1];
67                    }
68
69                    if (!results.some(r => r.name === nameFromUrl)) {
70                        results.push({ name: nameFromUrl, price });
71                    }
72                }
73            });
74            return results;
75        });
76        return players;
77    }
78
79    // Save data to a report file
80    async saveReport(
81        players: { name: string; price: string }[]
82    ): Promise<string> {
83        const reportsDir = path.join(__dirname, '..', '..', 'reports');
84        if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir);
85
86        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
87        const filepath = path.join(reportsDir,
88            `popular-players-${timestamp}.txt`);
89
90        const content = [
91            '=== FUTBIN Popular Players Report ===',
92            `Date: ${new Date().toLocaleString()}`,
93            `Total Players: ${players.length}`,
94            '',
95            ...players.map((p, i) => `${i + 1}. ${p.name} - ${p.price}`)
96        ].join('\n');
97
98        fs.writeFileSync(filepath, content);
99        return filepath;
100    }
101}

Method Breakdown

goto()
Returns: Promise<void>
Navigates to the site and handles the cookie consent popup. This is the entry point — every test starts by calling this method.
acceptCookies()
Returns: Promise<void>
A helper method that tries multiple selectors for the cookie banner. Uses a fallback strategy — tries each selector and clicks the first visible one. Gracefully handles cases where no banner appears.
goToPopularPlayers()
Returns: Promise<void>
Navigates to the popular players page via the dropdown menu. Hovers over the "Players" link, then clicks the "Popular" option. Waits for the page to load.
getPopularPlayers()
Returns: Promise<{ name: string; price: string }[]>
Extracts player data directly from the DOM using page.evaluate(). Returns a typed array of player objects with name and price. Avoids duplicates.
saveReport()
Returns: Promise<string>
Saves the extracted data to a text file with a timestamp. Creates the reports directory if it doesn't exist. Returns the file path.

The Test Using This Page Object

tests/stoichkov-pom.spec.tstypescript
1import { test, expect } from '@playwright/test';
2import { FutbinPage } from './pages/FutbinPage';
3
4test('Get Popular Players List', async ({ page }) => {
5    const futbin = new FutbinPage(page);
6
7    // Navigate and handle cookies
8    await futbin.goto();
9
10    // Navigate to popular players
11    await futbin.goToPopularPlayers();
12
13    // Extract player data
14    const players = await futbin.getPopularPlayers();
15
16    // Log results
17    console.log('\n=== Popular Players ===');
18    players.forEach((p, i) => {
19        console.log(`${i + 1}. ${p.name} - ${p.price}`);
20    });
21
22    // Save report
23    const reportPath = await futbin.saveReport(players);
24    console.log(`\nReport saved to: ${reportPath}`);
25
26    // Assert we found players
27    expect(players.length).toBeGreaterThan(0);
28});
Notice the SimplicityThe test is only ~15 lines of clean, readable code. All the complexity — cookie handling, DOM parsing, file I/O — is hidden inside the Page Object. If FUTBIN changes their UI, you only update FutbinPage.ts.

Component Objects

Sometimes a page has reusable UI components (header, navigation, modals, search bars) that appear on multiple pages. Instead of duplicating these in every Page Object, create Component Objects and compose them.

tests/components/NavigationComponent.tstypescript
1import { Page, Locator } from '@playwright/test';
2
3// Component Object for the navigation bar
4export class NavigationComponent {
5  private readonly page: Page;
6  private readonly homeLink: Locator;
7  private readonly profileLink: Locator;
8  private readonly settingsLink: Locator;
9  private readonly logoutButton: Locator;
10  private readonly searchInput: Locator;
11
12  constructor(page: Page) {
13    this.page = page;
14    const nav = page.locator('nav.main-nav');
15    this.homeLink = nav.getByRole('link', { name: 'Home' });
16    this.profileLink = nav.getByRole('link', { name: 'Profile' });
17    this.settingsLink = nav.getByRole('link', { name: 'Settings' });
18    this.logoutButton = nav.getByRole('button', { name: 'Logout' });
19    this.searchInput = nav.getByPlaceholder('Search...');
20  }
21
22  async goToHome(): Promise<void> {
23    await this.homeLink.click();
24  }
25
26  async goToProfile(): Promise<void> {
27    await this.profileLink.click();
28  }
29
30  async goToSettings(): Promise<void> {
31    await this.settingsLink.click();
32  }
33
34  async logout(): Promise<void> {
35    await this.logoutButton.click();
36  }
37
38  async search(query: string): Promise<void> {
39    await this.searchInput.fill(query);
40    await this.searchInput.press('Enter');
41  }
42}

Then compose it into your Page Objects:

tests/pages/DashboardPage.tstypescript
1import { Page } from '@playwright/test';
2import { BasePage } from './BasePage';
3import { NavigationComponent } from '../components/NavigationComponent';
4
5export class DashboardPage extends BasePage {
6  // Compose the navigation component
7  public readonly navigation: NavigationComponent;
8
9  constructor(page: Page) {
10    super(page);
11    this.navigation = new NavigationComponent(page);
12  }
13
14  getUrl(): string {
15    return '/dashboard';
16  }
17
18  // ... dashboard-specific methods
19}
20
21// In your test:
22// const dashboard = new DashboardPage(page);
23// await dashboard.navigate();
24// await dashboard.navigation.goToSettings(); // Use component!
Composition over DuplicationThe NavigationComponent is written once and used in every page that has a nav bar. If the navigation UI changes, update one file — not every page object.

Advanced POM Patterns

Fluent Interface (Method Chaining)

The fluent interface pattern lets you chain method calls for more expressive tests. Each method returns this (or another page object) instead of void:

tests/pages/CheckoutPage.tstypescript
1import { Page, Locator } from '@playwright/test';
2
3export class CheckoutPage {
4  private readonly page: Page;
5  private readonly addressInput: Locator;
6  private readonly cityInput: Locator;
7  private readonly zipInput: Locator;
8  private readonly cardNumberInput: Locator;
9  private readonly placeOrderButton: Locator;
10
11  constructor(page: Page) {
12    this.page = page;
13    this.addressInput = page.getByLabel('Address');
14    this.cityInput = page.getByLabel('City');
15    this.zipInput = page.getByLabel('ZIP Code');
16    this.cardNumberInput = page.getByLabel('Card Number');
17    this.placeOrderButton = page.getByRole('button', { name: 'Place Order' });
18  }
19
20  // Each method returns 'this' for chaining
21  async fillAddress(address: string): Promise<CheckoutPage> {
22    await this.addressInput.fill(address);
23    return this;
24  }
25
26  async fillCity(city: string): Promise<CheckoutPage> {
27    await this.cityInput.fill(city);
28    return this;
29  }
30
31  async fillZip(zip: string): Promise<CheckoutPage> {
32    await this.zipInput.fill(zip);
33    return this;
34  }
35
36  async fillCardNumber(card: string): Promise<CheckoutPage> {
37    await this.cardNumberInput.fill(card);
38    return this;
39  }
40
41  async placeOrder(): Promise<void> {
42    await this.placeOrderButton.click();
43  }
44}
45
46// Usage in test — clean, chainable:
47// const checkout = new CheckoutPage(page);
48// await (await (await (await checkout
49//   .fillAddress('123 Main St'))
50//   .fillCity('New York'))
51//   .fillZip('10001'))
52//   .fillCardNumber('4111111111111111');
53// await checkout.placeOrder();

Data-Driven Page Objects

Use TypeScript interfaces to create typed test data that works seamlessly with your Page Objects:

tests/pages/RegistrationPage.tstypescript
1// Define typed test data interfaces
2interface UserCredentials {
3  username: string;
4  password: string;
5}
6
7interface ShippingInfo {
8  address: string;
9  city: string;
10  state: string;
11  zip: string;
12  country: string;
13}
14
15// Page Object with typed data methods
16export class RegistrationPage {
17  constructor(private page: Page) {}
18
19  async fillRegistrationForm(user: UserCredentials & {
20    email: string;
21    firstName: string;
22    lastName: string;
23  }): Promise<void> {
24    await this.page.getByLabel('First Name').fill(user.firstName);
25    await this.page.getByLabel('Last Name').fill(user.lastName);
26    await this.page.getByLabel('Email').fill(user.email);
27    await this.page.getByLabel('Username').fill(user.username);
28    await this.page.getByLabel('Password').fill(user.password);
29  }
30
31  async fillShippingInfo(shipping: ShippingInfo): Promise<void> {
32    await this.page.getByLabel('Address').fill(shipping.address);
33    await this.page.getByLabel('City').fill(shipping.city);
34    await this.page.getByLabel('State').fill(shipping.state);
35    await this.page.getByLabel('ZIP').fill(shipping.zip);
36    await this.page.getByLabel('Country').fill(shipping.country);
37  }
38}
39
40// Test with typed data
41// const testUser = {
42//   firstName: 'John', lastName: 'Doe',
43//   email: 'john@test.com',
44//   username: 'johndoe', password: 'Test@123'
45// };
46// await registrationPage.fillRegistrationForm(testUser);

Enterprise Project Structure

Here's what a fully-scaled enterprise test automation project looks like. This structure supports dozens of page objects, shared components, test data management, and custom utilities:

enterprise-test-project/
├── tests/
│ ├── pages/
│ │ ├── BasePage.ts
│ │ ├── LoginPage.ts
│ │ ├── DashboardPage.ts
│ │ ├── ProductPage.ts
│ │ ├── CartPage.ts
│ │ ├── CheckoutPage.ts
│ │ └── ProfilePage.ts
│ ├── components/
│ │ ├── NavigationComponent.ts
│ │ ├── HeaderComponent.ts
│ │ ├── FooterComponent.ts
│ │ ├── SearchComponent.ts
│ │ └── ModalComponent.ts
│ ├── specs/
│ │ ├── auth/
│ │ │ ├── login.spec.ts
│ │ │ ├── logout.spec.ts
│ │ │ └── registration.spec.ts
│ │ ├── shopping/
│ │ │ ├── product-browse.spec.ts
│ │ │ ├── add-to-cart.spec.ts
│ │ │ └── checkout.spec.ts
│ │ └── user/
│ │ ├── profile.spec.ts
│ │ └── settings.spec.ts
│ ├── fixtures/
│ │ ├── test-data.ts   # Typed test data
│ │ └── custom-fixtures.ts   # Playwright fixtures
│ └── utils/
│ ├── helpers.ts   # Shared utilities
│ └── api-client.ts   # API helpers
├── playwright.config.ts
├── tsconfig.json
└── package.json

Using Playwright Fixtures for POM

For the cleanest test code, you can use Playwright's fixtures system to automatically create Page Objects and inject them into your tests:

tests/fixtures/custom-fixtures.tstypescript
1import { test as base } from '@playwright/test';
2import { LoginPage } from './pages/LoginPage';
3import { DashboardPage } from './pages/DashboardPage';
4import { ProductPage } from './pages/ProductPage';
5
6// Declare your custom fixtures
7type MyFixtures = {
8  loginPage: LoginPage;
9  dashboardPage: DashboardPage;
10  productPage: ProductPage;
11};
12
13// Extend Playwright's test with your fixtures
14export const test = base.extend<MyFixtures>({
15  loginPage: async ({ page }, use) => {
16    const loginPage = new LoginPage(page);
17    await use(loginPage);
18  },
19
20  dashboardPage: async ({ page }, use) => {
21    const dashboardPage = new DashboardPage(page);
22    await use(dashboardPage);
23  },
24
25  productPage: async ({ page }, use) => {
26    const productPage = new ProductPage(page);
27    await use(productPage);
28  },
29});
30
31export { expect } from '@playwright/test';
tests/specs/shopping/product-browse.spec.tstypescript
1// Import YOUR custom test, not Playwright's
2import { test, expect } from '../fixtures/custom-fixtures';
3
4// Page objects are automatically injected!
5test('user can browse products', async ({ loginPage, dashboardPage, productPage }) => {
6  await loginPage.goto();
7  await loginPage.login('admin', 'password');
8
9  // No need to create page objects manually
10  await dashboardPage.navigate();
11  const welcome = await dashboardPage.getWelcomeText();
12  expect(welcome).toContain('Welcome');
13});
This is Enterprise-LevelFixtures are the recommended way to use POM in Playwright. They handle instantiation, cleanup, and dependency injection automatically. This is the pattern used by major companies in their test suites.