---
title: Page Object Model in Playwright | TestingBot
description: Playwright changes two things about page objects. Its locators are lazy,
  so a page object can hold them as plain fields without any risk of stale elements.…
source_url:
  html: https://testingbot.com/resources/courses/page-object-model/playwright-page-objects
  md: https://testingbot.com/resources/courses/page-object-model/playwright-page-objects.md
---

Course

[Page Object Model and Framework Design](https://testingbot.com/resources/courses/page-object-model)

Chapter 3 of 10

1. [1 What Is the Page Object Model?](https://testingbot.com/resources/courses/page-object-model)
2. [2 Your First Page Object in Selenium](https://testingbot.com/resources/courses/page-object-model/first-page-object)
3. [3 Page Object Model in Playwright](https://testingbot.com/resources/courses/page-object-model/playwright-page-objects)
4. [4 Components and Fluent Page Objects](https://testingbot.com/resources/courses/page-object-model/components-and-fluent-pages)
5. [5 Page Factory vs Plain By Locators](https://testingbot.com/resources/courses/page-object-model/page-factory-vs-by-locators)
6. [6 Test Data, Config and Environments](https://testingbot.com/resources/courses/page-object-model/test-data-and-config)
7. [7 Test Automation Framework Structure](https://testingbot.com/resources/courses/page-object-model/framework-layout)
8. [8 Run the Framework in Parallel on a Grid](https://testingbot.com/resources/courses/page-object-model/parallel-on-a-grid)
9. [9 POM Anti-Patterns and When to Skip POM](https://testingbot.com/resources/courses/page-object-model/anti-patterns)
10. [10 Knowledge check](https://testingbot.com/resources/courses/page-object-model/knowledge-check)

[Exit course](https://testingbot.com/resources/courses)

 Chapter 3 of 10 Intermediate 8 min read 2 quick checks Updated Nov 2026 

# Page Object Model in Playwright
On this page
1. [Setup](https://testingbot.com#setup)
2. [The shape Playwright recommends](https://testingbot.com#the-shape-playwright-recommends)
3. [A trap that page objects help you avoid](https://testingbot.com#a-trap-that-page-objects-help-you-avoid)
4. [Injecting page objects with fixtures](https://testingbot.com#injecting-page-objects-with-fixtures)
5. [Why fixtures beat beforeEach](https://testingbot.com#why-fixtures-beat-beforeeach)

Playwright changes two things about page objects. Its locators are lazy, so a page object can hold them as plain fields without any risk of stale elements. And its test runner has fixtures, which let you inject ready-made page objects into tests instead of constructing them by hand. This chapter rebuilds the login suite in TypeScript and uses both.

## Setup[#](https://testingbot.com#setup)

```bash
npm init playwright@latest
```

Accept TypeScript and a `tests` folder. The installer adds `@playwright/test` (1.65.0 at the time of writing) and `@types/node`. Then point the config at the practice site:

```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'https://practicetestautomation.com',
    trace: 'retain-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
```

With `baseURL` set, `page.goto('/practice-test-login/')` resolves against it, which is what makes the environment switch in chapter 05 a one-line change.

## The shape Playwright recommends[#](https://testingbot.com#the-shape-playwright-recommends)

The [Page object models guide](https://playwright.dev/docs/pom) on playwright.dev uses one consistent shape: the class takes a `Page`, creates every `Locator` once in the constructor, stores them as `readonly` fields, and exposes `async` methods for user actions. Here is our login page in that shape:

```typescript
// pages/login-page.ts
import { type Locator, type Page } from '@playwright/test';
import { LoggedInPage } from './logged-in-page';

export class LoginPage {
  readonly username: Locator;
  readonly password: Locator;
  readonly submit: Locator;
  readonly error: Locator;

  constructor(private readonly page: Page) {
    this.username = page.getByLabel('Username');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Submit' });
    // #error is in the DOM from the start (opacity 0); .show is added on failure
    this.error = page.locator('#error.show');
  }

  async goto() {
    await this.page.goto('/practice-test-login/');
  }

  async loginAs(user: string, pass: string): Promise<LoggedInPage> {
    await this.username.fill(user);
    await this.password.fill(pass);
    await this.submit.click();
    return new LoggedInPage(this.page);
  }
}
```

```typescript
// pages/logged-in-page.ts
import { type Locator, type Page } from '@playwright/test';
import { LoginPage } from './login-page';

export class LoggedInPage {
  readonly heading: Locator;
  readonly message: Locator;
  readonly logOutLink: Locator;

  constructor(private readonly page: Page) {
    this.heading = page.getByRole('heading', { level: 1 });
    this.message = page.locator('.post-content strong');
    this.logOutLink = page.getByRole('link', { name: 'Log out' });
  }

  async logOut(): Promise<LoginPage> {
    await this.logOutLink.click();
    return new LoginPage(this.page);
  }
}
```

Compare this with the Selenium version from chapter 01:

- **No base class, no waits.** Playwright's actions wait for the element to be attached, visible, stable, enabled and able to receive events before acting. The `BasePage` with `visible()` and `click()` helpers has nothing left to do.
- **Locators are public.** In Selenium we returned strings so tests could not poke at `WebElement`s. In Playwright, exposing `Locator`s is the norm, because tests need them for web-first assertions such as `await expect(loginPage.error).toHaveText(...)`, which retry until they pass or time out. A locator is a query, not a handle, so exposing it does not leak page internals the way a cached element would.
- **User-facing locators.** `getByLabel('Username')` and `getByRole('button', { name: 'Submit' })` describe what the user sees. They survive markup refactors that would break `#submit`, and they fail when the accessible name changes, which is usually a real bug.

The Selenium page objects returned strings, but the Playwright ones expose `Locator` fields. Why is that acceptable?

 A locator is a lazy query that re-resolves on every use, and tests need it for retrying web-first assertions Playwright locators cannot be clicked from outside the class TypeScript makes `readonly` fields private at runtime It is not acceptable; Playwright page objects should return strings too

A `Locator` is not a handle to a cached element, so exposing it leaks nothing that can go stale. Returning strings would force one-shot reads and lose `toHaveText`'s retrying.

## A trap that page objects help you avoid[#](https://testingbot.com#a-trap-that-page-objects-help-you-avoid)

Why `#error.show` and not just `#error`? Because this test passes without ever logging in:

```typescript
import { test, expect } from '@playwright/test';

test('this passes, and it should not', async ({ page }) => {
  await page.goto('/practice-test-login/');
  await expect(page.locator('#error')).toHaveText('Your username is invalid!');
  await expect(page.locator('#error')).toBeVisible();
});
```

The error box is in the DOM from page load, with that text, hidden only by `opacity: 0`. Playwright's [actionability docs](https://playwright.dev/docs/actionability) define visible as a non-empty bounding box without `visibility:hidden`, and state outright that elements with `opacity:0` are considered visible, so `toBeVisible()` passes. Selenium's `isDisplayed()` returns `false` for the same element. We ran both against the live page while writing this chapter.

The fix is to encode "the error is actually showing" once, in the page object, as `#error.show`. Every test that asserts on `loginPage.error` gets the correct meaning for free. This is the strongest argument for page objects in Playwright: not saving keystrokes, but putting hard-won knowledge about a page in exactly one place.

## Injecting page objects with fixtures[#](https://testingbot.com#injecting-page-objects-with-fixtures)

You can construct page objects inside each test:

```typescript
test('wrong password', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  // ...
});
```

That works, and it is what the playwright.dev POM guide shows. Once you have a dozen page objects, it turns into the same setup boilerplate POM was meant to remove. Playwright's [fixtures](https://playwright.dev/docs/test-fixtures) solve it: extend the base `test` with a fixture that builds the page object, and tests just ask for it by name.

```typescript
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

type Pages = {
  loginPage: LoginPage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await use(loginPage);
  },
});

export { expect } from '@playwright/test';
```

The fixture depends on the built-in `page` fixture, so it gets a fresh, isolated browser context per test. Everything before `use()` is setup, everything after is teardown. Fixtures are only created for tests that request them, so a test that never mentions `loginPage` never pays for the navigation.

The tests import `test` and `expect` from the fixtures file instead of from `@playwright/test`:

```typescript
// tests/login.spec.ts
import { test, expect } from './fixtures';

test('valid user sees the welcome message', async ({ loginPage, page }) => {
  const home = await loginPage.loginAs('student', 'Password123');

  await expect(page).toHaveURL(/logged-in-successfully/);
  await expect(home.heading).toHaveText('Logged In Successfully');
  await expect(home.message).toContainText('successfully logged in');
});

test('wrong password shows an error', async ({ loginPage }) => {
  await loginPage.loginAs('student', 'wrong-password');

  await expect(loginPage.error).toHaveText('Your password is invalid!');
});

test('wrong username shows an error', async ({ loginPage }) => {
  await loginPage.loginAs('not-a-user', 'Password123');

  await expect(loginPage.error).toHaveText('Your username is invalid!');
});

test('log out returns to the login form', async ({ loginPage, page }) => {
  const home = await loginPage.loginAs('student', 'Password123');
  await home.logOut();

  await expect(page).toHaveURL(/practice-test-login/);
});
```

`npx playwright test` runs all four in parallel workers. Note that `loginAs` returns a `LoggedInPage` even when we expect a failure; in Playwright that is harmless, because constructing the object performs no checks. If you want the return type to document intent the way the Selenium version does, add a separate `loginExpectingError` method that returns `this`.

A spec in the same file as the login tests never asks for `loginPage`. Does it still pay for the navigation to the login form?

 Yes, every fixture in the extended `test` runs for every test Yes, but only once per worker No, fixtures are only created for tests that request them

Fixtures are lazy: the setup before `use()` runs only when a test names the fixture (or the fixture is marked `auto`).

## Why fixtures beat beforeEach[#](https://testingbot.com#why-fixtures-beat-beforeeach)

You could build page objects in `test.beforeEach` and store them in `let` variables. Fixtures are better for three reasons:

1. **Type safety.** A test's parameters declare exactly what it uses; there are no `let loginPage: LoginPage` variables that might be undefined.
2. **Composition.** A `loggedInPage` fixture can depend on `loginPage`, and Playwright resolves the chain for you. Chapter 05 adds option fixtures for test data on top.
3. **Reuse across files.** The fixtures file is imported by every spec; `beforeEach` hooks have to be repeated or wrapped in a `describe`.

Fixtures also give you a natural place for cross-cutting work, such as reporting a test's result to a remote grid, which is what chapter 07 does. For more on JavaScript runners beyond Playwright, see our overview of [JavaScript testing frameworks](https://testingbot.com/resources/articles/top-15-javascript-testing-frameworks), and for connecting this suite to remote browsers, the [Playwright Test documentation](https://testingbot.com/support/web-automate/playwright/playwright-test).

[Previous · Chapter 2 Your First Page Object in Selenium](https://testingbot.com/resources/courses/page-object-model/first-page-object) [Next · Chapter 4 Components and Fluent Page Objects](https://testingbot.com/resources/courses/page-object-model/components-and-fluent-pages)

On this page

1. [Setup](https://testingbot.com#setup)
2. [The shape Playwright recommends](https://testingbot.com#the-shape-playwright-recommends)
3. [A trap that page objects help you avoid](https://testingbot.com#a-trap-that-page-objects-help-you-avoid)
4. [Injecting page objects with fixtures](https://testingbot.com#injecting-page-objects-with-fixtures)
5. [Why fixtures beat beforeEach](https://testingbot.com#why-fixtures-beat-beforeeach)

Certification

This course has a free, hands-on certificate.

[How it works](https://testingbot.com/resources/courses/page-object-model/certification)
