Part II: Structuring UI Interaction
Chapter 4: Page Object Model Revisited
The Page Object Model remains a widely used pattern for structuring UI test code. It provides a way to encapsulate interactions with a user interface, promoting reuse and reducing duplication. However, its effectiveness depends heavily on how it is implemented.
Playwright introduces features, such as its locator API and built-in synchronisation, that influence how page objects should be designed. Patterns that were once considered standard practice do not always translate well. This chapter examines how the Page Object Model can be adapted to align with Playwright’s design, with particular attention to cohesion, separation of concerns, and the avoidance of common anti-patterns.
Keeping Page Objects Cohesive and Focused
A recurring issue in Page Object Model implementations is the tendency for page objects to grow excessively large. When a single class attempts to represent an entire page with numerous unrelated responsibilities, it becomes difficult to maintain and reason about.
In the context of Playwright, it is often more effective to treat page objects as representations of logical components rather than entire pages. This approach reflects how modern interfaces are typically structured.
For example, instead of a monolithic DashboardPage, the interface can be broken down into smaller, cohesive units:
1 from playwright.sync_api import Page, Locator
2
3 class NavigationBar:
4 def __init__(self, page: Page) -> None:
5 self.page = page
6 self.profile_menu: Locator = page.get_by_role("button", name="Profile")
7 self.logout_link: Locator = page.get_by_role("menuitem", name="Log out")
8
9 def logout(self) -> None:
10 self.profile_menu.click()
11 self.logout_link.click()
12
13
14 class DashboardPage:
15 def __init__(self, page: Page) -> None:
16 self.page = page
17 self.navbar = NavigationBar(page)
18 self.welcome_message: Locator = page.get_by_text("Welcome")
19
20 def is_loaded(self) -> bool:
21 return self.welcome_message.is_visible()
This structure keeps each class narrowly focused. The NavigationBar handles navigation-specific interactions, while DashboardPage coordinates higher-level behaviour. As a result, changes to one part of the interface are less likely to affect unrelated areas.
A useful rule of thumb is that a page object should have a single, well-defined responsibility. If a class begins to accumulate unrelated methods, it is often a sign that it should be split into smaller components.
Separating Structure from Behaviour
Another common source of complexity is the mixing of structural definitions (locators) with higher-level test logic. While some degree of coupling is unavoidable, maintaining a clear separation improves both readability and maintainability.
In Playwright, locators are already expressive and self-contained. This allows them to serve as a stable “interface” to the UI, while behaviour can be defined in terms of these locators.
A straightforward example illustrates this separation:
1 class LoginPage:
2 def __init__(self, page: Page) -> None:
3 self.page = page
4 self.email_input = page.get_by_label("Email")
5 self.password_input = page.get_by_label("Password")
6 self.submit_button = page.get_by_role("button", name="Sign in")
7
8 def login(self, email: str, password: str) -> None:
9 self.email_input.fill(email)
10 self.password_input.fill(password)
11 self.submit_button.click()
Here, the locators define the structure, while the login method defines behaviour. Tests interact with the behaviour rather than the underlying selectors.
However, it is important not to embed assertions or test-specific logic within page objects:
1 # Anti-pattern: embedding assertions inside the page object
2 def login_and_verify(self, email: str, password: str) -> None:
3 self.login(email, password)
4 assert self.page.get_by_text("Welcome").is_visible()
This approach blurs the boundary between test logic and page abstraction. A better alternative is to keep assertions within the test itself:
1 from playwright.sync_api import expect
2
3 login_page.login("user@example.com", "password")
4 expect(page.get_by_text("Welcome")).to_be_visible()
Maintaining this separation ensures that page objects remain reusable across different tests and scenarios.
Avoiding Common Anti-patterns
Several anti-patterns frequently appear in Page Object Model implementations, particularly when adapting older practices to Playwright.
One such pattern is the use of overly generic methods, such as:
1 def click_button(self, name: str) -> None:
2 self.page.get_by_role("button", name=name).click()
While flexible, this approach obscures intent. A method such as submit_order() communicates far more clearly than a generic click_button("Submit"). Tests should read as descriptions of user behaviour, not as low-level instructions.
Another issue is the retention of manual waiting logic within page objects:
1 # Anti-pattern: unnecessary explicit waiting
2 def submit(self) -> None:
3 self.submit_button.wait_for(state="visible")
4 self.submit_button.click()
Given Playwright’s auto-waiting capabilities, such code is often redundant. It adds noise without improving reliability and may indicate a misunderstanding of the framework’s behaviour.
A further pitfall is exposing raw locators directly to tests:
1 # Anti-pattern: leaking implementation details
2 login_page.submit_button.click()
This undermines the abstraction provided by the page object. If tests rely directly on locators, any change to the UI may require widespread updates. Encapsulating interactions within methods helps to contain such changes.
Finally, overly rigid page objects can become a problem. Designing classes that assume a single, fixed workflow makes it difficult to adapt tests to new scenarios. A more flexible approach is to provide small, composable methods that can be combined as needed.
Aligning POM with Playwright’s Design
Playwright’s features encourage a shift in how the Page Object Model is applied. Its locator API reduces the need for complex selector management, while its auto-waiting mechanisms remove much of the boilerplate associated with synchronisation.
As a result, page objects can be simpler and more focused. They no longer need to handle low-level concerns such as waiting for elements or retrying actions. Instead, they can concentrate on representing meaningful interactions.
For example:
1 class CartPage:
2 def __init__(self, page: Page) -> None:
3 self.page = page
4
5 def add_product(self, name: str) -> None:
6 self.page.get_by_role("listitem").filter(
7 has_text=name
8 ).get_by_role("button", name="Add to basket").click()
9
10 def checkout(self) -> None:
11 self.page.get_by_role("button", name="Checkout").click()
This implementation relies on Playwright’s strengths rather than working around them. There is no explicit waiting logic, and the locators are expressed in terms of user-visible behaviour.
In this sense, the Page Object Model is not replaced but refined. Its core aim, to provide a clear and maintainable abstraction over the UI, remains unchanged. However, the means by which this is achieved are better aligned with Playwright’s capabilities.
Chapter 5: The Page Manager Pattern
As UI test suites grow in size and complexity, the limitations of a straightforward Page Object Model become more apparent. While page objects help encapsulate interactions, they do not, on their own, provide a coherent way to organise or access those objects across a codebase.
This chapter introduces the Page Manager pattern as a structured access layer for page objects. It centralises object construction, supports lazy initialisation, and encourages organisation along domain boundaries rather than individual screens. The aim is to provide a more maintainable and discoverable architecture, particularly in larger test suites where the number of page objects can become difficult to manage.
Centralised Page Construction
In a conventional Page Object Model, each page must be instantiated explicitly within the test:
1 login_page = LoginPage(page)
2 dashboard_page = DashboardPage(page)
While this approach is serviceable in smaller projects, it tends to introduce repetition and inconsistency as the suite expands. Each test becomes responsible for object construction, and any change to constructor signatures must be propagated throughout the codebase.
The Page Manager addresses this by acting as a single point of construction:
1 class PageManager:
2 def __init__(self, page):
3 self.page = page
4 self._login = None
5 self._dashboard = None
6
7 @property
8 def login(self):
9 if self._login is None:
10 self._login = LoginPage(self.page)
11 return self._login
12
13 @property
14 def dashboard(self):
15 if self._dashboard is None:
16 self._dashboard = DashboardPage(self.page)
17 return self._dashboard
Tests then interact with page objects through this central interface:
1 pages = PageManager(page)
2
3 pages.login.login("user@example.com", "password")
4 pages.dashboard.is_loaded()
This approach consolidates object creation logic, ensuring consistency and reducing duplication. If additional dependencies are introduced, such as configuration or logging, they can be injected in one place rather than across many test files.
Lazy Initialisation
A key feature of the Page Manager pattern is lazy initialisation. Page objects are created only when they are first accessed, rather than at the point of manager instantiation.
This behaviour is implemented through property methods:
1 @property
2 def login(self):
3 if self._login is None:
4 self._login = LoginPage(self.page)
5 return self._login
This design has several practical implications. It avoids unnecessary object creation, particularly in tests that only interact with a subset of available pages. It also keeps initialisation lightweight, which can be beneficial when test setup is executed frequently.
From a design perspective, lazy initialisation aligns with how tests are written. A test does not need all page objects upfront; it only requires access to those relevant to the scenario. The Page Manager reflects this by constructing objects on demand.
Domain-oriented Organisation
As the number of page objects increases, a flat structure within the Page Manager can become difficult to navigate. Grouping pages by functional area provides a more scalable alternative.
For example:
1 class AuthPages:
2 def __init__(self, page):
3 self.login = LoginPage(page)
4 self.register = RegisterPage(page)
5
6
7 class DashboardPages:
8 def __init__(self, page):
9 self.home = DashboardPage(page)
10 self.settings = SettingsPage(page)
11
12
13 class PageManager:
14 def __init__(self, page):
15 self.auth = AuthPages(page)
16 self.dashboard = DashboardPages(page)
Usage then reflects the domain structure of the application:
1 pages = PageManager(page)
2
3 pages.auth.login.login("user@example.com", "password")
4 pages.dashboard.home.is_loaded()
This organisation shifts the mental model from individual classes to business domains. Instead of thinking in terms of constructing objects, the test reads as an interaction with parts of the system under test.
Such an approach tends to scale more naturally, particularly in applications with clearly defined functional areas. It also improves discoverability, as developers can explore available pages through logical groupings rather than scanning a long list.
Position within Test Architecture
The Page Manager is best understood as one layer within a broader test architecture. It sits between the test code and the page objects, acting as a gateway to UI interactions.
In more developed frameworks, it is often combined with additional layers:
1 class App:
2 def __init__(self, page):
3 self.pages = PageManager(page)
4 self.services = ServiceManager()
This separation allows different concerns to be handled independently. Page objects deal with UI structure and interaction, while services encapsulate API calls or business logic.
A further extension introduces a flow layer:
1 class UserFlows:
2 def __init__(self, pages: PageManager):
3 self.pages = pages
4
5 def login(self, email: str, password: str) -> None:
6 self.pages.auth.login.login(email, password)
7 self.pages.dashboard.home.is_loaded()
Tests can then operate at a higher level of abstraction:
1 flows.login("user@example.com", "password")
Within this architecture, the Page Manager serves a specific and limited purpose: it provides structured access to page objects. It does not contain business logic, orchestration, or assertions. Keeping this boundary clear helps maintain a clean separation of concerns.
Common Pitfalls
Despite its advantages, the Page Manager pattern can introduce its own set of issues if applied without restraint.
One common problem is overloading the manager with too many unrelated pages. A single class containing dozens of properties becomes difficult to navigate and undermines the intended clarity. Domain-based grouping helps to mitigate this.
Another issue is allowing the Page Manager to accumulate behaviour. It should not include methods that perform actions or assertions. Its role is limited to construction and access; anything beyond that belongs in page objects or higher-level abstractions.
There is also a risk of over-abstraction. Introducing too many layers, particularly in smaller projects, can make the code harder to follow rather than easier. The Page Manager is most effective when it addresses a clear organisational need.
Finally, care should be taken not to obscure intent. While the pattern simplifies access to page objects, tests should still read as clear descriptions of user behaviour. If the structure becomes opaque, the benefits are diminished.
Architectural Significance
The introduction of a Page Manager represents a shift in how the test suite models the application. Rather than treating page objects as isolated classes, it presents them as part of a unified and navigable structure.
This aligns with broader software design principles, including centralised dependency management and domain modelling. The application under test is no longer a collection of unrelated components, but a structured system that can be explored and interacted with in a consistent manner.
In practical terms, this leads to improved maintainability, better discoverability, and a clearer separation of responsibilities. As test suites scale, these qualities become increasingly important for sustaining long-term development.
Chapter 6: Modelling Domains, Not Pages
As test suites and applications grow, a strictly page-based approach to modelling the user interface begins to show its limitations. Modern applications are rarely structured as isolated pages with independent behaviour; instead, they are composed of interconnected features that span multiple screens and workflows.
A page-centric model tends to fragment related behaviour across multiple classes, making it harder to reason about complete user journeys. This chapter introduces a domain-based approach to structuring UI automation. Rather than grouping logic around pages, functionality is organised according to business domains, such as authentication, checkout, or user management. The aim is to reduce duplication, improve cohesion, and better reflect how the system is actually used.
Mapping UI Structure to Business Domains
In a traditional Page Object Model, structure is derived directly from the UI:
- LoginPage
- RegistrationPage
- ProfilePage
- SettingsPage
While intuitive at small scale, this mapping often fails to capture how users interact with the system. A single user journey may span multiple pages, each contributing a small part of a larger workflow.
A domain-based approach restructures this perspective. Instead of thinking in terms of screens, we think in terms of business capabilities:
- Authentication
- Account Management
- Ordering
- Checkout
These domains may span multiple pages, but they represent coherent areas of functionality.
For example, authentication might include both login and registration flows:
1 class AuthenticationDomain:
2 def __init__(self, page):
3 self.login = LoginPage(page)
4 self.register = RegisterPage(page)
5
6 def login_user(self, email: str, password: str) -> None:
7 self.login.open()
8 self.login.login(email, password)
9
10 def register_user(self, email: str, password: str) -> None:
11 self.register.open()
12 self.register.register(email, password)
This structure reflects how users perceive the system rather than how the UI is segmented. It also allows tests to operate at a higher level of abstraction, focusing on behaviour rather than navigation steps.
Reducing Duplication Across Pages
One of the common consequences of page-based modelling is duplication. Similar interactions are often repeated across multiple page objects, particularly when shared functionality exists, such as navigation bars, modals, or authentication states.
For example, a logout action may appear in several page objects:
1 class DashboardPage:
2 def logout(self):
3 self.page.get_by_role("button", name="Profile").click()
4 self.page.get_by_role("menuitem", name="Log out").click()
5
6
7 class SettingsPage:
8 def logout(self):
9 self.page.get_by_role("button", name="Profile").click()
10 self.page.get_by_role("menuitem", name="Log out").click()
This duplication is a sign that the behaviour does not belong to a single page, but to a shared domain component.
By extracting this into a domain-level abstraction, duplication can be reduced:
1 class AccountDomain:
2 def __init__(self, page):
3 self.page = page
4
5 def logout(self) -> None:
6 self.page.get_by_role("button", name="Profile").click()
7 self.page.get_by_role("menuitem", name="Log out").click()
Both DashboardPage and SettingsPage can then delegate to this shared behaviour where appropriate.
Doing it this way improves consistency - if the logout flow changes, only one implementation needs to be updated, rather than multiple page objects. It also reduces the risk of subtle behavioural drift between pages that are intended to perform the same action.
Improving Maintainability through Composition
Domain modelling encourages composition over inheritance or flat structuring. Instead of building large page objects or deeply hierarchical page inheritance trees, behaviour is composed from smaller, reusable components.
A domain may bring together multiple page objects and shared components:
1 class CheckoutDomain:
2 def __init__(self, page):
3 self.page = page
4 self.cart = CartPage(page)
5 self.shipping = ShippingPage(page)
6 self.payment = PaymentPage(page)
7
8 def complete_purchase(self, payment_details: dict) -> None:
9 self.cart.proceed_to_checkout()
10 self.shipping.enter_details("UK", "SW1A 1AA")
11 self.payment.pay(payment_details)
Each component remains focused on its own responsibility, while the domain orchestrates the overall workflow. This separation ensures that individual pieces remain simple, while still enabling complex behaviours to be expressed clearly.
Composition also improves flexibility. If a new step is introduced into the checkout process, it can be incorporated into the domain without requiring widespread changes to individual page objects.
Domains as a Reflection of System Behaviour
A key advantage of domain-based modelling is that it aligns test structure with business intent. Rather than describing how a user navigates through screens, tests describe what the user is trying to achieve.
Compare the following approaches:
Page-based:
1 login_page.open()
2 login_page.fill_email("user@example.com")
3 login_page.fill_password("password")
4 login_page.submit()
5 dashboard_page.assert_loaded()
Domain-based:
1 auth.login_user("user@example.com", "password")
The second version is more concise, but more importantly, it is more expressive. It communicates intent rather than implementation detail.
This shift becomes increasingly valuable as systems grow in complexity. When tests are written in terms of domains, they remain stable even if the underlying UI structure changes, provided the business behaviour remains consistent.
Boundary Between Domains and Pages
It is important to maintain a clear separation between domain logic and page-level implementation. Domains should not contain low-level selectors or UI-specific details. Those remain the responsibility of page objects.
A domain should orchestrate behaviour, not define structure:
1 # Appropriate: domain-level orchestration
2 def login_user(self, email: str, password: str) -> None:
3 self.login.login(email, password)
1 # Not appropriate: leaking UI structure into domain layer
2 def login_user(self, email: str, password: str) -> None:
3 self.page.get_by_role("textbox", name="Email").fill(email)
Maintaining this boundary ensures that changes to the UI do not ripple unnecessarily into higher-level abstractions. It also preserves the clarity of each layer’s responsibility.
Relationship to Page Manager Pattern
Domain-based modelling builds naturally on the Page Manager pattern introduced in the previous chapter. Where the Page Manager provides structured access to page objects, domain models consume those objects to express higher-level behaviour.
A typical structure might look like:
1 class App:
2 def __init__(self, page):
3 self.pages = PageManager(page)
4 self.auth = AuthenticationDomain(page)
5 self.checkout = CheckoutDomain(page)
This sort of layered approach allows tests to operate at different levels of abstraction depending on their needs. Low-level tests may interact directly with pages whilst higher-level tests focus on domain behaviours.
You can think of it in a way that domain modelling does not replace earlier patterns but extends them, providing a more scalable and expressive way to represent system behaviour as complexity increases.