Part III: Beyond UI Interaction

Chapter 7: Service Layer Integration

UI tests rarely operate in isolation. In most real-world systems, meaningful test scenarios depend on pre-existing data, authenticated sessions, or specific system states that are difficult or inefficient to establish purely through the user interface. Relying exclusively on UI interactions for setup often leads to slow, brittle tests that are harder to maintain.

This chapter introduces the concept of a service layer within the test architecture. The service layer is responsible for handling API interactions and other non-UI operations required to prepare, manipulate, or verify system state. It sits alongside the UI layer rather than replacing it, allowing tests to combine backend control with frontend validation in a structured and maintainable way.

The aim is to clearly separate concerns: UI automation remains focused on user behaviour, while the service layer manages direct system interaction where appropriate.

Separating UI and Backend Concerns

A common challenge in UI test design is the temptation to perform all actions through the user interface. While this approach is conceptually simple, it does not scale well. For example, creating test data via repeated UI steps can be slow and introduces unnecessary dependencies on frontend stability.

A service layer addresses this by providing direct access to backend functionality, typically via HTTP APIs:

 1 import requests
 2 
 3 class UserService:
 4     def __init__(self, base_url: str, token: str | None = None):
 5         self.base_url = base_url
 6         self.token = token
 7 
 8     def create_user(self, email: str, password: str) -> dict:
 9         response = requests.post(
10             f"{self.base_url}/users",
11             json={"email": email, "password": password},
12             headers=self._headers()
13         )
14         response.raise_for_status()
15         return response.json()
16 
17     def delete_user(self, user_id: str) -> None:
18         response = requests.delete(
19             f"{self.base_url}/users/{user_id}",
20             headers=self._headers()
21         )
22         response.raise_for_status()
23 
24     def _headers(self) -> dict:
25         headers = {"Content-Type": "application/json"}
26         if self.token:
27             headers["Authorization"] = f"Bearer {self.token}"
28         return headers

This separation ensures that UI tests remain focused on validating behaviour through the interface, while setup and teardown responsibilities are handled more efficiently at the API level.

A typical UI test can then assume the required state already exists:

1 user = user_service.create_user("user@example.com", "password")
2 
3 auth.login_user("user@example.com", "password")
4 dashboard.assert_loaded()

This reduces both execution time and complexity within UI flows.

Designing Reusable Service Clients

A well-designed service layer should be modular, reusable, and independent of UI concerns. Each service typically corresponds to a logical area of the backend system, such as users, orders, or payments.

For example:

 1 class OrderService:
 2     def __init__(self, base_url: str, token: str | None = None):
 3         self.base_url = base_url
 4         self.token = token
 5 
 6     def create_order(self, user_id: str, items: list[dict]) -> dict:
 7         response = requests.post(
 8             f"{self.base_url}/orders",
 9             json={"user_id": user_id, "items": items},
10             headers=self._headers()
11         )
12         response.raise_for_status()
13         return response.json()

This structure mirrors the domain-oriented approach introduced in Chapter 6, but operates at the API level rather than the UI level. Each service encapsulates a specific area of system functionality, making it easier to maintain and extend.

To avoid duplication, shared configuration such as base URLs, authentication tokens, and retry logic should be centralised where possible. This ensures that changes to infrastructure do not require updates across multiple service classes.

In more mature implementations, a service manager can be introduced to mirror the Page Manager pattern:

1 class ServiceManager:
2     def __init__(self, base_url: str, token: str | None = None):
3         self.users = UserService(base_url, token)
4         self.orders = OrderService(base_url, token)

This provides a consistent entry point for backend operations, similar in spirit to how the Page Manager organises UI interactions.

Coordinating UI and API Workflows

The primary value of introducing a service layer lies in its ability to coordinate UI and backend operations within a single test flow. This enables tests to focus on behaviour rather than setup mechanics.

For example, a test might use the service layer to prepare data, then validate behaviour through the UI:

1 user = services.users.create_user("user@example.com", "password")
2 
3 auth.login_user("user@example.com", "password")
4 dashboard.assert_loaded()

In more complex scenarios, services can be used both before and after UI interactions:

1 order = services.orders.create_order(user_id, [{"sku": "ABC123", "qty": 1}])
2 
3 checkout.add_product_to_cart("ABC123")
4 checkout.complete_purchase()
5 
6 services.orders.delete_order(order["id"])

This hybrid approach allows tests to remain fast and focused, while still exercising the UI where it matters.

However, it is important to maintain a clear boundary. The service layer should not replace UI validation. Its role is to support test execution, not to bypass meaningful user journeys entirely. Over-reliance on backend calls can lead to tests that pass without genuinely exercising the interface.

Avoiding Tight Coupling Between Layers

One of the key risks in introducing a service layer is excessive coupling between UI tests and backend implementation details. If tests rely too heavily on internal APIs, they may become sensitive to backend changes that are unrelated to user-facing behaviour.

To mitigate this, service methods should be designed around business operations rather than raw endpoints. For example:

1 # Prefer this
2 services.users.create_user(email, password)
3 
4 # Over this
5 requests.post("/v2/internal/user/create", ...)

By expressing operations in domain terms, the service layer remains aligned with business intent, rather than technical implementation.

It is also important to avoid leaking service logic into UI abstractions. Page objects and domain models should not be responsible for making API calls. Each layer should remain focused on its own responsibility.

Relationship to Page and Domain Layers

The service layer complements the UI and domain layers introduced in earlier chapters. Each layer serves a distinct purpose:

  • Page objects encapsulate UI structure and interaction
  • Domain models represent business workflows at a conceptual level
  • Services manage backend state and system integration

A typical architecture might therefore look like:

1 class App:
2     def __init__(self, page, base_url: str, token: str | None = None):
3         self.pages = PageManager(page)
4         self.domains = DomainManager(page)
5         self.services = ServiceManager(base_url, token)

This separation allows tests to operate at the appropriate level of abstraction. A simple UI validation may use only page objects, while more complex scenarios combine services and domain flows to achieve precise control over system state.

Chapter 8: Flows and Task Abstractions

As test suites evolve, individual tests often begin to repeat the same sequences of actions. Logging in, adding items to a basket, completing checkout, or creating a user via multiple steps are all common examples. While each step may be straightforward in isolation, repetition across tests can lead to verbose, harder-to-maintain code.

This chapter introduces flow-based abstractions as a way of encapsulating these recurring sequences. A flow represents a complete user journey or task-oriented workflow, built from page objects, domain models, and service interactions introduced in earlier chapters. The goal is to improve readability and maintainability by expressing tests in terms of behaviour rather than implementation detail.

Structuring Reusable Workflows

A flow is a higher-level abstraction that coordinates multiple actions into a coherent sequence. It sits above page objects and domain models, focusing on what the user is trying to achieve rather than how each step is performed.

For example, a login flow might combine navigation, form interaction, and post-login validation:

 1 class AuthFlows:
 2     def __init__(self, pages):
 3         self.pages = pages
 4 
 5     def login(self, email: str, password: str) -> None:
 6         self.pages.auth.login.open()
 7         self.pages.auth.login.enter_email(email)
 8         self.pages.auth.login.enter_password(password)
 9         self.pages.auth.login.submit()
10         self.pages.dashboard.assert_loaded()

This encapsulation ensures that the full login process is defined in one place. If the login sequence changes, only the flow requires modification, rather than every individual test.

Flows are particularly useful when dealing with multi-step processes that span multiple pages or domains. For example, checkout is rarely confined to a single screen:

 1 class CheckoutFlows:
 2     def __init__(self, pages, services):
 3         self.pages = pages
 4         self.services = services
 5 
 6     def complete_purchase(self, user_id: str, item_sku: str) -> None:
 7         self.services.orders.create_order(user_id, [{"sku": item_sku, "qty": 1}])
 8 
 9         self.pages.shop.add_item_to_cart(item_sku)
10         self.pages.cart.proceed_to_checkout()
11         self.pages.checkout.confirm_payment()
12         self.pages.checkout.assert_success()

This allows flows to coordinate both UI and backend actions where appropriate, while keeping the test itself free from low-level orchestration logic.

Keeping Tests Declarative

One of the main advantages of flow-based design is that it enables tests to become more declarative. Instead of describing each step explicitly, tests describe intent.

Compare the following styles.

Step-by-step approach:

1 pages.auth.login.open()
2 pages.auth.login.enter_email("user@example.com")
3 pages.auth.login.enter_password("password")
4 pages.auth.login.submit()
5 
6 expect(pages.dashboard.welcome_message).to_be_visible()

Flow-based approach:

1 flows.auth.login("user@example.com", "password")

The second version removes unnecessary detail from the test, leaving only the behaviour being verified. This makes tests easier to read, particularly for non-developers or stakeholders reviewing automated coverage.

Declarative tests also reduce cognitive load. When reading a test, the focus shifts from how the system is operated to what behaviour is being validated. This distinction becomes increasingly valuable as the number of tests grows.

However, it is important that flows do not become opaque. A flow should remain a clear and predictable representation of a user journey, rather than a hidden collection of unrelated actions.

Aligning with Behaviour-driven Approaches

Flow abstractions naturally align with behaviour-driven development principles. While not strictly tied to BDD frameworks, they encourage similar ideas: expressing tests in terms of behaviour and user intent.

A flow effectively represents a reusable implementation of a scenario step. This allows higher-level tests to remain concise while still relying on consistent underlying behaviour.

For example:

1 def test_user_can_complete_purchase(flows):
2     flows.auth.login("user@example.com", "password")
3     flows.checkout.complete_purchase(user_id="123", item_sku="ABC123")

This structure closely resembles BDD-style scenarios:

1 Given a registered user
2 When they log in
3 And complete a purchase
4 Then the order is created successfully

Flows map directly onto these conceptual steps, even if the test framework itself does not enforce BDD syntax.

This sort of alignment helps improve communication between technical and non-technical stakeholders. It also helps ensure that automated tests reflect business behaviour rather than implementation detail.

Composition of Flows

Flows are most effective when composed rather than deeply nested. Smaller flows can be combined to represent more complex journeys, rather than creating large, monolithic workflows.

For example, a checkout flow might reuse an authentication flow:

 1 class CheckoutFlows:
 2     def __init__(self, auth_flows, pages):
 3         self.auth_flows = auth_flows
 4         self.pages = pages
 5 
 6     def purchase_as_logged_in_user(self, email: str, password: str, item_sku: str) -> None:
 7         self.auth_flows.login(email, password)
 8         self.pages.shop.add_item_to_cart(item_sku)
 9         self.pages.cart.checkout()
10         self.pages.checkout.confirm()

This composability reduces duplication and encourages reuse of stable building blocks. It also helps maintain a clear hierarchy of abstraction: small flows represent atomic behaviours, while larger flows represent end-to-end journeys.

Avoiding Over-abstraction

While flows improve readability, there is a risk of overusing them. If every minor interaction is wrapped in a flow, tests can become difficult to follow and debug.

A common anti-pattern is excessive nesting:

1 flows.user.do_everything()

This approach hides too much behaviour and reduces transparency. When failures occur, it becomes harder to determine which step is responsible.

Flows should therefore be used selectively. They are most effective for:

  • Repeated multi-step user journeys
  • Cross-domain workflows
  • Business-critical scenarios

Simple interactions are often better left in page objects or domain methods.

Relationship to the Existing Architecture

Flows sit at the top of the abstraction hierarchy introduced in previous chapters:

  • Page objects define UI structure and interaction
  • Domain models represent business capabilities
  • Services manage backend state
  • Flows orchestrate end-to-end behaviour

A typical structure might look like:

1 class App:
2     def __init__(self, page, base_url: str):
3         self.pages = PageManager(page)
4         self.services = ServiceManager(base_url)
5         self.flows = FlowManager(self.pages, self.services)

This layered approach allows tests to be written at the appropriate level of abstraction. Some tests may interact directly with pages for fine-grained validation, while others rely entirely on flows to express high-level behaviour.

Flows provide the top-most behavioural layer of the test architecture, ensuring that complex user journeys remain readable, reusable, and aligned with business intent.