skip to content

An end-to-end suite has one page object per URL, but the application is built from reusable components — a cart widget, a data grid, a date picker — that appear on many of those pages, and the page objects have started duplicating each other. When would you scope helpers to a component instead of to a page, and what does that change?

level: seniorimportance: should knowfreq 40%

answer

  1. the unit of reuse changed
  2. widget on six pages, six copies
  3. parameterise by root, not by page
  4. page object becomes a map
  5. similar-looking is not the same component

basics

~20 s

Scope the helper to the widget when the same widget appears on several pages: one owner, reused wherever it renders, constructed from the container element it lives in. Page-shaped objects duplicate that widget's knowledge on every page that hosts it.

solid answer

~50 s

Page objects assume the page is the unit of reuse, which was true for server-rendered multi-page apps and is much less true for a component-built UI. When a data grid appears on six pages, six page objects each learn how to sort it, and they drift. The fix is to make the component the unit: a helper that takes the container element it operates within and exposes that widget's actions, so the same helper works everywhere the widget renders. The page-level object then shrinks to a composition — it knows which components this page hosts and how to reach them — instead of owning their internals. Two practical benefits: a change to the widget is one edit, and the helper works on any surface, including a component-level harness. The cost is that you have to be honest about what is genuinely the same component; two grids that merely look alike will fight over one helper, and forcing them together is worse than the duplication.

code

typescript · 21 lines
typescript
import { type Locator, type Page } from '@playwright/test';

class DataGrid {
  constructor(private readonly root: Locator) {}

  rows(): Locator {
    return this.root.getByRole('row');
  }

  async sortBy(column: string): Promise<void> {
    await this.root.getByRole('columnheader', { name: column }).click();
  }
}

class OrdersPage {
  constructor(private readonly page: Page) {}

  get grid(): DataGrid {
    return new DataGrid(this.page.getByRole('region', { name: 'Orders' }));
  }
}

go deeper

for a junior

Know that when the same widget appears on many pages, describing it once and reusing that description beats copying the same steps into every page's helper.

for a middle

Explain the mechanics: a helper parameterised by a root element works for any instance, while a page-wide helper becomes ambiguous the moment the widget renders twice on one screen.

for a senior

Show the judgment about when to consolidate — number of hosts, real behaviour, ownership — and be willing to argue that two look-alike components should keep separate helpers rather than share a flag-ridden one.

for a principal

Frame it as aligning the test abstraction with the application's unit of change, and decide who owns and maintains a shared helper layer so it evolves with the components rather than rotting beside them.

## Why page-shaped objects drift in a component app The page object pattern predates component frameworks. Its unit is the *page*, because in a multi-page application the page was the thing that existed, changed and was navigated to. In an application assembled from components, the page is often just a composition — an arrangement of widgets that each have their own owner, their own tests and their own change cadence. When the abstraction's unit disagrees with the application's unit, knowledge gets copied. A sortable grid on the orders page, the customers page and the reports page ends up described three times, in three page objects, by three people. Six months later the three descriptions differ: one waits for a spinner, one does not; one sorts by clicking the header, one by opening a menu that was added later. Nobody chose those differences. That is **drift**, and it is the concrete failure mode this leaf is about. ## Making the component the unit A component-scoped helper is parameterised by *where the component is*, not by which page it happens to be on. It receives a container — a root element for the widget — and every lookup it makes is relative to that root: ```ts class DataGrid { constructor(private readonly root: Locator) {} rows() { return this.root.getByRole('row'); } async sortBy(column: string) { await this.root.getByRole('columnheader', { name: column }).click(); } } ``` The same class now works on the orders page, the customers page, in a modal, and twice on the same screen if two grids are rendered side by side. Two grids in one page object would have needed two sets of methods or a disambiguating parameter; two instances of a component helper need nothing but two roots. ## What the page-level object becomes It does not disappear; it changes job. It stops owning widget internals and becomes a **map of the screen**: navigation to it, the pieces it hosts, and any behaviour that genuinely belongs to the page as a whole (a page-level empty state, a wizard's step order). Instead of forty methods, it exposes a handful of components: ```ts class OrdersPage { constructor(private readonly page: Page) {} get grid() { return new DataGrid(this.page.getByRole('region', { name: 'Orders' })); } } ``` That is a small file, and each part of it points at an owner. ## When it is worth it - **The widget appears on more than two surfaces.** One or two hosts rarely justifies the extra indirection. - **The widget has real behaviour** — sorting, pagination, multi-step interaction — rather than being a styled div. - **The widget is owned by a team or a library.** Then the helper can live next to the component and change with it, which is the closest a test layer gets to being maintained by the people who cause the breakages. ## When it is not - **Superficial similarity.** Two components that look alike but are separate implementations will pull one helper in opposite directions, adding flags until it is worse than two honest copies. Sameness of markup is not sameness of component. - **A page-unique flow.** A one-off checkout wizard is a page-level concern; wrapping each of its panels as a "component" invents structure the app does not have. - **A suite too small to have drifted.** If the widget is touched by three tests, duplication has not cost anything yet. ## The wider point The underlying principle is that a test abstraction should mirror the unit of change in the application. Ask what a typical pull request touches: if PRs change a component and its usages, component-scoped helpers track them; if PRs replace whole screens, page objects track that better. The debate over page objects versus locator-first is at bottom this question — *what is the thing whose knowledge deserves an owner?* — and a component UI usually answers "the component", with the page reduced to a thin composition on top. ## Reuse beyond end-to-end A bonus of the component-scoped shape is that the helper depends only on a root element, not on a URL or a session. That makes it usable wherever the component is rendered — including an isolated component harness — so the same vocabulary describes the widget at more than one test level. That reuse is not free (the helper must not assume application-level context), but where it works it removes a second, divergent description of the same widget.

  • What breaks if a component helper is written against the whole page instead of a root element?
    It can only describe one instance. Render the widget twice on a screen and every lookup becomes ambiguous, so the helper grows index or disambiguation parameters. Taking a root also makes the helper portable to any surface that renders the component, including a modal or an isolated harness.
  • Two grids in the app look identical but are separate implementations. Should they share one helper?
    No. Forcing them together produces a helper full of flags for behaviour that only one of them has, and every change risks the other. Duplication between genuinely distinct components is cheaper than a shared abstraction that has to be true of both. Merge them only if the components themselves are merged.
  • Does the page object disappear entirely under this approach?
    No, it shrinks. It keeps navigation to the screen, page-level state such as an empty view, and a map of which components the page hosts and how to reach them. What it stops owning is the internals of widgets that exist elsewhere too, which is where the duplication came from.

Page objects describe an address; component helpers describe the appliance. When the same appliance is installed in six rooms, you want one manual for the appliance, not six room manuals that each re-explain it.

saying these in an interview costs you the question

  • One page object per URL is the standard, always
  • Any two components with similar markup should share a helper
  • Component helpers are only for component tests, not e2e
  • A page object with forty methods just means the page is complex
  • Passing a root element is an unnecessary complication

context