Incremental Adoption
The library is designed for staged adoption. You can start with one page object and one accessor, leave the rest of the suite untouched, and add structure where it pays back.
Step 1: replace one chain with an accessor
Section titled “Step 1: replace one chain with an accessor”Pick the most-duplicated locator chain in your suite. Wrap it in a class:
// Beforetest("apply promo", async ({ page }) => { await page.getByTestId("CheckoutPage").getByTestId("PromoCodeInput").fill("SAVE20");});
// After@RootSelector("CheckoutPage")class CheckoutPage { constructor(readonly page: Page) {}
@Selector("PromoCodeInput") accessor PromoCodeInput!: Locator;}
test("apply promo", async ({ page }) => { const checkout = new CheckoutPage(page); await checkout.PromoCodeInput.fill("SAVE20");});That’s it. No fixtures, no inheritance, no migration of other tests.
Step 2: extract a control when a selector repeats
Section titled “Step 2: extract a control when a selector repeats”If the same PromoCodeInput accessor appears across many specs and you keep writing the same .fill().expect().toHaveValue() pattern, extract it:
class InputControl { constructor(readonly locator: Locator) {}
async fillAndVerify(value: string) { await this.locator.fill(value); await expect(this.locator).toHaveValue(value); }}
@RootSelector("CheckoutPage")class CheckoutPage { constructor(readonly page: Page) {}
@Selector("PromoCodeInput", InputControl) accessor PromoCode!: InputControl;}The control is reusable in every other page object that has a similar input.
Step 3: adopt PageObject where helpers add value
Section titled “Step 3: adopt PageObject where helpers add value”If you find yourself writing wait helpers or .expect() chains repeatedly, switch the accessor to PageObject:
@RootSelector("CheckoutPage")class CheckoutPage extends RootPageObject { @Selector("PromoCodeInput") accessor PromoCode = new PageObject();}
// Capture the accessor in a variable — the chain rebuilds lazily on every use.const promo = checkout.PromoCode;
await promo.waitVisible();await promo.$.fill("SAVE20");await promo.expect({ soft: true }).toHaveValue("SAVE20");Switching one accessor does not require changing others. Mix styles in the same class.
Why this works
Section titled “Why this works”- Decorators are scoped per accessor. Changing one accessor does not affect siblings.
Locator, custom controls, andPageObjectcoexist. No “you must extend X” rule.- Fixtures are optional. Manual
new CheckoutPage(page)keeps existing test files untouched.
Don’t refactor too early
Section titled “Don’t refactor too early”Three similar .fill() calls is not duplication. Five similar await expect(...).toHaveValue(...) chains across pages is the threshold where extracting a control pays back. Until then, the cost of the abstraction exceeds its value.
See also
Section titled “See also”- Choosing a Style — when to use each pattern.
- Plain Classes, Custom Controls, Built-In POM — the three styles.