Q24 of 42 · Playwright
How would you structure a POM for Playwright with shared base behaviour?
Short answer
Short answer: Create a `BasePage` class with the `page` fixture and shared utilities (waiting helpers, URL navigation, common header interactions). Subclass per page: `LoginPage extends BasePage`. Inject the page via constructor. Methods return `this` or the next page object for fluent chaining.
Detail
A maintainable Playwright POM has a thin base class plus per-page classes. The base owns shared concerns; subclasses own page-specific locators and actions.
Base class:
import { Page, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
async goto(path: string) {
await this.page.goto(path);
}
// Shared header
get header() {
return this.page.getByRole('banner');
}
async logout() {
await this.header.getByRole('button', { name: 'Log out' }).click();
}
async expectTitle(expected: string | RegExp) {
await expect(this.page).toHaveTitle(expected);
}
}
Per-page subclass:
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';
export class LoginPage extends BasePage {
private email = () => this.page.getByLabel('Email');
private password = () => this.page.getByLabel('Password');
private submit = () => this.page.getByRole('button', { name: 'Sign in' });
async visit(): Promise<this> {
await this.goto('/login');
return this;
}
async loginAs(email: string, pwd: string): Promise<DashboardPage> {
await this.email().fill(email);
await this.password().fill(pwd);
await this.submit().click();
return new DashboardPage(this.page);
}
}
Why locators-as-functions (instead of properties): the function returns a fresh Locator each call, which re-queries on each action. A property captured at construction time would also work because Locators are lazy, but functions read more naturally and avoid subtle staleness if you ever materialise.
Fixture integration — instead of new LoginPage(page) in every test, expose pages as fixtures:
type Pages = { loginPage: LoginPage; dashboard: DashboardPage };
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => use(new LoginPage(page)),
dashboard: async ({ page }, use) => use(new DashboardPage(page)),
});
// Spec
test('logs in', async ({ loginPage, dashboard }) => {
await loginPage.visit();
await loginPage.loginAs('alice@x.com', 'pwd');
await expect(dashboard.welcome).toContainText('Welcome');
});
Discipline: assertions belong in tests, not in POM methods. POM exposes state (locators, simple readers); tests assert.
// EXAMPLE
pages/CartPage.ts
import { Locator, Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { CheckoutPage } from './CheckoutPage';
export class CartPage extends BasePage {
rows = () => this.page.getByTestId('cart-row');
total = () => this.page.getByTestId('total');
private checkoutBtn = () => this.page.getByRole('button', { name: 'Checkout' });
async visit() {
await this.goto('/cart');
return this;
}
async removeItem(itemId: string) {
await this.page.getByTestId(`item-${itemId}`)
.getByRole('button', { name: 'Remove' })
.click();
return this;
}
async proceedToCheckout(): Promise<CheckoutPage> {
await this.checkoutBtn().click();
return new CheckoutPage(this.page);
}
}// WHAT INTERVIEWERS LOOK FOR
// COMMON PITFALL
// Related questions
How does Playwright's storageState work and when would you use it?
Playwright
What is a fixture in Playwright and how does it differ from beforeEach?
Playwright
How would you structure a Page Object Model in a TypeScript Cypress project?
Cypress
How do you design a type-safe Page Object class hierarchy in TypeScript with Playwright?
TypeScript