A QA automation engineer's notes on keeping a fifty-page-object Playwright suite from rotting into forks and utils folders.
My Playwright suite covers five entity domains of a B2B contract-and-order-management platform with roughly fifty page objects between them. That's the size where page object layers usually rot. Every entity form becomes a 400-line fork of the last one, a utils/ folder fills up with half-shared helpers nobody owns, and specs reach two layers down to click a button.
Mine didn't rot. Three structural decisions made the lazy path and the correct path the same path: a single shared base class for entity forms, modules as the only public API, and a hard rule for what gets into the shared layer.
The split: one base class owns the mechanics
Every entity form in the app is structurally the same. Order, contact, account, template: a create route, an edit mode reached from a detail page, a save button, a detail URL shared between view and edit. So the generic mechanics live in one abstract class, and subclasses pass config:
// src/core/pages/entityFormPage.ts
export interface EntityFormConfig {
readonly createPath: string; // '/orders/new'
readonly detailUrlPattern: RegExp; // /\/orders\/\d+\/general/
readonly createHeadingText: string; // 'Create New Order'
readonly editHeadingText: string; // 'Edit Order'
}
export abstract class EntityFormPage {
readonly saveButton: Locator;
readonly editButton: Locator;
readonly createHeading: Locator;
readonly editHeading: Locator;
protected constructor(
protected readonly page: Page,
protected readonly config: EntityFormConfig,
) {
this.saveButton = page.getByTestId('save-button');
this.editButton = page.getByTestId('edit-button');
this.createHeading = page.getByText(config.createHeadingText);
this.editHeading = page.getByText(config.editHeadingText);
}
async navigateToCreate(): Promise<void> {
await this.page.goto(this.config.createPath);
await this.createHeading.waitFor({ state: 'visible' });
}
async openEdit(): Promise<void> {
await this.editButton.click();
await this.editHeading.waitFor({ state: 'visible' });
}
// save(), selectOption(), getCurrentEntityId() — below
}
The base also owns the field helpers for the component library's form controls. Dropdown panels that fail to render, inputs that stay pristine and silently drop out of the save payload: I wrote a whole post about those UI-framework traps. The point here is only that they're solved once, inside the base, so no spec and no subclass ever sees them.
A subclass is then almost embarrassingly thin, just locators plus entity-specific flows:
// src/domains/orders/pages/orderFormPage.ts
export class OrderFormPage extends EntityFormPage {
static readonly CREATE_PATH = '/orders/new';
static readonly DETAIL_URL = /\/orders\/\d+\/general/;
readonly titleInput: Locator;
readonly statusDropdown: Locator;
constructor(page: Page) {
super(page, {
createPath: OrderFormPage.CREATE_PATH,
detailUrlPattern: OrderFormPage.DETAIL_URL,
createHeadingText: 'Create New Order',
editHeadingText: 'Edit Order',
});
this.titleInput = page.getByRole('textbox', { name: 'Order Title' });
this.statusDropdown = page.getByRole('combobox', { name: 'Status' });
}
async selectStatus(status: string): Promise<void> {
await this.selectOption(this.statusDropdown, status);
}
}
Adding the fifth entity form took about forty lines. Without the base it would have been the fourth copy of four hundred, and the fourth place to fix every save-detection bug.
Save semantics are an API, answered once
The most valuable thing the base class owns is the answer to "how do I know the save finished?" In the app, view and edit modes share the detail URL, so page.url() never changes on save. The reliable signal is the edit button reappearing. And when the backend rejects a save, the form just stays in edit mode behind an error banner, which naively reads as a 30-second hang. So save() races the success signal against the failure signal:
async save(): Promise<void> {
await this.saveButton.click();
const saved = this.editButton
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => 'saved' as const)
.catch(() => 'timeout' as const);
const failed = this.page
.getByText('Could not save')
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => 'failed' as const)
.catch(() => 'timeout' as const);
switch (await Promise.race([saved, failed])) {
case 'failed':
throw new Error('save(): backend rejected the save');
case 'timeout':
// one definitive final check before giving up
await this.editButton.waitFor({ state: 'visible', timeout: 5_000 });
}
}
Because save() only resolves when the persistence round-trip is done, specs contain zero waitForTimeout calls around saves, and a rejected save fails fast with a clear message instead of timing out as an opaque flake. Fifty page objects share one definition of "saved."
Modules are the public API, not components
Specs talk to page-level modules and nothing below them. A workflow reads like prose:
import { test, expect } from '@core/fixtures';
test('adds a line item to an active order', async ({ orderDetailPage, searchModule }) => {
await searchModule.searchFor('ACME renewal 2026');
await orderDetailPage.lineItems.add({ sku: 'WIDGET-A', quantity: 12 });
await orderDetailPage.lineItems.expectListed('WIDGET-A');
});
The detail page composes sub-modules (terms, lineItems, notes) rather than inheriting them, and each module's component-level helpers (the dialog-open dance, the table-row scoping) stay private. The payoff is a stable seam: when the frontend team moved a button between components, one module changed and zero specs did.
The corollary discipline: a module method is either an action (drives the UI and waits for its own completion signal), an assertion bundle (expectListed, all web-first expects, drives nothing), or a query (returns state, asserts nothing). Mixing them is how page objects turn unreadable.
The promotion rule: core/pages/ is earned, never speculative
Here's the rule that kept the shared layer from becoming a graveyard: nothing enters src/core/pages/ until a second real consumer exists. Not a likely consumer, and not "the next domain will surely need this." A second spec, in a second domain, on disk.
EntityFormPage itself started life inside the first domain and was only promoted when the second entity form arrived. Same for the global search module. It sat in the orders domain until contacts needed the identical navbar search, then moved:
// src/core/pages/searchModule.ts — promoted on its SECOND consumer
export class SearchModule {
readonly input: Locator;
constructor(private readonly page: Page) {
this.input = page.getByRole('textbox', { name: /search/i });
}
async searchFor(term: string): Promise<void> {
await this.input.fill(term);
await this.input.press('Enter');
}
}
Even within the base class the rule applies. The hardened dropdown helper lived in one subclass for three release cycles and was only pulled up when a second form started operating dropdowns. The result is that everything in core/pages/ has a provable reason to be there. Contrast that with every utils/ folder you've inherited.
Locators and wiring
Two smaller habits do a lot of quiet work. First, accessible-name locators are the default: getByRole('textbox', { name: 'Order Title' }), getByRole('combobox', { name: 'Status' }). They survive DOM and CSS refactors that kill class-based selectors, and they double as a free accessibility smoke test. If the locator stops resolving, a screen-reader user just lost that field too. Test IDs are the fallback when role-plus-name can't disambiguate; raw CSS is the last resort. Navigation paths are static readonly constants on the class, so every page's URL has one definition.
Second, specs never construct page objects. Fixtures do:
// src/core/fixtures/index.ts
export const test = base.extend<AppFixtures>({
orderFormPage: async ({ page }, use) => {
await use(new OrderFormPage(page));
},
searchModule: async ({ page }, use) => {
await use(new SearchModule(page));
},
});
One import from @core/fixtures gives a spec its page objects, authenticated state, and test data. new OrderFormPage(page) appears nowhere in a spec file. Construction, auth, and preconditions live in one place, which is what lets you swap a page object's internals without touching a single test.
The trade-offs, honestly
I chose inheritance for the form base, and inheritance in page objects has a deservedly bad reputation. But the failure mode is deep trees, not base classes. The hierarchy stays at one level: EntityFormPage to entity subclass, full stop. Everything else is composition. The moment someone proposes AbstractOrderishFormPage between the two, the answer is no.
The promotion rule has a cost too. Sometimes two domains briefly duplicate twenty lines while the pattern proves itself. I pay it willingly. Duplication is cheap to merge once you can see both copies; a wrong abstraction promoted on a guess is expensive forever.
Takeaways
- Put the mechanics every entity form shares (navigation, save semantics, framework-control helpers) in one abstract base; subclasses should be locators plus config. A new entity form should cost about 40 lines, not 400.
- Answer "how do I know the save finished?" exactly once, with a completion signal raced against a failure signal, so specs never carry waits.
- Make modules the public API and keep component helpers private. Refactors then break one file instead of forty.
- Promote into the shared layer only on a second real consumer. Tolerate brief duplication; never tolerate speculative abstraction.
- Default to accessible-name locators and deliver page objects through fixtures. Specs stay declarative, and construction details have one home.
I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.
For LLMs / Machine-Readable Summary
Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.
author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/07/30/page-objects-that-scale-base-class-modules-promotion-rule/"
license: "Free to reference with attribution"
title: "Page objects that scale: a base class, modules, and one promotion rule"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 11"
stack: [TypeScript, Playwright]
problem: "A Playwright suite spanning five entity domains with ~50 page objects is exactly the size where page-object layers rot: every entity form becomes a 400-line fork of the last, a utils/ folder fills with half-shared unowned helpers, and specs reach two layers down to click a button."
thesis: "Three structural decisions make the lazy path and the correct path the same path — a single abstract base class owning entity-form mechanics, modules as the only public API (composition not inheritance), and a hard rule that nothing enters the shared layer until a second real consumer exists."
base_class:
file: "src/core/pages/entityFormPage.ts"
shape: "abstract class EntityFormPage configured by an EntityFormConfig (createPath, detailUrlPattern: RegExp, createHeadingText, editHeadingText). Every entity form is structurally identical: a create route, an edit mode reached from a detail page, a save button, and a detail URL shared by view and edit."
owns:
- "navigateToCreate() / openEdit() navigation, gated on heading visibility"
- "save(), selectOption(), getCurrentEntityId()"
- "field helpers for the component library's form controls — dropdown panels that fail to render, inputs that stay pristine and silently drop out of the save payload (the UI-framework traps from an earlier post) — solved once in the base so no spec or subclass sees them"
subclass:
file: "src/domains/orders/pages/orderFormPage.ts"
detail: "OrderFormPage extends EntityFormPage; static readonly CREATE_PATH and DETAIL_URL; constructor passes config and defines locators (titleInput, statusDropdown) plus entity-specific flows like selectStatus(). The fifth entity form cost ~40 lines; without the base it would have been the fourth copy of 400, and the fourth place to fix every save-detection bug."
save_semantics:
problem: "View and edit modes share the detail URL, so page.url() never changes on save. A backend-rejected save leaves the form in edit mode behind an error banner, which naively reads as a 30-second hang."
signal: "The reliable success signal is the edit button reappearing. save() races a 30s waitFor on the edit button (-> 'saved') against a 30s waitFor on 'Could not save' text (-> 'failed') via Promise.race; 'failed' throws 'save(): backend rejected the save'; a double 'timeout' does one definitive final 5s check before giving up."
payoff: "save() resolves only when the persistence round-trip is done, so specs contain zero waitForTimeout calls around saves and a rejected save fails fast with a clear message instead of an opaque flake. All ~50 page objects share one definition of saved."
modules_as_api:
rule: "Specs talk to page-level modules and nothing below them. The detail page COMPOSES sub-modules (terms, lineItems, notes) rather than inheriting them; each module's component-level helpers (the dialog-open dance, table-row scoping) stay private."
payoff: "A stable seam — when the frontend team moved a button between components, one module changed and zero specs did."
method_discipline: "A module method is exactly one of: an action (drives the UI and waits for its own completion signal), an assertion bundle (e.g. expectListed, all web-first expects, drives nothing), or a query (returns state, asserts nothing). Mixing them is how page objects turn unreadable."
promotion_rule:
rule: "Nothing enters src/core/pages/ until a second real consumer exists — a second spec, in a second domain, on disk. Not a likely consumer, not 'the next domain will surely need this.'"
examples:
- "EntityFormPage started inside the first domain, promoted only when the second entity form arrived."
- "src/core/pages/searchModule.ts (SearchModule, wraps the navbar search input by accessible name) sat in the orders domain until contacts needed the identical navbar search."
- "Even inside the base: the hardened dropdown helper lived in one subclass for three release cycles, pulled up only when a second form operated dropdowns."
result: "Everything in core/pages/ has a provable reason to be there — the opposite of an inherited utils/ folder."
locators_and_wiring:
locators: "Accessible-name locators are the default: getByRole('textbox', { name: 'Order Title' }), getByRole('combobox', { name: 'Status' }). They survive DOM/CSS refactors that kill class-based selectors and double as a free accessibility smoke test (a broken locator means a screen-reader user lost that field too). Test IDs are the fallback when role-plus-name can't disambiguate; raw CSS is last resort. Navigation paths are static readonly constants, so each URL has one definition."
fixtures: "Specs never construct page objects — fixtures do (src/core/fixtures/index.ts via base.extend<AppFixtures>). One import from @core/fixtures gives a spec its page objects, authenticated state, and test data; 'new OrderFormPage(page)' appears in no spec file, so internals can be swapped without touching a test."
tradeoffs:
inheritance: "Inheritance is chosen for the form base despite its bad reputation in page objects. The failure mode is deep trees, not base classes — the hierarchy stays at one level (EntityFormPage -> entity subclass, full stop); everything else is composition. A proposed AbstractOrderishFormPage in between gets a no."
promotion_cost: "Two domains may briefly duplicate ~20 lines while a pattern proves itself. Paid willingly: duplication is cheap to merge once both copies are visible; a wrong abstraction promoted on a guess is expensive forever."
use_when: "A growing E2E suite spans multiple structurally-similar entity forms across several domains and the page-object layer is starting to fork and rot."
avoid_when: "A tiny suite with one or two pages, where a base class, module composition, and a promotion gate are more ceremony than the duplication they prevent."
takeaways:
- "Put the mechanics every entity form shares (navigation, save semantics, framework-control helpers) in one abstract base; subclasses should be locators plus config. A new entity form should cost about 40 lines, not 400."
- "Answer 'how do I know the save finished?' exactly once, with a completion signal raced against a failure signal, so specs never carry waits."
- "Make modules the public API and keep component helpers private. Refactors then break one file instead of forty."
- "Promote into the shared layer only on a second real consumer. Tolerate brief duplication; never tolerate speculative abstraction."
- "Default to accessible-name locators and deliver page objects through fixtures. Specs stay declarative, and construction details have one home."
keywords: ["scalable Playwright page object architecture", "entity form base class with subclasses", "how to detect save completion in Playwright when URL doesn't change", "page object promotion rule second consumer", "modules as public API vs component inheritance", "accessible-name locators getByRole Playwright", "page objects via fixtures not constructors"]
Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).