SoftwareTestPilot
Automation TestingPublished: Updated: · 2 weeks ago16 min read

Playwright Page Object Model in TypeScript: Build a Small, Clean Framework

Build a Playwright Page Object Model in TypeScript step by step: config, page objects, a reusable component, fixtures, assertions and tests, plus the design choices and overengineering traps.

SoftwareTestPilot
QA Editorial Team
Share:XLinkedInWhatsApp
Playwright Page Object Model in TypeScript: Build a Small, Clean Framework: Automation Testing article cover, SoftwareTestPilot.com
Playwright Page Object Model in TypeScript: Build a Small, Clean Framework: Automation Testing article cover, SoftwareTestPilot.com

The Page Object Model is the first framework pattern most testers learn, and also the one most often overbuilt. Done well in Playwright with TypeScript, it gives you short, readable tests and one place to fix a locator when the interface changes. Done badly, it gives you a tower of base classes and wrapper methods that slow everyone down.

This guide builds a small, complete framework rather than isolated snippets: configuration, page objects, one reusable component, a fixtures file, assertions and tests. The application is a fictional online shop with a login page, a product list and a cart, so URLs and selectors are examples you will replace with your own. After the build, we look at the design choices behind each file and the specific points where adding more structure starts to hurt.

If you have not written a Playwright test in TypeScript before, start with the Playwright TypeScript tutorial and come back. Everything here assumes you can run npx playwright test.

What a page object is, in one paragraph

A page object is a class that represents one screen, or one meaningful part of a screen. It holds the locators for that area and exposes methods named after what a user does there: login(), addToCart(), removeItem(). Tests call those methods and never touch selectors directly. When a button label changes, you edit one line in one class, and every test that uses it keeps working.

That is the whole idea. Everything else in this article (fixtures, components, folder layout) exists to make that one idea comfortable to use in a growing suite, and the overengineering section exists because it is easy to keep adding structure after the idea has already paid off.

The framework we are building

The finished project has six folders' worth of code, and each one has a single job:

shop-tests/
├── playwright.config.ts      # where and how tests run
├── pages/
│   ├── login.page.ts         # one class per screen
│   ├── products.page.ts
│   └── cart.page.ts
├── components/
│   └── header.component.ts   # widget shared by several pages
├── fixtures/
│   └── test.ts               # hands ready page objects to tests
├── data/
│   └── users.ts              # test accounts, read from env
└── tests/
    ├── login.spec.ts
    └── cart.spec.ts

Notice what is missing: no BasePage, no utils/ folder of helpers, no driver factory. Playwright already manages browsers, contexts and waiting, so a lot of the scaffolding that Selenium frameworks need simply has no job here. We will add a layer only when a concrete piece of duplication asks for it.

The naming convention *.page.ts and *.component.ts is a choice, not a rule. Its benefit is that a file search for .page lists every screen the suite knows about, which helps new team members find their way.

Step 1: Configuration that pages can rely on

Page objects should use relative URLs like /login, never full addresses. That only works if the config sets a baseURL, so the config is the first file to write.

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: [['html', { open: 'never' }], ['list']],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    testIdAttribute: 'data-testid',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Three settings matter for page objects. baseURL lets the same page classes run against local, staging or a preview build by changing one environment variable. testIdAttribute tells getByTestId() which attribute your developers use, so page objects never hardcode it. And trace: 'on-first-retry' means that when a page method fails in CI you get a trace showing exactly which locator did not match, which is far more useful than any logging you could add inside the class.

Test accounts go in a tiny data module that reads secrets from the environment, so passwords never sit in the repository:

// data/users.ts
export type User = { email: string; password: string };

export const users = {
  shopper: {
    email: 'shopper@example.com',
    password: process.env.SHOPPER_PASSWORD ?? '',
  },
} satisfies Record<string, User>;

The satisfies keyword checks every entry against the User type while keeping the exact keys, so users.shopper autocompletes and a typo like users.shoper fails at compile time.

Step 2: The first page object

The login page shows the basic shape every page object in this framework follows: a constructor that receives the Page, readonly Locator fields, and action methods.

// pages/login.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import type { User } from '../data/users';

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

  constructor(private readonly page: Page) {
    this.email = page.getByLabel('Email');
    this.password = page.getByLabel('Password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
    this.error = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
    await expect(this.submit).toBeVisible();
  }

  async login(user: User) {
    await this.email.fill(user.email);
    await this.password.fill(user.password);
    await this.submit.click();
  }
}

Why locators are fields built in the constructor. A Playwright Locator is a description of how to find an element, not the element itself. Creating one does not touch the page, and it is resolved fresh every time you act on it. So building all locators up front is free, can never produce a stale element, and lets anyone read the top of the class to see every element the page cares about.

Why the locators are public. Keeping them readonly but public lets a test write await expect(loginPage.error).toHaveText(...) without the page object needing a dedicated method for every possible check. readonly stops a test from reassigning them by accident.

Why goto() waits for the button. This is the one assertion that belongs inside a page object: confirming you actually arrived on the page. Every test that opens the login page needs it, and it turns a vague later failure into a clear "login page did not load" at the right step.

The locators use getByLabel and getByRole because they follow what a user sees and survive styling changes. For the full priority order and when a test id is the better choice, read the Playwright locators guide.

Step 3: A reusable component for the header

The header, with its cart badge and account menu, appears on the product list and the cart page. Defining its locators in both page objects would duplicate them. A common reflex is to push them into a base class; a better fit is a component: a small class scoped to the header's root element that pages include as a field.

// components/header.component.ts
import type { Locator, Page } from '@playwright/test';

export class Header {
  readonly root: Locator;
  readonly cartLink: Locator;
  readonly cartCount: Locator;
  readonly accountMenu: Locator;

  constructor(page: Page) {
    this.root = page.getByRole('banner');
    this.cartLink = this.root.getByRole('link', { name: /cart/i });
    this.cartCount = this.root.getByTestId('cart-count');
    this.accountMenu = this.root.getByRole('button', { name: 'Account' });
  }

  async openCart() {
    await this.cartLink.click();
  }

  async logout() {
    await this.accountMenu.click();
    await this.root.getByRole('menuitem', { name: 'Sign out' }).click();
  }
}

Every locator is chained from root, so a "Cart" link somewhere else on the page can never be clicked by mistake. That scoping is the main advantage of components over inheritance: a component knows exactly which part of the page it owns. A page can also hold several components (a header, a filter panel, a modal) without the single-inheritance limit a base class imposes.

Step 4: Product and cart pages that use the component

// pages/products.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import { Header } from '../components/header.component';

export class ProductsPage {
  readonly header: Header;
  readonly heading: Locator;

  constructor(private readonly page: Page) {
    this.header = new Header(page);
    this.heading = page.getByRole('heading', { name: 'Products' });
  }

  async goto() {
    await this.page.goto('/products');
    await expect(this.heading).toBeVisible();
  }

  card(name: string): Locator {
    return this.page.getByRole('listitem').filter({ hasText: name });
  }

  async addToCart(name: string) {
    await this.card(name).getByRole('button', { name: 'Add to cart' }).click();
  }
}
// pages/cart.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import { Header } from '../components/header.component';

export class CartPage {
  readonly header: Header;
  readonly rows: Locator;
  readonly total: Locator;
  readonly emptyMessage: Locator;

  constructor(private readonly page: Page) {
    this.header = new Header(page);
    this.rows = page.getByRole('row').filter({ has: page.getByRole('cell') });
    this.total = page.getByTestId('cart-total');
    this.emptyMessage = page.getByText('Your cart is empty');
  }

  async expectLoaded() {
    await expect(this.page).toHaveURL(/\/cart$/);
  }

  row(name: string): Locator {
    return this.rows.filter({ hasText: name });
  }

  async remove(name: string) {
    await this.row(name).getByRole('button', { name: 'Remove' }).click();
  }
}

Dynamic locators are methods. card(name) and row(name) take runtime data and return a Locator, built with filter() rather than string-concatenated XPath. Returning the locator, rather than a boolean or text, lets the test decide what to check: visibility, count, text, or a nested button.

Methods return nothing. Some frameworks make every action return the next page object (login() returns ProductsPage) for fluent chains. It reads nicely in demos, but it hard-codes navigation into the class: a failed login and a successful one would need different methods with different return types. In this framework, tests ask fixtures for whichever page they need next, which keeps each class independent.

Step 5: Fixtures that hand tests ready page objects

Without fixtures, every test begins with the same lines: const loginPage = new LoginPage(page); const cartPage = new CartPage(page);. Playwright's test.extend moves that construction into one file and gives each test exactly the objects it names in its parameters, fully typed.

// fixtures/test.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { ProductsPage } from '../pages/products.page';
import { CartPage } from '../pages/cart.page';
import { users } from '../data/users';

type Pages = {
  loginPage: LoginPage;
  productsPage: ProductsPage;
  cartPage: CartPage;
  signedIn: ProductsPage;
};

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  productsPage: async ({ page }, use) => {
    await use(new ProductsPage(page));
  },
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
  signedIn: async ({ loginPage, productsPage }, use) => {
    await loginPage.goto();
    await loginPage.login(users.shopper);
    await expect(productsPage.heading).toBeVisible();
    await use(productsPage);
  },
});

export { expect };

Fixtures are lazy: a test that asks only for loginPage never builds the cart page, and a test that asks for signedIn gets logged in automatically because that fixture depends on loginPage and productsPage. All of them share the same page, so the objects act on one browser tab.

The signedIn fixture logs in through the interface, which is fine for a handful of tests. Once the suite grows, log in once in a setup project and reuse the saved session instead. That pattern, plus API-created test data with automatic cleanup, is built step by step in the Playwright custom fixtures example. If you are deciding between fixtures and beforeEach, see fixtures vs hooks compared.

One rule keeps this working: every spec imports test and expect from fixtures/test.ts, never from @playwright/test directly.

Step 6: The tests

With the layers in place, the tests read as plain descriptions of behaviour:

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

test('shopper can sign in', async ({ loginPage, productsPage }) => {
  await loginPage.goto();
  await loginPage.login(users.shopper);
  await expect(productsPage.heading).toBeVisible();
});

test('wrong password shows an error', async ({ loginPage }) => {
  await loginPage.goto();
  await loginPage.login({ ...users.shopper, password: 'wrong-password' });
  await expect(loginPage.error).toHaveText('Email or password is incorrect');
  await expect(loginPage.submit).toBeEnabled();
});
// tests/cart.spec.ts
import { test, expect } from '../fixtures/test';

test('adding a product updates the cart badge', async ({ signedIn }) => {
  await signedIn.addToCart('Travel Mug');
  await expect(signedIn.header.cartCount).toHaveText('1');
});

test('removing the last item empties the cart', async ({ signedIn, cartPage }) => {
  await signedIn.addToCart('Travel Mug');
  await signedIn.header.openCart();
  await cartPage.expectLoaded();
  await expect(cartPage.row('Travel Mug')).toBeVisible();

  await cartPage.remove('Travel Mug');
  await expect(cartPage.rows).toHaveCount(0);
  await expect(cartPage.emptyMessage).toBeVisible();
});

Look at the second login test. The same login() method serves the happy path and the error path, because the method only performs the action and the test decides what "correct" means. If login() had asserted that the products page appeared, the error test would need a second method, loginExpectingFailure(), and the page object would slowly fill with variations.

Where assertions belong

This is the design question teams argue about most, so here is the rule this framework follows and why.

Kind of checkWhere it livesExample
Arrival: "I am on the right page"Page objectgoto() waiting for a heading, expectLoaded()
Business outcomeTestCart badge shows 1, error text is correct
Readiness before an actionNowhere: Playwright handles itButton visible and enabled before a click

Arrival checks go in the page object because every caller needs them and they make failures point at the right step. Business outcomes stay in the test because they differ between scenarios, and a reader should see what a test proves without opening another file.

The third row is the one people miss. Playwright's actions already wait for an element to be attached, visible, stable, enabled and able to receive events before clicking. Adding await expect(button).toBeVisible() before every click inside page methods adds noise and no safety. Likewise, use web-first assertions like toHaveText and toHaveCount, which retry until a timeout, rather than reading a value once with textContent() and comparing it. The official actionability checks and assertions pages list exactly what is checked.

The design choices, summarised

Every decision above trades something. This table collects them so you can change any one deliberately rather than by habit.

ChoiceWhyWhat it costs
Readonly public locator fieldsReadable inventory of the page; tests can assert directlyTests can reach into locators, so reviewers must keep actions in methods
Components instead of a base classScoped locators, any number per pageOne extra file per widget
Methods return nothingClasses stay independent of navigation outcomesNo fluent chains
Fixtures build page objectsNo construction lines in tests, typed parametersNew team members must learn test.extend
Relative URLs plus baseURLSame code for every environmentNothing meaningful
Role and label locators firstFollow what users see, survive styling changesNeed accessible markup; fall back to test ids where it is missing

Overengineering risks, and how to spot them early

A page object framework rarely fails because it has too little structure. It fails because each layer seemed reasonable when added, and together they make a two-line change take an afternoon. These are the patterns to watch for.

1. The wrapper BasePage

A BasePage with methods like clickElement(locator), typeText(locator, text) and waitForElement(locator) renames Playwright's own API without adding behaviour. Every new team member must learn both, and the wrappers often quietly remove features, such as options passed to click(). Test: if a method body is a single Playwright call, delete the method.

2. Page objects for screens no test uses

Generating a class for every route on day one feels thorough, but untested classes rot: their locators are never run, so nobody notices when they break. Test: create a page object when the second test needs that screen, not before.

3. One class per page, regardless of size

The opposite problem: a single DashboardPage with 60 locators covering six panels. Split by area the moment a class is hard to scan, usually somewhere past a few hundred lines. Components are the natural seams.

4. Abstracting the test runner

Some frameworks hide Playwright behind their own "driver" interface so the suite could switch tools one day. In practice, this blocks you from using locators, fixtures and web-first assertions, which are the reasons you chose Playwright. Test: if an abstraction exists for a migration nobody has scheduled, remove it.

5. Configuration-driven locators

Storing selectors in JSON or YAML files "so non-coders can update them" separates a locator from the code that uses it, loses type checking, and breaks editor navigation. A TypeScript field is already a single place to edit.

6. Layers between test and page object

Adding "step" or "flow" classes on top of page objects is sometimes justified for long business journeys repeated across many tests. More often it creates a third place to look. Prefer test.step() inside the test for readable reports, and add a flow class only when the same multi-page sequence appears in several specs.

A useful closing question for any new layer: which duplicated lines in the current suite does this remove? If you cannot point to them, the layer is speculative and can wait.

How this framework grows without changing shape

The structure above holds up well as a suite grows, because each growth step has an obvious home:

  • A new screen gets a page file and a fixture entry, nothing else.
  • A new shared widget (modal, data table, date picker) becomes a component class included by the pages that show it.
  • Faster sign-in replaces the signedIn fixture body with a saved storage state from a setup project; tests do not change.
  • API-created test data becomes another fixture that creates records before use() and deletes them after.
  • More environments means more values for BASE_URL, not more code.

For the surrounding project concerns this article skips (linting, reporting, CI pipelines and parallel runs) the Playwright framework setup guide covers the full repository.

Build your Playwright foundation with the hands-on course, where you write page objects, fixtures and tests against a real practice app.

Explaining this design in an interview

Interviewers asking about page objects usually want to hear trade-offs, not definitions. A strong answer based on this framework covers four points in under two minutes: page objects own locators and actions for one screen; locators are lazy Locator fields, so there are no stale elements; business assertions live in tests so one method serves many scenarios; and fixtures inject page objects so tests stay short. Then name one thing you deliberately avoided, such as a wrapper base class, and why. That last part is what separates someone who has maintained a suite from someone who has only read about the pattern. For more practice questions, see the Playwright TypeScript interview questions.

Summary

A clean Playwright Page Object Model in TypeScript needs surprisingly little: a config with a baseURL, one class per screen holding readonly locators and action methods, components for shared widgets, a fixtures file that builds those objects, and tests that make the business assertions. Keep arrival checks in page objects and outcome checks in tests, let Playwright handle waiting, and add a new layer only when you can name the duplication it removes.

The official Playwright page object models and fixtures documentation are the reference for the APIs used here. The code in this article was reviewed against that documentation but not executed, because the shop is fictional; adjust URLs, labels and test ids to your application.

Build your Playwright foundation with the hands-on course, where you write page objects, fixtures and tests against a real practice app.

Key takeaways

  1. 1A page object is a class that owns the locators and actions for one screen, so tests describe behaviour instead of selectors.
  2. 2Store locators as readonly Locator fields built in the constructor. Locators are lazy, so this costs nothing and never goes stale.
  3. 3Keep business assertions in tests. Page objects may expose small helpers such as expectLoaded() when every test needs the same check.
  4. 4Hand page objects to tests through fixtures, so no spec file repeats new LoginPage(page).
  5. 5Model repeated widgets (header, modal, table) as components scoped to a root locator, not as one giant base class.
  6. 6Stop adding layers when the next one does not remove duplication you can point at today.

Common mistakes to avoid

  • Building a BasePage with dozens of generic wrappers like clickElement() and typeText() that only rename Playwright methods.
  • Hiding assertions for business rules inside page methods, so a test cannot check the error path without a second method.
  • Returning raw strings for selectors instead of Locator objects, which loses auto-waiting and chaining.
  • Adding waitForTimeout() inside page methods to cover a timing problem that a web-first assertion would solve.
  • Creating a page object for every tiny screen before any test uses it.