Most Playwright Page Object Model (POM) tutorials stop at "put your selectors in a class". That part is easy. The trouble starts three months later: page objects with 40 methods, waitForTimeout(2000) sprinkled everywhere, assertions hidden in helpers, and every test file starting with five lines of new SomethingPage(page).
This tutorial shows a POM structure in TypeScript that stays small and readable as the suite grows. All code runs against practicesoftwaretesting.com (the "Toolshop" demo app), so you can copy it and run it today.
You will build:
- a tiny
BasePagewith navigation and a readiness check - page objects that use
data-testlocators and exposeLocators, not strings - a page method that waits for the API response instead of sleeping
- custom fixtures so tests receive ready-made page objects
- tests that read like a checklist
1. Setup
npm init playwright@latest pom-demo -- --quiet --lang=ts
cd pom-demo
Then tell Playwright which attribute your app uses for test ids. Toolshop uses data-test, so getByTestId('email') will match data-test="email":
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'https://practicesoftwaretesting.com',
testIdAttribute: 'data-test',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
Setting baseURL means page objects can call page.goto('/auth/login') and never hard-code a domain. Pointing the suite at staging later is a one-line change.
The folder layout we are aiming for:
src/
pages/
BasePage.ts
LoginPage.ts
AccountPage.ts
CatalogPage.ts
index.ts
fixtures/
test.ts
tests/
login.spec.ts
catalog.spec.ts
2. Four rules that keep page objects healthy
Before any code, these are the rules the rest of the article follows:
-
A page object describes a page. It does not test it. It exposes elements and user actions (
login(),search()). The test decides what "correct" means. -
Expose
Locators, not selector strings. ALocatoris lazy and auto-waits, so tests can use web-first assertions likeawait expect(loginPage.error).toBeVisible(). -
Wait for a real signal, never for time. A visible element, a URL, or a network response.
waitForTimeoutis a bug you have not found yet. - Keep the base class small. Navigation, shared header elements, a readiness check. Anything else belongs in the page that owns it.
The one exception to rule 1: a page object may use expect to wait until it is ready (for example, "the email field is visible"). That is synchronisation, not a business assertion.
3. The BasePage
// src/pages/BasePage.ts
import { expect, type Locator, type Page } from '@playwright/test';
/** Shared behaviour for every page object: navigation, header, readiness. */
export abstract class BasePage {
protected abstract readonly path: string;
readonly accountMenu: Locator;
readonly signInLink: Locator;
readonly cartBadge: Locator;
constructor(protected readonly page: Page) {
this.accountMenu = page.getByTestId('nav-menu');
this.signInLink = page.getByTestId('nav-sign-in');
this.cartBadge = page.getByTestId('cart-quantity');
}
async open(): Promise<this> {
await this.page.goto(this.path);
await this.waitUntilLoaded();
return this;
}
/** Override when a page needs a stronger readiness signal. */
async waitUntilLoaded(): Promise<void> {
await expect(this.page.getByTestId('nav-home')).toBeAttached();
}
}
A few details worth copying:
-
pathis abstract, so every page must declare its URL, andopen()works for all of them. -
open()returnsthis, so you can writeconst login = await loginPage.open()when it reads better. -
waitUntilLoaded()is the hook that kills flakiness. Every page overrides it with the element that proves the page is usable.
4. A simple page: LoginPage
// src/pages/LoginPage.ts
import { expect, type Locator, type Page } from '@playwright/test';
import { AccountPage } from './AccountPage';
import { BasePage } from './BasePage';
export type Credentials = { email: string; password: string };
export class LoginPage extends BasePage {
protected readonly path = '/auth/login';
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
constructor(page: Page) {
super(page);
this.email = page.getByTestId('email');
this.password = page.getByTestId('password');
this.submit = page.getByTestId('login-submit');
this.error = page.getByTestId('login-error');
}
override async waitUntilLoaded(): Promise<void> {
await expect(this.email).toBeVisible();
}
async login({ email, password }: Credentials): Promise<void> {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
/** Fluent helper: returns the next page object on success. */
async loginAs(credentials: Credentials): Promise<AccountPage> {
await this.login(credentials);
const account = new AccountPage(this.page);
await account.waitUntilLoaded();
return account;
}
}
Notice there are two login methods:
-
login()just performs the action. Use it for negative tests, where you then assert onloginPage.error. -
loginAs()performs the action and returns the next page once it is ready. Use it when login is a step on the way to something else.
That split avoids the classic login(email, password, expectSuccess = true) flag, which turns into a mess of if statements.
The account page it returns:
// src/pages/AccountPage.ts
import { expect, type Locator, type Page } from '@playwright/test';
import { BasePage } from './BasePage';
export class AccountPage extends BasePage {
protected readonly path = '/account';
readonly title: Locator;
constructor(page: Page) {
super(page);
this.title = page.getByTestId('page-title');
}
override async waitUntilLoaded(): Promise<void> {
await expect(this.page).toHaveURL(/\/account$/);
await expect(this.title).toHaveText('My account');
}
}
5. A harder page: wait for the API, not for time
The Toolshop home page is a product grid with a search box. Here is where most POMs go wrong: after clicking "Search" the old products are still on screen for a moment, so a test that reads the grid immediately gets stale results, passes locally and fails in CI.
The fix is to start listening for the search response before clicking, and then wait until the grid shows exactly what the API returned:
// src/pages/CatalogPage.ts
import { expect, type Locator, type Page, type Response } from '@playwright/test';
import { BasePage } from './BasePage';
type Product = { id: string; name: string; price: number };
type Paginated<T> = { data: T[]; total: number };
export class CatalogPage extends BasePage {
protected readonly path = '/';
readonly searchBox: Locator;
readonly searchButton: Locator;
readonly searchCaption: Locator;
readonly sort: Locator;
readonly productNames: Locator;
readonly productPrices: Locator;
constructor(page: Page) {
super(page);
this.searchBox = page.getByTestId('search-query');
this.searchButton = page.getByTestId('search-submit');
this.searchCaption = page.getByTestId('search-caption');
this.sort = page.getByTestId('sort');
this.productNames = page.getByTestId('product-name');
this.productPrices = page.getByTestId('product-price');
}
override async waitUntilLoaded(): Promise<void> {
// Cards render before their data arrives, so wait for a name with real text.
await expect(this.productNames.filter({ hasText: /\S/ }).first()).toBeVisible();
}
/** Searches and waits until the grid shows exactly what the API returned. */
async search(term: string): Promise<Paginated<Product>> {
const response = this.page.waitForResponse((r) => r.url().includes('/products/search') && r.ok());
await this.searchBox.fill(term);
await this.searchButton.click();
await expect(this.searchCaption).toContainText(term);
return this.gridMatches(await response);
}
async sortBy(value: 'name,asc' | 'name,desc' | 'price,asc' | 'price,desc'): Promise<Paginated<Product>> {
const response = this.page.waitForResponse(
(r) =>
/\/products(\?|$)/.test(r.url()) &&
(r.url() + (r.request().postData() ?? '')).includes(value) &&
r.ok(),
);
await this.sort.selectOption(value);
return this.gridMatches(await response);
}
private async gridMatches(response: Response): Promise<Paginated<Product>> {
const body = (await response.json()) as Paginated<Product>;
await expect(this.productNames).toHaveText(body.data.map((p) => p.name));
return body;
}
async prices(): Promise<number[]> {
const texts = await this.productPrices.allInnerTexts();
return texts.map((t) => Number(t.replace(/[^0-9.]/g, '')));
}
}
Three things make this reliable:
-
waitForResponseis created before the click. If you create it after, a fast response can arrive before you start listening and the test hangs until timeout. -
toHaveText([...names])with an array is a web-first assertion. It retries until the grid shows exactly those names in that order, so you never read a half-updated list. -
search()returns the API payload. Tests can compare UI and API data without making a second request.
And a barrel file so imports stay short:
// src/pages/index.ts
export * from './BasePage';
export * from './LoginPage';
export * from './AccountPage';
export * from './CatalogPage';
6. Fixtures: stop writing new LoginPage(page)
If every test begins with const loginPage = new LoginPage(page), you are doing work Playwright can do for you. Extend the base test with fixtures:
// src/fixtures/test.ts
import { test as base, expect } from '@playwright/test';
import { AccountPage, CatalogPage, LoginPage } from '../pages';
type Pages = {
loginPage: LoginPage;
accountPage: AccountPage;
catalogPage: CatalogPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => use(new LoginPage(page)),
accountPage: async ({ page }, use) => use(new AccountPage(page)),
catalogPage: async ({ page }, use) => use(new CatalogPage(page)),
});
export { expect };
Fixtures are lazy: a test that only asks for catalogPage never creates the others. They also give you one place to add setup and teardown later (an API client, a fresh test user, a cart that is deleted after the test).
7. The tests
Toolshop publishes demo credentials on its login page (customer@practicesoftwaretesting.com / welcome01). In a real project, read them from environment variables.
// tests/login.spec.ts
import { test, expect } from '../src/fixtures/test';
const customer = {
email: process.env.CUSTOMER_EMAIL ?? 'customer@practicesoftwaretesting.com',
password: process.env.CUSTOMER_PASSWORD ?? 'welcome01',
};
test.describe('Login', () => {
test.beforeEach(async ({ loginPage }) => {
await loginPage.open();
});
test('customer can sign in and lands on their account', async ({ loginPage }) => {
const account = await loginPage.loginAs(customer);
await expect(account.title).toHaveText('My account');
await expect(account.accountMenu).toBeVisible();
});
test('wrong password shows an error and stays on the login page', async ({ loginPage, page }) => {
await loginPage.login({ email: customer.email, password: 'definitely-wrong-1!' });
await expect(loginPage.error).toHaveText('Invalid email or password');
await expect(page).toHaveURL(/\/auth\/login$/);
});
});
// tests/catalog.spec.ts
import { test, expect } from '../src/fixtures/test';
test.describe('Catalog', () => {
test.beforeEach(async ({ catalogPage }) => {
await catalogPage.open();
});
for (const term of ['pliers', 'hammer']) {
test(`search "${term}" shows only matching products`, async ({ catalogPage }) => {
const result = await catalogPage.search(term);
expect(result.data.length).toBeGreaterThan(0);
for (const name of await catalogPage.productNames.allInnerTexts()) {
expect(name.toLowerCase()).toContain(term);
}
});
}
test('sort by price, high to low', async ({ catalogPage }) => {
await catalogPage.sortBy('price,desc');
const prices = await catalogPage.prices();
expect(prices.length).toBeGreaterThan(1);
expect(prices).toEqual([...prices].sort((a, b) => b - a));
});
});
Run them:
npx playwright test
npx playwright show-report
Look at what the tests do not contain: no selectors, no waits, no new. Each test is arrange (open), act (one method), assert (expect). When Toolshop renames data-test="email", you change one line in LoginPage.
8. Mistakes to avoid
Assertions inside action methods. A search() that also checks "results contain the term" can't be reused for a "no results" test. Return data, let the test assert.
One giant page object per app. If a class passes ~200 lines, split it into components (HeaderComponent, ProductCard) and compose them in the page.
Returning Promise<void> from navigation that changes page. Return the next page object (loginAs() returns AccountPage) so the next step is obvious and typed.
Using CSS chains like .card > div:nth-child(2) span. Prefer getByTestId, getByRole and getByLabel. They survive layout changes and match how users find things.
Logging in through the UI in every test. It works, but it is the slowest part of most suites. Log in once and reuse the session with storageState (that's the next article in this series).
waitForTimeout "just to be safe". If you need it, the page object is missing a readiness check. Find the signal (element, URL, response) and wait for that.
9. Checklist
- [ ]
baseURLandtestIdAttributeset inplaywright.config.ts - [ ] Every page has a
pathand awaitUntilLoaded() - [ ] Page objects expose
Locators and user actions, not assertions - [ ] Actions that trigger network calls wait for the response
- [ ] Tests get page objects from fixtures
- [ ] Zero
waitForTimeoutcalls
If you'd rather start from a finished setup, I packaged this structure (plus API tests, storageState login, visual and accessibility checks, CI sharding and Docker) into a Playwright + TypeScript starter kit: 46 tests against Toolshop and an 11-page guide, $29. Everything in this article works without it.
Top comments (0)