Test automation · first published on Medium, 7 October 2024

The Complexity of Mobile E2E Testing: A Common Dilemma

End-to-end (E2E) testing of a mobile application can be challenging due to the many variables involved. Mobile apps rely on both a frontend interface that users interact with and backend services that process data.

Where is the problem?

One common and tricky question during testing is: Is the issue on the mobile frontend or the backend?

From my experience, debugging issues in a mobile E2E flow can be time-consuming, often requiring involvement from multiple teams — each responsible for different parts of the system. As a tester, this means you spend a lot of time jumping between teams to trace the root cause of a problem.

Debugging simplified

Given these challenges, the complexity of identifying the root cause can delay the testing process. But what if we could remove one of the variables from this equation?

Imagine simplifying the process by creating a fully functional E2E flow on the mobile app that doesn't depend on the backend at all. This eliminates external dependencies, speeds up testing cycles, and allows for easier debugging.

It would be like performing an advanced component test, focusing solely on the mobile side, without worrying about backend services.

Given the complexity of tracing issues between the frontend and backend, a more controlled approach to testing becomes essential. This is where stub services come into play.

Introducing Stub Services: Eliminating the Backend

In a typical system architecture, the mobile app communicates with a backend server, which in turn interacts with platform services to process data and return responses.

Replacing the Backend

But how can we eliminate this entire backend system while still maintaining the ability to perform an end-to-end (E2E) flow? The solution is stubbing — or in simpler terms, mocking the backend behavior with predefined responses.

The Role of the Stub Service

The stub service is introduced between the mobile application and the backend. This service effectively replaces the backend, mimicking its behavior by responding to the app's requests with predefined responses.

This stub intercepts and handles all communication, allowing us to simulate backend responses during testing.

The key advantage? The app operates as if the backend is fully functional, without any dependency on the actual backend. This creates a seamless testing experience where the mobile app remains unaware that it's communicating with a mock service.

How Does It Work?

We set up a stub response in the stub service to match an expected incoming request from the app. This configuration specifies three main elements:

Request details: The stub service needs to know which request it should expect, including the HTTP method and the URL. Parameters: This includes any necessary request parameters or body content. Response: The desired response the service should return when the request is matched.

Example

Let's say the app makes a request like:

GET /books/find/123

In this case, the stub service will be configured to return a predefined response:

{
  "title": "automation best practices",
  "year": "2024"
}

Request Matching Process

Since we already know how the app is expected to behave, we can anticipate the call. When the stub service receives the request, it goes through the following steps:

Matching the Request: The service checks the request URL, method, and parameters against the stored configurations. Returning the Response: If a match is found, the predefined response is returned. If no match is found, the service returns an error, indicating no corresponding stub was found.

While the concept of stubbing may sound complex, the actual implementation is straightforward once the infrastructure is in place.

Let's break it down

This might sound overwhelming, and I agree — setting up the stub infrastructure requires some effort. However, once it's properly in place, implementing the actual stubbing becomes a smooth and straightforward process.

The first step toward stubbing is to ensure the app communicates only with the stub service. We achieve this by setting up a dedicated application flavor.

A dedicated application flavour

We create a dedicated app flavor where all the URLs used by the app are replaced with the stub service's address. This gives us, as testers, full control over the application's communication.

Once the app is configured, we move on to setting up a robust stub service that can handle incoming requests and return predefined responses.

A smart Stub Service

The stub service must be capable of handling all incoming communication from the application. To achieve this, we need:

An endpoint to accept any incoming requests, whether it's REST or GraphQL. Endpoints to save and configure predefined stubbed responses. Functionality to track every outgoing request from the application.

An endpoint to accept any incoming requests

How can the stub service listen to all incoming requests? Simple — a single endpoint that can accept any type of request. Below is an example in Kotlin of a REST endpoint that handles this:

@RequestMapping(value = "/stub", method = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE])
fun handleRequest(
    @RequestBody(required = false) body: String?,
    request: HttpServletRequest
): ResponseEntity<String> {
    // Log the request method and URL
    println("Received ${request.method} request to ${request.requestURI}")

    // Optionally, you can log headers or other parts of the request
    println("Headers: ${request.headerNames.toList()}")
    println("Body: $body")

    // Here we would check against the configured stubs and return a response
    val response = matchStubbedResponse(request, body)

    return if (response != null) {
        ResponseEntity.ok(response)
    } else {
        ResponseEntity.status(HttpStatus.NOT_FOUND).body("No matching stub found")
    }
}

Endpoints to save and configure predefined stubbed responses

To define our stubs, we'll need an endpoint to allow us to create and store stub configurations in a database, each with a unique ID for tracking and debugging purposes.

Below is a simplified example of an endpoint for creating a stub:

@RestController
class StubController(val stubService: StubService) {

    @PostMapping("/stub/create")
    fun createStub(@RequestBody stubRequest: StubRequest): ResponseEntity<String> {
        // Save the stub to the database with a unique ID
        val stubId = stubService.saveStub(stubRequest)

        return ResponseEntity.ok("Stub created with ID: $stubId")
    }
}

data class StubRequest(
    val method: String,
    val url: String,
    val requestBody: String?,
    val responseBody: String,
    val responseStatus: Int
)

When managing stubs, it's important to consider their lifecycle. Do we want the same response returned every time, or should the response change based on different states? For instance, an endpoint could return one response initially and then a different one as the application's state changes. While adding this type of flexibility is crucial for advanced testing scenarios, we'll keep things simple for now by focusing on static stubbed responses.

Functionality to track every outgoing request from the application

Tracking and matching every outgoing request from the application requires careful planning and close collaboration with the development team. The key is ensuring that you know exactly which calls the app is making and the associated metadata for each one.

For example, when a user logs into the app, the application generates a unique session ID (which changes on every login). We ensure that every subsequent call made by the app includes this session ID in a dedicated header.

What does this achieve? We can match a stub not just to a specific request but to a specific call made in a particular test session. We can run multiple tests in parallel, knowing that the stubs won't interfere with each other, as each test will have its own unique session ID. We can choose whether a stub should be generic (always return the same response) or specific (return a response only for that particular session ID).

Here's a revised code snippet that takes the session ID header into account when matching stubs:

@RequestMapping(value = "/stub", method = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE])
fun handleRequest(
    @RequestBody(required = false) body: String?,
    request: HttpServletRequest
): ResponseEntity<String> {
    val sessionId = request.getHeader("Session-Id")

    println("Received ${request.method} request to ${request.requestURI} with Session-Id: $sessionId")

    val response = matchStubbedResponse(request, body, sessionId)

    return if (response != null) {
        ResponseEntity.ok(response)
    } else {
        ResponseEntity.status(HttpStatus.NOT_FOUND).body("No matching stub found")
    }
}

Let's Recap

Our solution revolves around simplifying mobile application testing by eliminating the need for a real backend. We achieve this by introducing a stub service that acts as a mock backend, handling all the app's requests with predefined responses. Here's how it works:

Dedicated app communication: We configure the app to communicate exclusively with our stub service by replacing real backend URLs with stub service URLs using a dedicated app flavor.

Handling the communication: The stub service listens to all incoming requests, attempts to match them with predefined configurations, and returns the corresponding stubbed responses. If no match is found, an error is returned.

Refining communication matching: We incorporate session IDs into requests to ensure that stubs are tied to specific test cases. This allows us to run tests in parallel without conflicts, ensuring stubs remain dedicated to individual test sessions.

Flexibility of stubbing: Stubs can be generic (always returning the same response) or specific (tailored to certain session IDs), providing flexibility based on the test scenario.

By controlling the application's communication, we eliminate the backend as a variable in testing, allowing us to focus entirely on the mobile application behavior.

Planning Stubs with Tools like BrowserStack

To effectively plan and configure our stubs, we can utilize tools like BrowserStack, which offers a Network Tracking Tool. This tool captures and displays every call made by the application, including the request details, response data, and the order of the calls.

By using this tool: We can easily observe the complete network flow of the app, understanding which requests are made and when. This information can help us design accurate stub configurations, ensuring that our mock responses align with the real behavior of the app. Tracking the order of calls is particularly useful when testing complex workflows, as it allows us to create more specific stubs for different stages of the application's flow.

This makes tools like BrowserStack invaluable for planning and optimizing our stub-based testing approach.

Summary

This solution offers valuable insights into the application's state quickly and efficiently — no timeouts, versioning conflicts, or environment blockers to worry about.

What we've outlined here is just the beginning. Implementing a stub-based approach will require thoughtful planning and execution, but once in place, it equips both testers and developers with a powerful tool.

It allows you to confidently identify the root cause of issues without uncertainty, streamlining your testing process and improving overall efficiency.

Want a guardrailed assistant like this on your site, or AI automation with the same rigour?