When scenarios number in the dozens, the question arises of where locators and actions live, so that an interface edit does not force rewriting half the suite. The classic answer is a Page Object, an object encapsulating work with the page. But the pattern has a dark side: it easily degenerates into a dump of selectors and methods for every occasion. A good Page Object speaks the application's language rather than listing markup elements.
The antipattern is recognizable - the God Page Object. One class for the whole application: login, buy, editProfile, openAdmin, and so on for thousands of lines. Such an object knows everything and therefore changes with any edit, conflicts in version control and is impossible to navigate. It repeats the monolith's mistake at the test level: a single point into which all complexity flows, instead of a split by areas of responsibility.
The healthy alternative is composition of small objects. Instead of one all-knowing class you create several domain ones: CheckoutPage for placing an order, CartComponent for the cart, NavigationComponent for navigation, OrdersApi for working with orders via the API. Each is responsible for its area, each has a small clear interface. A scenario assembles the needed objects like blocks rather than dragging along one giant class for one method.
Inside, such an object describes actions in the domain's language. CheckoutPage does not expose raw locators but gives meaningful methods: open() opens the page, payByCard(card) fills in the card details and presses pay. The method name expresses the user intent - "pay by card" - while how exactly this is done in terms of fields and buttons is hidden inside. The test reads as a business scenario, not as a sequence of clicks.
It helps to see a healthy Page Object in full once to feel the measure. Below is CheckoutPage with a constructor taking page, a pay-by-card method and a method returning the confirmation locator. You return to this form when a page object starts to grow: if there are too many methods and they are about different things, it is a signal to split it into several domain ones.
A subtle question is where to keep assertions. Assertions can be left in the test itself rather than hidden in the Page Object, and often that is right: the test then reads as a story with visible expectations, and it is clear what exactly it asserts. A component object meanwhile can return locators outward, so the test itself checks by them. The split is simple: the object gives access and actions, the test expresses expectations.
There is also a justified exception - encapsulating a complex wait. If waiting for a domain result is a non-trivial sequence (wait for a status, then for a record to appear, then for a counter to update), it is reasonable to hide it in an object's method, but only when the method name honestly expresses the contract. confirmation(), returning the locator of a confirmed order, reads as a promise; a nameless dump of waits does not. Encapsulate meaning, not hide complexity under a vague name.
The typical suite-architecture failures are predictable. A God Page Object of thousands of lines that changes with everything and conflicts for everyone. A Page Object as a thin wrapper over raw selectors with no domain methods - the same clicks, just through an extra layer. And assertions hidden in the object, because of which the test does not show what it asserts. Split into domain objects, name methods by user intent, and keep expectations visible in the test.
export class CheckoutPage {
constructor(private readonly page: Page) {}
async open() {
await this.page.goto('/checkout')
}
async payByCard(card: TestCard) {
await this.page.getByLabel('Card number').fill(card.number)
await this.page.getByRole('button', { name: 'Pay' }).click()
}
confirmation() {
return this.page.getByRole('status', { name: 'Order paid' })
}
}