Skip to content

Chapter 9: End‐to‐End Tests

Reyas Khan M edited this page Jun 5, 2025 · 1 revision

Welcome to the final chapter of our tutorial! We've come a long way. We've built our individual microservices (Shopfront, Product Catalogue, Stock Manager), packaged them into Docker containers, learned how to run them together locally with Docker Compose, and configured them for deployment in a cluster using Kubernetes.

At this point, you might be thinking, "Great, it's built and deployed! We're done!"

Not quite. While each microservice might work perfectly on its own, and they might be able to find each other (Microservice Communication (REST APIs)), there's still a crucial question to answer: Does the entire system work correctly when all the pieces are running and interacting together?

What if:

  • The Shopfront sends a request to the Product Catalogue, but the Product Catalogue changes its API format slightly?
  • The Stock Manager has a bug that returns wrong stock levels?
  • The network configuration in Kubernetes is slightly off, preventing one service from reaching another?

These kinds of problems happen when different parts of a system integrate. We need a way to test the complete "story" of a user interacting with our application, from the very beginning to the very end of a process.

This is where End-to-End Tests come in.

The Problem: Testing the Whole Story

Imagine our online shop application as a complex machine with many interconnected gears (microservices). Unit tests check if each individual gear spins correctly. Integration tests check if a couple of connected gears mesh properly. But we need to know if the entire machine runs smoothly and produces the expected output when all the gears are turning together.

Our central "user story" or "flow" is: A customer visits the Shopfront home page and sees a list of products with their details (name, price) and current stock levels. This single interaction involves the Shopfront calling the Product Catalogue and the Stock Manager, combining the data (Data Models / DTOs), and presenting it.

We need tests that simulate this complete flow to ensure it works as expected.

The Solution: End-to-End (E2E) Tests

End-to-End (E2E) Tests verify that the entire application stack works correctly from the user's perspective. They simulate how a real user or another system would interact with the application's external interface and check if the final outcome is correct.

In our project, the primary user interface is the Shopfront Microservice, specifically its public API that a browser or test tool can call. Our E2E tests will interact with the Shopfront's API and verify the data it returns, implicitly checking that the Shopfront successfully communicated with the Product Catalogue and Stock Manager to get that data.

Think of E2E tests as having a robot customer that visits your running, deployed shop. The robot performs actions (like loading the product list page/API) and then checks if what it sees (the response data) is exactly what it expects, confirming that the whole shopping system is functional.

How End-to-End Tests Work in Our Project

Our project includes a dedicated module called functional-e2e-tests. This module contains automated tests designed to be run after the entire application stack (Shopfront, Product Catalogue, Stock Manager) is up and running.

The tests in this module do the following:

  1. They are written in Java, using the JUnit testing framework.
  2. They use a library called RestAssured to make HTTP requests to the Shopfront's API.
  3. They access the Shopfront via the network address and port where it is exposed (e.g., http://localhost:8010/ when running via Docker Compose, or a Service IP/NodePort in Kubernetes).
  4. They check the structure and content of the JSON response received from the Shopfront's API.

This verifies the complete flow, from the test code hitting the external API, through the Shopfront's internal logic and calls to other services, and back through the Shopfront's response.

Here's a simple sequence showing the test flow:

sequenceDiagram
    participant TestRunner as E2E Test Runner
    participant ShopfrontAPI as Shopfront Microservice (API)
    participant ShopfrontInternal as Shopfront Internal Logic
    participant ProductCatalogueAPI as Product Catalogue Microservice (API)
    participant StockManagerAPI as Stock Manager Microservice (API)

    TestRunner->>ShopfrontAPI: HTTP GET /products
    ShopfrontAPI->>ShopfrontInternal: Process Request
    ShopfrontInternal->>ProductCatalogueAPI: HTTP GET /products
    ProductCatalogueAPI-->>ShopfrontInternal: HTTP Response (Product Data)
    ShopfrontInternal->>StockManagerAPI: HTTP GET /stocks
    StockManagerAPI-->>ShopfrontInternal: HTTP Response (Stock Data)
    ShopfrontInternal->>ShopfrontAPI: Combine Data & Prepare Response
    ShopfrontAPI-->>TestRunner: HTTP Response (Combined Product & Stock Data)
    Note over TestRunner: Verify Response Data
Loading

Looking at the End-to-End Test Code

Let's examine some key files within the functional-e2e-tests directory.

First, the pom.xml file defines the project's dependencies. We need JUnit for writing tests and RestAssured for making API calls.

<!-- functional-e2e-tests/pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>uk.co.taidev.springshopping</groupId>
    <artifactId>resttests</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>
    <dependencies>
        <!-- Needed to read JSON responses into Java objects -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.12.7.1</version>
        </dependency>
        <!-- JUnit for writing and running tests -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.13.1</version>
            <scope>test</scope>
        </dependency>
        <!-- RestAssured for making HTTP API calls -->
        <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <version>3.0.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

This pom.xml is standard for a Maven project. It declares the necessary dependencies (jackson-databind to help process JSON, junit for tests, and rest-assured for API calls) and sets the Java version. The <scope>test</scope> means these dependencies are only needed for running the tests, not for building a deployable application artifact itself.

Next, let's look at the Product class defined within the test project. This class models the structure of the combined product information that the Shopfront's /products API endpoint is expected to return in its JSON response. It mirrors the Shopfront Microservice's internal Product model.

// functional-e2e-tests/src/test/java/uk/co/danielbryant/djshopping/functionale2etests/entities/Product.java
package uk.co.danielbryant.djshopping.functionale2etests.entities;

import java.math.BigDecimal;

public class Product {
    private Integer id;
    private String sku;
    private String name;
    private String description;
    private BigDecimal price;
    private Integer amountAvailable;

    // Default constructor needed by Jackson for JSON mapping
    public Product() {
    }

    // Constructor to create objects (not strictly needed for receiving JSON)
    public Product(Integer id, String sku, String name, String description, BigDecimal price, Integer amountAvailable) {
        this.id = id;
        this.sku = sku;
        this.name = name;
        this.description = description;
        this.price = price;
        this.amountAvailable = amountAvailable;
    }

    // Getter methods allow RestAssured/Jackson to access the data after parsing JSON
    public Integer getId() { return id; }
    public String getSku() { return sku; }
    public String getName() { return name; }
    public String getDescription() { return description; }
    public BigDecimal getPrice() { return price; }
    public Integer getAmountAvailable() { return amountAvailable; }
}

This simple Java class has fields corresponding to the data we expect for each product from the Shopfront API (ID, SKU, name, description, price, amount available). The presence of a default constructor and getter methods allows libraries like Jackson (used internally by RestAssured) to automatically map the JSON data received from the API into instances of this Product class.

Finally, the main test class DjShopRestIntegrationTest. Although it uses the name "IntegrationTest", in the context of this multi-service project testing the public API, these are indeed End-to-End tests.

// functional-e2e-tests/src/test/java/uk/co/danielbryant/djshopping/functionale2etests/DjShopRestIntegrationTest.java
package uk.co.danielbryant.djshopping.functionale2etests;

import io.restassured.http.ContentType;
import org.junit.Test; // JUnit annotation for a test method

import static io.restassured.RestAssured.given; // Static import for RestAssured methods
import static org.hamcrest.core.Is.is; // Static import for 'is' matcher

public class DjShopRestIntegrationTest {

    // Defines the format we expect the API to use (JSON)
    private static final ContentType CONTENT_TYPE = ContentType.JSON;
    // The base URL for the Shopfront API - IMPORTANT!
    private static final String SUT_BASE_URI = "http://localhost:8010/"; // System Under Test URI

    @Test // Marks this method as a test case
    public void correctNumberOfProductsReturned() {
        given().contentType(CONTENT_TYPE) // Configure the request (expect JSON)
                .when()
                .get(SUT_BASE_URI + "products") // Send GET request to /products endpoint
                .then()
                .body("size()", is(5)); // Verify the response: the JSON array should have 5 items
    }

    @Test
    public void productOneHasCorrectProductInfo() {
        given().contentType(CONTENT_TYPE)
                .when()
                .get(SUT_BASE_URI + "products")
                .then()
                // Verify the response: in the first item (index 0), check 'id' and 'sku' fields
                .body("[0].id", is("1")) // Check product ID
                .body("[0].sku", is("12345678")); // Check SKU
    }

    @Test
    public void productOneHasCorrectStockInfo() {
        given()
                .contentType(CONTENT_TYPE)
                .when()
                .get(SUT_BASE_URI + "products")
                .then()
                // Verify the response: in the first item (index 0), check 'amountAvailable' field
                .body("[0].amountAvailable", is(5)); // Check stock amount
    }
}

This class contains our actual E2E test methods, each marked with @Test.

  • SUT_BASE_URI is configured as http://localhost:8010/. This means the tests expect the Shopfront to be accessible at this address. When you run the system locally using docker-compose up, the Shopfront is exposed on port 8010 of your machine, so this URI works. If running against a Kubernetes cluster, this URI would need to be changed to point to the cluster's access point (e.g., a node IP and NodePort, or an Ingress URL).
  • The given().when().then() structure is from RestAssured:
    • given(): Sets up the request (e.g., content type).
    • when(): Specifies the HTTP method and URL (get(SUT_BASE_URI + "products")).
    • then(): Defines the assertions to make on the response (e.g., check the response body()).
  • The body("size()", is(5)) assertion checks that the returned JSON array contains exactly 5 items.
  • The body("[0].id", is("1")) and body("[0].amountAvailable", is(5)) assertions check specific fields (id, sku, amountAvailable) of the first product returned in the list (assuming the API consistently returns product "1" as the first item, which might be fragile in a real system, but is simple for this example). These values (ID "1", SKU "12345678", amount 5) correspond to the synthetic data loaded into the Product Catalogue and Stock Manager services when they start (as seen in Chapter 2 and Chapter 3).

These tests confirm that the Shopfront successfully retrieved product details and stock information and combined them correctly in its response.

Running the End-to-End Tests

To run these tests, you must first have the entire microservice application running. You can do this locally using Docker Compose:

  1. Go to the root directory of the project in your terminal.
  2. Start the application stack: docker-compose -f docker-compose-build.yml up --build
  3. Wait for all three services to start up and become healthy (look at the logs).
  4. Once the services are running, open a new terminal window (keep the first one running the services).
  5. Navigate to the functional-e2e-tests directory.
  6. Run the Maven tests: mvn test

Maven will compile the test code, run the JUnit tests, and use RestAssured to make the API calls to http://localhost:8010/. If the Shopfront is running and correctly communicating with the Product Catalogue and Stock Manager, all tests should pass.

# Example Maven test output (simplified)
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< uk.co.taidev.springshopping:resttests >------------------
[INFO] Building resttests 0.1.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ resttests ---
...
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ resttests ---
...
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ resttests ---
[INFO] Surefire report directory: /path/to/your/project/functional-e2e-tests/target/surefire-reports

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running uk.co.danielbryant.djshopping.functionale2etests.DjShopRestIntegrationTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: X.XXX sec

If any test fails, it indicates a problem somewhere in the integrated system – it could be a bug in the Shopfront's logic, a communication issue between services, or a problem with the data provided by the backend services.

Types of Software Tests (Briefly)

To put E2E tests into perspective, let's briefly look at different testing levels:

Test Type Focus Scope Example in our project
Unit Tests Smallest piece of code (method, class) Isolated component Testing a single method in ProductService logic within the Shopfront.
Integration Tests Interaction between a few components Several components or services (maybe with mocks) Testing if the Shopfront's ProductService can successfully use ProductRepo and StockRepo (perhaps with mocked responses from other services).
End-to-End Tests Entire application flow The complete running system, from UI/API to backend Our DjShopRestIntegrationTest calling the Shopfront API, which involves all three services.

E2E tests are crucial for confidence in the deployed system, but they are generally slower, more complex to set up (requiring the whole system), and harder to pinpoint the exact cause of failure compared to unit or integration tests. A good testing strategy uses a mix of all types.

Benefits of End-to-End Tests

  • Confidence: Provides the highest level of confidence that the entire system works as expected from a user's perspective.
  • Catch Integration Issues: Excellent at finding bugs that only appear when services interact in a deployed environment.
  • Verify Flows: Ensures key user journeys or business processes function correctly from start to finish.
  • Simulate Reality: Tests the system in an environment that closely mimics production.

Conclusion

In this final concept chapter, we introduced End-to-End Tests. We learned that they are essential for verifying that our entire microservice application works correctly when all services are deployed and interacting. We saw how the functional-e2e-tests module in our project uses JUnit and RestAssured to simulate client interactions with the Shopfront's public API and verify the combined data received from the Product Catalogue and Stock Manager. We emphasized that these tests require the complete system to be running beforehand and showed how to execute them using Maven.

E2E tests are the final check, confirming that our journey from individual microservices to a containerized, orchestrated application results in a functional system. This concludes our conceptual overview of the Java-application-with-docker-kuberenetes project. You now have a foundational understanding of each major component and technology used!



Doc by Reyas Khan. References: [1], [2], [3]

Clone this wiki locally