Basic page objects hold locators and methods. That works for twenty pages. At two hundred, three problems appear that the basic pattern has no answer for.
Problem one: a page object hands you an object for a page that has not loaded.
const page = new ContactsPage(driver);
await page.delete('Dana Fox'); // was the page ever loaded?
Nothing checked. If navigation failed, the failure surfaces inside delete as a missing element, blaming the wrong thing.
The loadable component pattern makes a page responsible for proving it loaded:
class BasePage {
constructor(driver) { this.driver = driver; }
async load() {
await this.navigate();
await this.waitUntilLoaded();
return this;
}
async waitUntilLoaded() {
throw new Error(`${this.constructor.name} must implement waitUntilLoaded`);
}
}
class ContactsPage extends BasePage {
async navigate() { await this.driver.get(`${config.baseUrl}/contacts`); }
async waitUntilLoaded() {
await waits.forVisible(this.driver, By.css('[data-testid="contact-table"]'),
'The contacts table never appeared');
}
}
Now await new ContactsPage(driver).load() either gives you a loaded page or fails saying the contacts table never appeared. The failure names the real problem at the real moment.
Problem two: navigation between pages is untyped and unclear.
A method that clicks something leading elsewhere should hand you the page you arrived at:
class LoginPage extends BasePage {
async signIn(email, password) {
await this.type(this.emailField, email);
await this.type(this.passwordField, password);
await this.click(this.submitButton);
return new DashboardPage(this.driver).waitUntilLoaded();
}
}
const dashboard = await new LoginPage(driver).load().then((p) => p.signIn(user, pass));
This is fluent chaining, and its value is more than style. In a typed language your editor now tells you what is reachable from where, and a test cannot accidentally call a dashboard method while on the login page. The page model encodes the application's navigation graph.
Do not overdo it. Chains four calls deep become unreadable, and a failure mid-chain is harder to locate. Two or three steps is the useful range.
Problem three: inheritance collapses under its own weight.
The instinct is a hierarchy: BasePage → AuthenticatedPage → AdminPage → AdminContactsPage. It works until a page needs behaviour from two branches, and then you are stuck.
⚠️ Prefer composition over deep inheritance.
Inheritance for what a page is (every page loads and navigates). Composition for what a page has (this page has a data grid, a filter bar and a modal).
A page object with three components is easy to change. A class four levels deep is not, because you cannot tell what a change affects without reading the whole chain.
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"]'));
}
async delete(name) {
await this.grid.rowAction(name, 'Delete');
await new ConfirmDialog(this.driver).confirm();
}
}
One level of inheritance, three composed components. That structure is the subject of the next lecture.
The rule that keeps page objects reusable: no assertions inside them.
// wrong: the page decides what success means
async deleteAndVerify(name) { ... }
// right: the page acts, the test judges
async delete(name) { ... }
async hasRow(name) { return (await this.driver.findElements(this.rowFor(name))).length > 0; }
The first cannot be used in a test about deletion being refused. Page objects expose state; tests assert on it.
Remember this: pages prove they loaded, navigation returns the next page, compose components rather than deepening inheritance, and never assert inside a page object.
Reference: Page object models.