I currently work on UI-level e2e test automation of a web app, where entire flow is concentrated in one page. To avoid huge single page object size, I've split it into smaller components, listed out and presented in the pic below:
- Table (red) class with TableRow elements
- Toolbar (green) class with ToolbarBtn elements
- List (yellow) with ListElement elements, that in turn contain ListElementStatusBox element

An example business flow goes as follows:
Click TableRow[i] -> Click ListElement[i] -> Click ToolbarBtn1 ->ListElementStatusBox[i] changes status to "Status 1"
The code for a decomposed page object and its components is presented below: list.component.ts
import { ElementStatus } from "../enums/element.status.enum";
export class List {
public readonly listElement: Locator;
constructor(page: Page) {
this.listElement = page.getByTestId(`list-element`);
}
public async clickListElementWithTitle(title: string): Promise<void> {
await this.listElement.getByText(title).click();
}
public async getElementStatus(elementTitle: string): Promise<ElementStatus> {
const status = await this.listElement.getByText(elementTitle).locator(`list-element-status-box`);
return status;
}
}
table.component.ts
export class Table {
private readonly _tableRow: Locator;
constructor(page: Page) {
this._tableRow = page.getByTestId(`left-table-row`);
}
public async clickTableRowWithTitle(title: string): Promise<void> {
await this._tableRow.getByText(title).click();
}
}
toolbar.component.ts
export class Toolbar {
private readonly _toolbarBtn1: Locator;
private readonly _toolbarBtn2: Locator;
private readonly _toolbarBtn3: Locator;
constructor(page: Page) {
this._toolbarBtn1 = page.getByTestId(`toolbar-button-1`);
this._toolbarBtn2 = page.getByTestId(`toolbar-button-2`);
this._toolbarBtn3 = page.getByTestId(`toolbar-button-3`);
}
public async clickButton1(): Promise<void> {
await this._toolbarBtn1.click();
}
public async clickButton2(): Promise<void> {
await this._toolbarBtn2.click();
}
public async clickButton3(): Promise<void> {
await this._toolbarBtn3.click();
}
}
element.status.enum.ts
export enum ElementStatus {
STATUS_1 = "Status 1",
STATUS_2 = "Status 2"
}
main.page.ts
import { List } from "../components/list.component";
import { Table } from "../components/table.component";
import { Toolbar } from "../components/toolbar.component";
import { ElementStatus } from "../enums/element.status.enum";
export class MainPage {
private readonly _leftTable: Table;
private readonly _topToolbar: Toolbar;
private readonly _centerList: List;
constructor(private readonly page: Page) {
this._leftTable = new Table(page);
this._topToolbar = new Toolbar(page);
this._centerList = new List(page);
}
public async goTo(): Promise<void> {
await this.page.goto('https://playwright.dev/');
}
public async changeTheStatusOfItem(rowTitle: string, listElementTitle: string): Promise<void> {
await this._leftTable.clickTableRowWithTitle(rowTitle);
await this._centerList.clickListElementWithTitle(listElementTitle);
await this._topToolbar.clickButton1();
}
public async getItemStatusForElementWithTitle(title: Locator): Promise<ElementStatus> {
const status = await this._centerList.getElementStatus(title);
return status;
}
}
example.test.ts
import { test, expect } from '@playwright/test';
import { MainPage } from '../pages/main.page';
import { ElementStatus } from '../enums/element.status.enum';
const rowTitle = "Test Row Title 1";
const listItemTitle = "Test List title 1"
test('get started link', async ({ page }) => {
const mainPage = new MainPage(page);
await mainPage.goTo();
await mainPage.changeTheStatusOfItem(rowTitle, listItemTitle);
await expect.poll(async () => {
const status = await mainPage.getItemStatusForElementWithTitle(listItemTitle);
return status;
}).toBe(ElementStatus.STATUS_1)
});
I'm not sure if this decomposition is correct, as there are some code smells:
- page object mainPage become more like actions aggregator than actual page object modelling a page and its behavior.
- Some methods in mainPage are just wrappers around basically the same methods from components, e.g.
getItemStatusForElementWithTitle(title), which violates DRY rule.
I'm also unsure about other topics, like:
- Would it be better to move
expect.poll()to page object and replace it withverifyElementStatusmethod in test file? - How to approach clicking nth element from the list? Would adding method in mainPage, like:
public getListElementWithNumber(number: number): Locator {
return this._centerList.listElement.nth(number)
}
or adding getter (in case only 1st element is important)
public get firstListElement(): Locator {
return this._centerList.listElement.first()
}
do the trick in a clean way? Or is there a design pattern that would make this code more elegant?