Choosing a Style
Start here
Section titled “Start here”A plain class with @RootSelector(...) and raw Locator accessors covers most pages:
import type { Locator, Page } from "@playwright/test";import { RootSelector, Selector } from "playwright-page-object";
@RootSelector("CheckoutPage")class CheckoutPage { constructor(readonly page: Page) {}
@Selector("PromoCodeInput") accessor PromoCodeInput!: Locator;}That’s the whole pattern. No inheritance, no fixtures, no extra abstractions. If you can use this, use it — and skip the rest of this page until you hit a case that needs something else.
Depart from the default when…
Section titled “Depart from the default when…”| Situation | What to do | Why |
|---|---|---|
The page has no container data-testid | Drop @RootSelector — keep the plain class | Chains from page.locator("body") automatically |
| A UI section repeats across pages | Make it a fragment | Constructor takes locator: Locator; nested decorators chain from it |
| You already have a control library | Pass it as the second arg to @Selector(...) | The decorator wraps the resolved locator in your class |
You want waitVisible(), .expect(), filter chains | Use PageObject / ListPageObject | Built-in wait helpers and assertions |
Each row is a small change to the default, not a different framework. Mix freely.
Mixing in the same class
Section titled “Mixing in the same class”@RootSelector("CheckoutPage")class CheckoutPage extends RootPageObject { @Selector("PromoCodeInput") accessor PromoCode!: Locator; // raw
@SelectorByRole("button", { name: "Apply" }, ButtonControl) accessor ApplyButton!: ButtonControl; // custom control
@ListSelector("CartItem_") accessor Items = new ListPageObject(CartItemControl); // built-in POM}The decorator decides what shape to produce based on how you declare the accessor:
accessor X!: Locator→ rawLocator@Selector("Id", MyClass)→ instance of a plain control classMyClass, constructed with the resolved locator.MyClassmust not extendPageObject— use the initializer form below for that case.accessor X = new PageObject()→PageObject(or subclass), cloned and bound to the parent’s scope on each access
Adoption path
Section titled “Adoption path”Pick the simplest thing that works. Evolve only when a real pain shows up:
- Duplicate locator chains across tests — wrap them in a plain class with
@RootSelectorand rawLocatoraccessors. - Same methods called on the same accessor (
fill,click,expect) — extract a custom control. - Same wait or assertion code repeating — switch that accessor to
PageObject.
Three similar .fill() calls is not duplication; five matching wait blocks across files is. See Incremental Adoption for a longer walkthrough.
- Plain Classes — the default pattern in depth.
- Fragments — reusable sub-trees.
- Custom Controls — integrating existing control classes.
- Built-In POM — when wait helpers and
.expect()pay back.