PRACTICAL GUIDE / playwright page component object
Playwright Page Component Objects for Shared Navigation, Tables, and Modals
Design Playwright page component objects for shared navigation, tables, and modals with semantic locators, fixtures, composition, and focused assertions.
In this guide12 sections
- Model UI Ownership, Not Screenshot Boundaries
- Root Every Component at a Stable Region
- Compose a Thin Page Shell
- Give Repeating Rows Their Own Identity
- Keep Modal Lifecycles Explicit
- Inject Components Through Focused Fixtures
- Place Assertions at the Right Layer
- Keep Components Stateless Across Renders
- Diagnose Architecture Failures
- Evaluate the Tradeoffs
- Operational Checklist
- Conclusion: Compose Around Stable Regions
What you will learn
- Model UI Ownership, Not Screenshot Boundaries
- Root Every Component at a Stable Region
- Compose a Thin Page Shell
- Give Repeating Rows Their Own Identity
A single page object that owns the header, filters, table, toast, modal, and every possible workflow becomes a second application hidden inside the test suite. The durable alternative is a Playwright page component object: a small model with one stable UI root, semantic locators beneath that root, and methods that express what a user does with that region.
Composition matters most when the same navigation bar appears on twenty pages, the same confirmation dialog serves several destructive actions, or a table has enough behavior to deserve its own vocabulary. Tests gain a readable API without losing Playwright's live locators, strictness, traces, or web-first assertions.
Model UI Ownership, Not Screenshot Boundaries
The official Playwright page object guide describes page objects as higher-level APIs that capture selectors and reusable operations. A component object applies that idea to part of a page. Its boundary should follow ownership in the product: global navigation, an order grid, a billing panel, or a dialog with a defined lifecycle.
A useful component has three properties. It has a root that uniquely identifies the region. Its child locators are relative to that root. Its public methods use product terms such as openAccountMenu, rowForOrder, or confirmDeletion. It should not know unrelated routes, seed databases, or decide what an entire test must assert.
Animated field map
Composed Playwright Component Objects
A page shell wires shared UI regions to semantic locators, domain actions, and assertions that remain close to the scenario.
01 / page shell
Page Shell
Own route-level navigation and compose the regions present on the page.
02 / shared component
Shared Component
Model one navigation, table, panel, or modal boundary.
03 / semantic locators
Semantic Locators
Resolve controls by role, label, text, or an explicit test contract.
04 / domain action
Domain Action
Expose an operation whose name reflects the user's intent.
05 / focused assertion
Focused Assertion
Verify the scenario outcome without hiding broad expectations in helpers.
Root Every Component at a Stable Region
Passing a Locator root into a component is stronger than passing the whole Page and searching globally. The root makes accidental cross-component matches harder and lets the same class model repeated instances. A dialog object can be constructed from a named dialog; an order row object can be constructed from one filtered row.
import { expect, type Locator, type Page } from "@playwright/test";
export class TopNavigation {
constructor(private readonly root: Locator) {}
async openAccountMenu(): Promise<void> {
await this.root.getByRole("button", { name: "Account" }).click();
await expect(this.root.getByRole("menu", { name: "Account" }))
.toBeVisible();
}
async signOut(): Promise<void> {
await this.openAccountMenu();
await this.root.getByRole("menuitem", { name: "Sign out" }).click();
}
}
export function topNavigation(page: Page): TopNavigation {
return new TopNavigation(page.getByRole("navigation", { name: "Primary" }));
}The visibility assertion belongs inside openAccountMenu because an open menu is the method's promised result. A test that signs out should still assert the route, signed-out banner, or session behavior that matters to its scenario.
Compose a Thin Page Shell
A page shell owns route-level entry and exposes the components present on that route. It should remain small enough that a reader can see the composition at a glance. Avoid inheriting every page from a large base class; explicit component fields make dependencies and reuse clearer.
import { type Page } from "@playwright/test";
import { ConfirmModal } from "./components/confirm-modal";
import { OrdersTable } from "./components/orders-table";
import { TopNavigation } from "./components/top-navigation";
export class OrdersPage {
readonly navigation: TopNavigation;
readonly orders: OrdersTable;
readonly deleteDialog: ConfirmModal;
constructor(private readonly page: Page) {
this.navigation = new TopNavigation(
page.getByRole("navigation", { name: "Primary" }),
);
this.orders = new OrdersTable(page.getByRole("grid", { name: "Orders" }));
this.deleteDialog = new ConfirmModal(
page.getByRole("dialog", { name: "Delete order" }),
);
}
async goto(): Promise<void> {
await this.page.goto("/orders");
}
}This shell does not proxy every method. Tests can call ordersPage.orders.openActionsFor(...) directly, which keeps ownership visible. Add a route-level workflow only when it genuinely coordinates multiple components and appears across enough tests to justify the abstraction.
Give Repeating Rows Their Own Identity
Tables are a common source of component-object overreach. Do not cache all row elements in the constructor or model rows by numeric position. Return a row object whose root is a live locator filtered by an immutable key. Every later action will resolve against the current DOM, including after sorting or virtualization.
import { expect, type Locator } from "@playwright/test";
class OrderRow {
constructor(private readonly root: Locator) {}
async expectStatus(status: string): Promise<void> {
await expect(this.root.getByRole("gridcell", { name: status, exact: true }))
.toBeVisible();
}
async requestDeletion(): Promise<void> {
await this.root.getByRole("button", { name: "Order actions" }).click();
await this.root.getByRole("menuitem", { name: "Delete" }).click();
}
}
export class OrdersTable {
constructor(private readonly root: Locator) {}
row(orderId: string): OrderRow {
const matchingRow = this.root.getByRole("row").filter({
has: this.root.getByRole("gridcell", { name: orderId, exact: true }),
});
return new OrderRow(matchingRow);
}
async expectOneRow(orderId: string): Promise<void> {
const row = this.root.getByRole("row").filter({ hasText: orderId });
await expect(row).toHaveCount(1);
}
}If the grid does not expose usable roles, introduce a deliberate test id at the row key. Hiding a brittle generated CSS selector inside a class centralizes the breakage but does not make the contract stable.
Keep Modal Lifecycles Explicit
Modal actions need two boundaries: prove the correct dialog is active, then operate inside it. A global getByRole("button", { name: "Delete" }) can match a control behind the overlay or a second dialog. Rooting by role and accessible name prevents that ambiguity.
import { expect, type Locator } from "@playwright/test";
export class ConfirmModal {
constructor(private readonly root: Locator) {}
async confirmFor(subject: string): Promise<void> {
await expect(this.root).toBeVisible();
await expect(this.root).toContainText(subject);
await this.root.getByRole("button", { name: "Confirm delete" }).click();
await expect(this.root).toBeHidden();
}
async cancel(): Promise<void> {
await this.root.getByRole("button", { name: "Cancel" }).click();
await expect(this.root).toBeHidden();
}
}Checking the subject guards against confirming the wrong destructive operation. The test should then verify the persistent outcome, such as the order disappearing from the table or an audit entry being created.
Inject Components Through Focused Fixtures
A fixture can create a page shell or shared component without hiding the scenario's navigation. Keep construction cheap and deterministic. If a fixture also logs in, seeds data, and navigates, its name and scope should make those side effects obvious.
import { test as base } from "@playwright/test";
import { OrdersPage } from "./orders-page";
type UiFixtures = {
ordersPage: OrdersPage;
};
export const test = base.extend<UiFixtures>({
ordersPage: async ({ page }, use) => {
await use(new OrdersPage(page));
},
});
export { expect } from "@playwright/test";The test remains direct: call goto, identify the order row, request deletion, confirm the named order, and assert the table result. The fixture removes constructor repetition while preserving the sequence in the report.
Place Assertions at the Right Layer
Component methods may assert their immediate contract: a menu opened, a dialog closed, or the requested row is unique. Broad business outcomes belong in the spec. Otherwise a method named submitRefund may silently assert five unrelated dashboard widgets and make a failure difficult to interpret.
Expose locators selectively when tests need varied assertions. A public readonly locator for a stable component output can be appropriate. Avoid exposing every internal locator, because callers will bypass domain methods and couple themselves to markup. A small observation method such as totalText() may be clearer when the raw element has no reusable meaning.
Keep Components Stateless Across Renders
A component object should retain locator recipes and immutable configuration, not a private copy of what the UI looked like earlier. Do not set this.isOpen = true after clicking a menu or cache the text of a selected row for later assertions. Another action, websocket update, route transition, or React render can invalidate that memory while the object continues to claim it is current.
Ask the page whenever state matters. A method such as isExpanded() may read aria-expanded, and selectedOrderId() may read the selected row at the moment of use. If a workflow needs to compare before and after values, keep those snapshots in the test so the temporal assertion remains visible. Stateless component objects can be reused safely after rerenders and make traces easier to reconcile with source.
Diagnose Architecture Failures
If one change touches many component classes, their roots overlap or the product boundary was split incorrectly. If a class needs conditionals for several unrelated pages, extract smaller components or pass a more precise root. If tests constantly reach into private fields, the public API is missing an important operation or observation.
Strictness errors inside a component usually reveal an insufficient root or missing accessible name. Detached-element errors suggest an ElementHandle or DOM value was cached instead of a locator. Slow tests may indicate that constructors or fixtures perform hidden navigation. Trace steps should make the component action understandable without opening its source.
Evaluate the Tradeoffs
Component objects add files and names, so they are not automatically worthwhile for a one-off control. Their value rises with stable ownership, repeated behavior, and multiple instances that benefit from rooted locators. Function helpers can be better for stateless operations; classes work well when several methods share a root and vocabulary.
Assertions inside components improve local contracts but can conceal scenario intent when overused. Fixture injection standardizes construction but can hide side effects if it performs too much. Prefer the smallest structure that makes ownership and failure evidence clearer.
Operational Checklist
- Choose a component boundary that product developers and users can name.
- Require one stable root locator for every component instance.
- Resolve child controls relative to that root.
- Use roles and accessible names before structural selectors.
- Model repeated records by immutable identity, not row index.
- Keep route navigation in a thin page shell.
- Assert immediate action contracts inside the component only when useful.
- Leave scenario outcomes visible in the test.
- Use fixtures for construction without hiding costly setup.
- Review traces to ensure method names and failures remain understandable.
Conclusion: Compose Around Stable Regions
Start with the shared regions that already cause duplication: primary navigation, one complex table, and one modal family. Give each a unique root and a short domain API, compose them in a thin page shell, and keep the final business assertion in the spec. That structure captures reuse without turning the automation layer into an opaque replica of the application.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Playwright documentation
Microsoft
Canonical API, locator, fixture, browser, and test-runner behavior.
- 02Playwright best practices
Microsoft
Official guidance for resilient tests, isolation, and user-facing locators.
- 03
FAQ / QUICK ANSWERS
Questions testers ask
What is a page component object in Playwright?
It is a small model for a reusable UI region such as navigation, a table, or a modal. The object owns that region's root locator and exposes actions or observations in domain language.
Should component objects contain Playwright assertions?
Use focused assertions when they are part of the component's action contract, such as confirming that a modal opened before clicking its destructive button. Keep scenario-specific outcomes in the test so failures still explain the business expectation.
How should a modal object be located?
Root it at a unique dialog locator, usually by role and accessible name, then locate buttons and content within that root. This prevents similarly named controls behind the modal from matching.
Can a page object compose multiple component objects?
Yes. A thin page shell can create navigation, table, filter, and modal components that share the same Page while retaining separate ownership boundaries.
When is a component object too small?
A wrapper that only renames one obvious locator without adding reuse, scoping, or domain meaning usually adds indirection. Extract a component when it has a stable root and meaningful behavior used across scenarios or pages.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Locators Guide: Find Elements Reliably
Playwright locators guide for stable UI tests with role selectors, filters, assertions, strict mode, debugging, and flaky selector fixes in CI.
GUIDE 02
Playwright Fixtures Explained: Test Setup That Scales
Playwright fixtures explained with test scope, worker scope, custom setup, cleanup, auth state, examples, reporting, and mistakes to avoid in CI.
GUIDE 03
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.
GUIDE 04
Strict TypeScript Patterns for Playwright Fixtures, Models, and Test Data
Apply strict TypeScript to Playwright fixtures, models, environment data, and case tables with compiler checks, runtime validation, and safe narrowing.