Header, Modal, Cookie Banner: Extracting Components as Nested Objects
Three page objects each re-implement openMenu() and acceptCookies(). A fourth screen is started, the copy-paste begins again, and one of the three implementations is quietly outdated — the cookie banner changed its button label two sprints ago, and only one of the three objects got the memo. This is the component problem: parts of the UI are regions (header, footer, cookie banner, modal, data table) that recur across screens, and page objects should model them exactly the way the app does — as reusable pieces.
A component object owns one region's selectors and actions, nothing more:
// components/SiteHeader.ts
export class SiteHeader {
private nav = (p: Page) => p.getByRole('navigation', { name: 'Main' });
private userMenu = (p: Page) => p.getByTestId('user-menu');
async gotoSection(p: Page, section: string) {
await this.nav(p).getByRole('link', { name: section }).click();
}
async openUserMenu(p: Page) { await this.userMenu(p).click(); }
async userName(p: Page): Promise<string> {
return (await p.getByTestId('current-user').textContent()) ?? '';
}
}
// pages/AccountPage.ts — composed, not copy-pasted
export class AccountPage {
readonly header = new SiteHeader();
async goto(p: Page) { await p.goto('/account'); }
// account-specific actions...
}
// the test uses whichever layer it needs
test('admin sees the audit link in the nav', async ({ page }) => {
const account = new AccountPage();
await account.goto(page);
await account.header.gotoSection(page, 'Audit');
await expect(page).toHaveURL(/\/audit/);
});
The nesting is the point: account.header communicates "this is the shared header, reached through the account page" without duplicating a line. When the nav gains a fifth link, one file changes.
What earns component status? The test is: does this region appear on three or more screens or have non-trivial internal behavior (a modal with open/close/submit, a table with sort/paginate/filter)? Both conditions predict maintenance cost, which is what components reduce. A region unique to one screen stays a private part of that screen's object — extracting it prematurely just moves the code and adds a layer of indirection a reader must climb.
Composition beats inheritance for exactly the reason the classic literature says it does. The inheritance version — class AccountPage extends BasePageWithHeader — looks DRY until the day a screen has two headers (authenticated and anonymous variants), or the modal appears on some pages only, or the table differs from the list page's table. Now BasePageWithHeader forks into BasePageWithHeaderAndModal, BasePageWithHeaderAndTable, and the hierarchy becomes a taxonomy no one dares restructure. Composition has no such cliff: adding a region to one page is adding one field, and a page with three regions is three fields plus its own actions. The rule teams adopt: "is-a" relationships earn inheritance; "has-a" relationships get composition — and UI regions are always "has-a".
One implementation caution: construct components in the page's constructor (or as readonly fields), not lazily inside every method, so the object graph is inspectable in a debugger and stable across calls. And keep component methods free of cross-region assumptions: SiteHeader should not know the account page exists — the test decides that header and account content appear together. With components in place, the remaining question is construction: how pages get built with the right driver and config without new-sprinkle, which is the factory's job.
Factories: One Place That Knows How Pages Get Constructed
The moment page objects take constructor arguments — a WebDriver, a Page, a base URL, a config object — construction becomes a decision repeated in every test: does CheckoutPage take the driver plus a URL, or a context? Was it new CheckoutPage(driver, config) or two positional arguments in the other order? A factory collapses that decision into one signature, and gives the framework a single point of control for object wiring.
// factories/pages.ts — the ONE place that constructs pages
export class PageFactory {
constructor(
private readonly page: Page, // Playwright: the browser context handle
private readonly cfg: SuiteConfig, // baseURL, credentials, flags
) {}
login() { return new LoginPage(); }
header() { return new SiteHeader(); }
checkout() { return new CheckoutPage(); }
orderConfirm() { return new OrderConfirmationPage(); }
}
// a test consumes the factory, never constructors directly
test('guest checkout completes', async ({ page }) => {
const pages = new PageFactory(page, config);
await pages.login().goto(page);
await pages.login().signIn(page, guest.email, guest.password);
// ...
});
In Java/Selenium the factory carries the WebDriver and SuiteConfig, and each method returns a fully-wired object; in Python it is a class or a set of module functions. The construction rule that keeps factories honest: tests call factories, never new Page(...) directly — otherwise the factory becomes decorative and constructor drift resumes. A lint rule banning direct page construction outside the factory directory enforces this mechanically.
Factories are also the natural home for stateful setup. Some objects are expensive or stateful: an authenticated session (login once, reuse the cookie jar), a seeded workspace (created via API, torn down after the run), a browser context per worker. The factory (or a fixture wrapping it) owns that lifecycle so tests inherit ready objects:
// Playwright fixture flavour — test auto-receives an authenticated context
export const test = base.extend<{ authedPage: Page }>({
authedPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'auth.json' });
const p = await ctx.newPage();
await use(p); // test body runs here
await ctx.close(); // cleanup guaranteed
},
});
That is the bridge to Fixtures and Test Data Layers: factories construct objects, fixtures manage per-test lifecycles, and the two compose — a fixture often calls a factory under the hood.
How many factories? One PageFactory for a small suite; per-domain factories (CheckoutFactory, AdminFactory) when the object graph grows past a screenful — the same "small units" rule as components. And a naming convention matters more than structure at this scale: pages/AccountPage.ts, components/SiteHeader.ts, factories/pages.ts, with methods named after the object they return. A new engineer should be able to answer "where is the modal's close selector?" and "where do pages get constructed?" by reading the directory tree — that navigability is the payoff of consistent naming, discussed explicitly in the last chapter's convention table. One more factory responsibility worth claiming early: environment awareness. The factory receives SuiteConfig (base URL, credentials, feature flags) from the single config source described in Structuring an Automation Suite, so pointing the entire object graph at staging instead of local is a config change, not a constructor refactor across fifty test files. What composition and factories cannot fix is readability of the flows themselves: chaining four object calls across two screens still reads like a recipe of mechanics. That is the fluent layer's territory.
Where Inheritance Breaks, and Why Composition Wins in Practice
Inheritance promises economy — write the header interaction once in a base class, every page inherits it — and delivers it right up until the day the hierarchy's assumptions stop matching the product. Because they always stop matching. Walking through the failure modes concretely is the fastest way to internalize why the pattern community converged on composition for test frameworks.
Failure 1: the multiple-concern fork. BasePage grows a cookie banner helper because every page has one. Then the marketing pages get a newsletter modal, and admin pages get a confirmation dialog. Do you add both to BasePage (now every page carries modal code it does not use) or fork the hierarchy (AdminBasePage, MarketingBasePage), which must then re-share the cookie logic through a third level? Every UI region added this way multiplies base classes, and the combinatorics explode exactly when the product diversifies — which is precisely when the suite most needs to grow cheaply.
Failure 2: the fragile base class. A change to BasePage.signIn() silently changes behavior for forty descendant pages. The failure appears in a checkout test, whose reader opens CheckoutPage, sees no relevant code, and must traverse the hierarchy to find the cause. This is the textbook "fragile base class problem", and it is worse in test code than in production, because the blast radius of one base-class edit is a whole suite red at once.
Failure 3: constructor archaeology. Deep hierarchies pass constructor arguments up chains (super(driver, config, flags)), and adding a parameter to a base constructor touches every descendant's super(...) call — a mechanical edit multiplied by the hierarchy's width.
Composition answers each directly: regions are fields, so a screen with a header, a modal, and a table is three fields plus its own actions; changing the header affects only objects that contain it; constructors stay shallow because nothing is inherited. The table worth being able to produce in a design discussion:
| Situation | Inheritance | Composition |
|---|---|---|
| Every page truly shares one behavior (URL logging) | acceptable base | acceptable mixin/utility |
| Region appears on some screens | pollutes all descendants | field, present where it belongs |
| Region behavior changes | silent blast radius | one component file |
| Two region variants (auth/anonymous header) | base-class fork | swap the field |
| New engineer asks "where is X?" | traverse hierarchy | read the object's fields |
Two honest exceptions keep the answer credible. A true is-a with stable shared behavior — every "page" needs goto(url) with base-URL handling — can live in a small base or, better, a utility function; and some frameworks' own extension mechanisms (Playwright fixtures) are legitimately hierarchical. The principle is not "never inherit"; it is inherit only for genuine is-a relationships with contracts you expect to remain stable — and UI regions never qualify.
The suite now has screens, components, and factories with a clear construction story. What remains is the surface the test reads when it chains these pieces across a multi-step flow — the fluent API that makes a spec look like a sentence, explored next in Fluent Page APIs.
Quiz
1. Why compose a SiteHeader component object into pages instead of extending a BasePageWithHeader?
2. What earns component-object status?
3. What rule keeps a PageFactory from becoming decorative?
4. When is inheritance acceptable in page-object design?
Flashcards
Question
What is a component object and how do page objects use it?
Click to reveal answer
Answer
Model recurring UI regions (header, footer, cookie banner, modal, table) as standalone objects owning their own selectors and actions, exposed as readonly fields on pages: account.header.gotoSection(...). One file changes when the region changes; the nesting documents which regions a screen actually has.
Question
The rule for inheritance vs composition, and why UI regions never qualify for inheritance.
Click to reveal answer
Answer
is-a with a stable contract earns inheritance; has-a gets composition. UI regions are always has-a — inheritance forks the base taxonomy when variants appear (two headers, some-page-only modals), pollutes descendants, and creates fragile-base-class blast radius. Composition: presence is a field, variants swap, blast radius is one component file.
Question
What does a page factory centralize, and what rule keeps it honest?
Click to reveal answer
Answer
A factory (PageFactory) is the single place constructing page objects with the current driver/page handle, config, and base URL. Rule: tests call factories, never new Page(...) directly — enforced by lint — or constructor drift (argument order, wiring variants) resumes across the suite.
Question
How do factories and fixtures divide responsibility?
Click to reveal answer
Answer
Factories construct wired objects; fixtures manage per-test lifecycles and often call factories under the hood — e.g. a Playwright fixture creating a context with storageState 'auth.json', handing the test an authenticated page, and closing the context in teardown. Expensive stateful setup (sessions, seeded workspaces) lives at this layer.
Question
What naming/directory convention makes a framework navigable for newcomers?
Click to reveal answer
Answer
pages/AccountPage.ts, components/SiteHeader.ts, factories/pages.ts, methods named after the object they return; a new engineer answers 'where is the modal's close selector?' and 'where do pages get constructed?' by reading the directory tree. Navigability is the payoff of naming consistency at scale.
Revision Notes
Key Takeaways
- 1. Recurring regions become component objects composed as fields — one file per region, presence documented by the object's shape
- 2. Inheritance forks and silently blast-radiuses exactly when the product diversifies; composition keeps presence, variants, and repair costs per-screen
- 3. Factories centralize construction so tests never debate constructor shape; a lint ban on direct new keeps the factory honest
- 4. Fixtures wrap factories for lifecycle: authenticated contexts and seeded workspaces arrive ready and are torn down automatically
- 5. Directory and method naming are a first-class design decision: navigability is what lets a new engineer contribute on day one
Interview Tips
- • "Header logic is duplicated across your page objects — fix it?" — component object composed as a field; then explain why not extends (has-a, variant swapping, blast radius)
- • "When would you use inheritance in a test framework?" — only true is-a with stable contracts (universal goto); name the failure modes you are avoiding: fork, fragile base class, constructor archaeology
- • "What does your page factory do?" — single construction point with driver/config wiring; state the enforcement rule (tests never call constructors) and the fixture relationship (lifecycle vs construction)
- • "How does a new engineer find the modal's close selector?" — component directory + naming convention; answering with the tree, not a wiki page, shows the convention is real
Cheat Sheet
Composition & Page Factories
Component object = one UI region:
class SiteHeader {
async gotoSection(p: Page, section: string) { ... }
}
class AccountPage {
readonly header = new SiteHeader(); // has-a, per-screen presence
}
Extraction bar: region on 3+ screens OR non-trivial internal behavior (modal, sortable table).
Inheritance failure modes: multiple-concern fork (BasePageWithHeaderAndModal...), fragile base class (one base edit → suite-wide red, cause hidden from the failing test's file), constructor archaeology (super(...) parameter edits × hierarchy width).
| Situation | Answer |
|---|---|
| region on some screens | field, not base class |
| variant (auth/anonymous header) | swap the field |
| true is-a, stable contract (goto(url)) | small base/utility is fine |
Factory:
class PageFactory {
constructor(private page: Page, private cfg: SuiteConfig) {}
login() { return new LoginPage(); }
}
// rule: tests call factories only — lint bans direct new Page(...)
Factory ↔ fixture split: factories construct objects; fixtures own per-test lifecycle (auth context via storageState, teardown guaranteed). Bridge to test-data layer.
Navigability: pages/ · components/ · factories/ directories; methods named after returned objects; newcomers navigate by tree.