The same data grid appears on forty screens. In a page-per-class model, that grid is modelled forty times. When the grid's markup changes, forty page objects need editing, and the engineer doing it will miss three.
The application was not built that way. It was built from components, and your model should match.
A component object is a page object scoped to part of the screen.
class DataGrid {
constructor(driver, rootLocator) {
this.driver = driver;
this.root = rootLocator;
}
async rootElement() {
return waits.forVisible(this.driver, this.root, 'Grid never appeared');
}
async rowCount() {
const grid = await this.rootElement();
return (await grid.findElements(By.css('[data-testid="row"]'))).length;
}
async rowAction(rowText, action) {
const grid = await this.rootElement();
const rows = await grid.findElements(By.css('[data-testid="row"]'));
for (const row of rows) {
if ((await row.getText()).includes(rowText)) {
const button = await row.findElement(By.css(`[data-action="${action.toLowerCase()}"]`));
return button.click();
}
}
throw new Error(`No row containing "${rowText}" in grid ${this.root}`);
}
}
Two things make this work.
Everything is scoped to the root. grid.findElements searches inside the grid, not the whole page. That is what lets two grids coexist on one screen without their selectors colliding, and it is the property page objects lack.
The error names the component. When no row matches, the message says which grid and which text. Compare that to NoSuchElementException with a bare CSS selector.
Pages compose components:
class ContactsPage extends BasePage {
constructor(driver) {
super(driver);
this.grid = new DataGrid(driver, By.css('[data-testid="contact-table"]'));
this.filters = new FilterBar(driver, By.css('[data-testid="filters"]'));
this.pagination = new Pagination(driver, By.css('[data-testid="pager"]'));
}
}
class OrdersPage extends BasePage {
constructor(driver) {
super(driver);
this.grid = new DataGrid(driver, By.css('[data-testid="orders-table"]')); // same class
}
}
One DataGrid class serves every screen with a grid. The markup change is one edit.
Which components to build. Not everything. The ones that repeat and have real behaviour: data grids and tables, filter and search bars, modal dialogs, date pickers, multi-select dropdowns, file upload areas, pagination, toast notifications.
A component with one method used on one screen is not worth the indirection. The rule I use is the same as for factories: the second time it appears, it becomes a component.
Nested components. A modal containing a form containing a date picker is three components, composed. Scoping makes this safe: each searches only within its own root, so a date picker inside a modal cannot accidentally match one behind it on the page.
The biggest practical win, and it is worth saying explicitly. Component modelling is what makes a Selenium suite survive a redesign. In a page-based model a redesign touches every page object. In a component model it touches the components that changed. I have seen a redesign that would have been a six-week test rewrite become a four-day one because the grid was modelled once.
Where it interacts with testability. Components work best when the application marks its components in the markup. data-testid="contact-table" as a root, and data-testid="row" inside, gives you a stable scope. Asking developers for root-level test IDs on repeated components is a smaller request than test IDs on everything, and it delivers most of the value. That is a good negotiating position.
Remember this: model what the application is built from. Scope everything to a root, compose components into pages, and a redesign stops being a rewrite.
Reference: Page object models.