This document outlines a highly effective and scalable pattern for organizing Playwright end-to-end tests using a combination of the Page Object Model (POM) and Component Object Model (COM). This approach dramatically enhances code reusability, improves test readability, and ensures maintainability as your application and test suite grow.
In the realm of automated testing, particularly with frameworks like Playwright, managing selectors and test logic can become complex. Unstructured test suites often lead to brittle tests that are hard to understand and expensive to maintain. The POM and COM patterns address this by encapsulating the interactions and elements of your web application into dedicated objects.
- Page Object Model (POM): Represents a distinct page within your web application. It abstracts the underlying HTML structure, providing high-level methods to interact with page elements and query their state. This decouples your test logic from the UI implementation details.
- Component Object Model (COM): An extension of POM, where reusable UI components (e.g., a table row, a navigation bar, a form field group) are abstracted into their own objects. This promotes maximum reusability, allowing components to be defined once and utilized across various pages or multiple times on the same page.
Our implementation of these patterns uses TypeScript, ensuring strong type safety and leveraging a flexible FunctionTree structure for logically organizing actions and assertions.
The foundation of this pattern lies in a set of TypeScript types designed for clarity and extensibility:
Fn<returns, args>: A generic type that defines a function signature. This is used as the base for all action and assertion methods within our Page and Component Objects, promoting consistency.
export type Fn<
returns = unknown,
args extends readonly any[] = readonly any[],
> = (...args: args) => returns;LocatorConfigMap: A type alias for a record where keys are strings (typically descriptive names) and values are PlaywrightLocatorobjects. This map holds all the selectors pertinent to a page or component.
export type LocatorConfigMap = Record<string, Locator>;FunctionTree: This crucial recursive interface enables the hierarchical organization of methods. It allows for arbitrary nesting of functions and otherFunctionTreeobjects, making it ideal for grouping related actions and assertions.
export interface FunctionTree {
[key: string]: Fn | FunctionTree
}ComponentObject: This type defines the fundamental structure for any reusable UI component. It includes:page: Page: The PlaywrightPageinstance, providing the context for browser interactions.actions: FunctionTree: A structured collection of methods representing interactive behaviors (e.g., clicking, typing, selecting).assertions: FunctionTree: A structured collection of methods for verifying the state or appearance of the component.
export type ComponentObject = {
page: Page;
// locators are not exposed to discourage exposing implementation details
actions: FunctionTree;
assertions: FunctionTree;
};PageObject: ExtendsComponentObjectto include agotomethod. This method is specific to pages as it handles navigation to a particular URL, which is a primary function of a page.
export type PageObject = ComponentObject & {
goto: () => Promise<void>;
};PomFactory&ComponentFactory: These are function types that act as blueprints for creating instances ofPageObjectandComponentObject, respectively. They typically accept a PlaywrightPageinstance and any other parameters needed to initialize the object.
export type PomFactory = (page: Page) => PageObject;
export type ComponentFactory = (page: Page) => ComponentObject;Let's illustrate the COM pattern with createPipelineTableCellPOM, which represents a single, editable cell within a table (e.g., for a job application's company name, position, or status).
// Located at tests/e2e/POMs/pipelineTable/pipelineTableCellPOM.ts
export function createPipelineTableCellPOM(
page: Page,
applicationId: string
) {
// ... implementation details ...
}This factory function takes the Playwright page and an applicationId (to uniquely identify the cell within the table) as arguments.
The locators object defines Playwright selectors for all interactive or verifiable elements within the table cell. data-testid attributes combined with the applicationId ensure robust and unique identification.
// ... existing code ...
export function createPipelineTableCellPOM(
page: Page,
applicationId: string
) {
const locators = {
companyCell: page.locator(`[data-testid="company-cell-${applicationId}"]`),
positionCell: page.locator(
`[data-testid="position-cell-${applicationId}"]`,
),
statusCell: page.locator(`[data-testid="status-cell-${applicationId}"]`),
statusBadge: page.locator(`[data-testid="status-badge-${applicationId}"]`),
interestCell: page.locator(
`[data-testid="interest-cell-${applicationId}"]`,
),
nextEventCell: page.locator(
`[data-testid="next-event-cell-${applicationId}"]`,
),
nextEventDate: page.locator(
`[data-testid="next-event-date-${applicationId}"]`,
),
actionsCell: page.locator(`[data-testid="actions-cell-${applicationId}"]`),
// Edit mode elements (row-level)
companyInput: page.locator(
`[data-testid="edit-input-company-${applicationId}"]`,
),
positionInput: page.locator(
`[data-testid="edit-input-position-${applicationId}"]`,
),
statusSelect: page.locator(
`[data-testid="edit-select-status-${applicationId}"]`,
),
interestSelect: page.locator(
`[data-testid="edit-select-interest-${applicationId}"]`,
),
nextEventInput: page.locator(
`[data-testid="edit-input-nextEvent-${applicationId}"]`,
),
} as const satisfies Record<string, Locator>
return {
page,
actions: {
// ... existing code ...
}The actions object within the component defines all the ways a test can interact with the table cell. The FunctionTree structure allows for clear, nested organization of these actions:
- Direct Actions: Simple, single-step interactions (e.g.,
clickCompanyCell). - Edit Mode Actions: Grouped under an
editobject, these methods handle the multi-step process of modifying a field (e.g., clicking the cell, filling an input, pressing Enter). - Generic Cell Actions: The
cellobject demonstrates how to create polymorphic actions that operate on different parts of the component based on a parameter (e.g.,cell.click('company')).
// ... existing code ...
actions: {
clickCompanyCell: async () => {
await locators.companyCell.click();
},
fillCompanyInput: async (text: string) => {
await locators.companyInput.fill(text);
},
edit: {
company: async (newValue: string) => {
await locators.companyCell.click(); // Enter edit mode
await locators.companyInput.fill(newValue);
await locators.companyInput.press('Enter'); // Save changes
},
position: async (newValue: string) => {
await locators.positionCell.click();
await locators.positionInput.fill(newValue);
await locators.positionInput.press('Enter');
},
status: async (newStatus: string) => {
await locators.statusCell.click();
await locators.statusSelect.selectOption(newStatus);
},
},
cell: {
click: async (cellType: 'company' | 'position' | 'status') => {
switch (cellType) {
case 'company':
await locators.companyCell.click();
break;
case 'position':
await locators.positionCell.click();
break;
case 'status':
await locators.statusCell.click();
break;
}
},
hover: async (cellType: 'company' | 'position') => {
switch (cellType) {
case 'company':
await locators.companyCell.hover();
break;
case 'position':
await locators.positionCell.hover();
break;
}
}
}
},
assertions: {
// ... existing code ...The assertions object mirrors the actions in its FunctionTree structure, providing methods to verify the state of the table cell:
- Direct Assertions: Simple checks like
companyCellIsVisibleorstatusBadgeHasText. - Component State Assertions: Methods like
editFormIsVisibleconfirm whether the component is in a specific state. - Nested Assertions: Assertions specific to individual fields (e.g.,
company.hasText,input.company.isVisible) are grouped for logical clarity.
// ... existing code ...
assertions: {
companyCellIsVisible: async () => {
await expect(locators.companyCell).toBeVisible();
},
companyCellHasText: async (expectedText: string) => {
await expect(locators.companyCell).toHaveText(expectedText);
},
statusBadgeHasText: async (expectedText: string) => {
await expect(locators.statusBadge).toHaveText(expectedText);
},
editFormIsVisible: async () => {
await expect(locators.companyInput).toBeVisible(); // Assuming any input visible means edit form is visible
},
company: {
hasText: async (expectedText: string) => {
await expect(locators.companyCell).toHaveText(expectedText);
},
isVisible: async () => {
await expect(locators.companyCell).toBeVisible();
},
},
status: {
hasText: async (expectedText: string) => {
await expect(locators.statusBadge).toHaveText(expectedText);
},
},
input: {
company: {
isVisible: async () => {
await expect(locators.companyInput).toBeVisible();
},
hasValue: async (expectedValue: string) => {
await expect(locators.companyInput).toHaveValue(expectedValue);
}
}
}
}
} as const satisfies ComponentObject
}
export const createPipelineTableCell = createPipelineTableCellPOM satisfies ComponentFactory
export type PipelineTableCell = ReturnType<typeof createPipelineTableCell>
// ... existing code ...The createApplicationDetailsPagePOM function demonstrates how to construct a PageObject and, critically, how to integrate and leverage existing ComponentObject instances within it. This page represents the detailed view of a single job application.
// Located at tests/e2e/POMs/applicationDetailsPagePOM.ts (inferred from the directory structure)
export function createApplicationDetailsPagePOM(
page: Page,
applicationId: string,
) {
// ... implementation details ...
}This page object defines its own unique locators (e.g., for the page title and an edit button) and implements the goto method characteristic of a PageObject.
// ... existing code ...
export function createApplicationDetailsPagePOM(
page: Page,
applicationId: string,
) {
const locators = {
pageTitle: page.locator('[data-testid="application-details-title"]'),
editButton: page.locator('[data-testid="edit-application-button"]'),
// ... other locators specific to the application details page
};
// Create an instance of the PipelineTableCell component for this specific application
const applicationTableCell: PipelineTableCell = createPipelineTableCell(page, applicationId);
return {
page,
goto: async () => {
await page.goto(`/applications/${applicationId}`);
},
actions: {
// ... existing code ...The strength of the COM pattern is highlighted here: the PipelineTableCell component, defined previously, is instantiated specifically for the applicationId relevant to this page.
// ... existing code ...
const locators = {
pageTitle: page.locator('[data-testid="application-details-title"]'),
editButton: page.locator('[data-testid="edit-application-button"]'),
// ... other locators specific to the application details page
};
// Create an instance of the PipelineTableCell component for this specific application
const applicationTableCell: PipelineTableCell = createPipelineTableCell(page, applicationId);
return {
page,
goto: async () => {
await page.goto(`/applications/${applicationId}`);
},
actions: {
// ... existing code ...The actions and assertions of the ApplicationDetailsPage seamlessly integrate both page-specific logic and methods inherited from the applicationTableCell component. Notice how component methods are logically namespaced under cell for clarity, e.g., applicationDetailsPage.actions.cell.editCompany().
// ... existing code ...
actions: {
clickEditButton: async () => {
await locators.editButton.click();
},
// Actions from the PipelineTableCell component, namespaced
cell: {
clickCompany: async () => {
await applicationTableCell.actions.clickCompanyCell();
},
editCompany: async (newValue: string) => {
await applicationTableCell.actions.edit.company(newValue);
},
editPosition: async (newValue: string) => {
await applicationTableCell.actions.edit.position(newValue);
},
},
},
assertions: {
pageTitleIsVisible: async () => {
await expect(locators.pageTitle).toBeVisible();
},
pageTitleContainsText: async (expectedText: string) => {
await expect(locators.pageTitle).toContainText(expectedText);
},
// Assertions from the PipelineTableCell component, namespaced
cell: {
companyHasText: async (expectedText: string) => {
await applicationTableCell.assertions.company.hasText(expectedText);
},
statusHasText: async (expectedText: string) => {
await applicationTableCell.assertions.status.hasText(expectedText);
},
companyInputIsVisible: async () => {
await applicationTableCell.assertions.input.company.isVisible();
}
},
},
} as const satisfies PageObject;
}
export const createApplicationDetailsPage =
createApplicationDetailsPagePOM satisfies PomFactory;
export type ApplicationDetailsPageObject = ReturnType<typeof createApplicationDetailsPage>;Adopting this Page and Component Object Model pattern provides substantial advantages for your Playwright test suite:
- Reusability: Components like
createPipelineTableCellPOMcan be instantiated and reused across various pages or multiple times within a single page, minimizing code duplication and maximizing efficiency. - Maintainability: Changes in the UI (e.g., a modified
data-testid) only require updates in a single, centralized location within the corresponding Page or Component Object. All tests relying on that element automatically benefit from the update. - Readability: Tests become significantly easier to comprehend. They interact with high-level, business-oriented actions and assertions (e.g.,
applicationDetailsPage.actions.cell.editCompany("New Company Name")) rather than low-level Playwright API calls and raw selectors. - Scalability: As your application and test suite expand, this structured approach effectively manages complexity. New pages or components can be integrated seamlessly without causing cascading effects or significant refactoring of existing tests.
- Clear Separation of Concerns: This pattern distinctly separates test logic from UI implementation details. Tests focus on what should be done and what should be verified, while the Page/Component Objects handle the how.
- Type Safety: Leveraging TypeScript throughout the implementation provides an exceptional developer experience, offering intelligent auto-completion, early detection of errors, and clear contracts for interacting with Page and Component Objects.
By embracing this robust and well-organized pattern, you can build a Playwright test suite that is not only highly effective but also resilient, easy to maintain, and capable of scaling with your project's demands.