End-to-End Microservice Tests with Cucumber

End-to-End Microservice Tests with Cucumber

If you work with Microservices, you surely faced the situation in which some of your system processes only make sense after the information flows through several microservices. For instance, if you’re implementing an Event-Driven architecture, you should verify your Business Processes end-to-end, including the complete event chain that spans across services. There are several frameworks you can use for that but, for its simplicity and perfect matching with Business requirements, Cucumber is a great option. Let’s get into the process of writing end-to-end scenarios using Cucumber.

Table of Contents

NOTE: This post doesn’t cover the use of Dependency Injection with Cucumber in Spring Boot. However, I’ve posted another article with a code sample to explain how to achieve that: Cucumber Tests in Spring Boot with Dependency Injection

What is Cucumber?

Cucumber is a framework intended for Behavior-Driven-Development (BDD) that supports many different programming languages. BDD is an approach that defends writing first the requirements in human language (Behavior) and, only then, developing the code making it cover the described behaviors. To accomplish this, Cucumber created Gherkin, a DSL that the framework can interpret.

Gherkin builds a powerful bridge between Business Users -using this language to describe the requirements- and Developers -implementing the code behind every sentence of that human-understandable language. Let’s see an example of a Gherkin test definition:

Feature: Users are able to send their multiplication
  attempts, which may be correct or not. When users
  send a correct attempt, they get a response indicating
  that the result is the right one. Also, they get points
  and potentially some badges when they are right, so they
  get motivation to come back and keep playing. Badges are
  won for the first right attempt and when the user gets 100,
  500 and 999 points respectively. If users send a wrong
  attempt, they don't get any point or badge.

  Scenario: The user sends a first right attempt and gets a badge
    When the user john_snow sends 1 right attempts
    Then the user gets a response indicating the attempt is right
    And the user gets 10 points for the attempt
    And the user gets the FIRST_WON badge

  Scenario: The user sends a second right attempt and gets points only
    Given the user john_snow sends 1 right attempts
    And the user gets the FIRST_WON badge
    When the user john_snow sends 1 right attempts
    Then the user gets a response indicating the attempt is right
    And the user gets 10 points for the attempt
    And the user does not get any badge
Get the book Practical Software Architecture

How does it work?

The idea is that we organize our features into multiple .feature files (like the one above). In each of them, we include the description of the feature at the top of the file. Cucumber ignores the description, it’s just part of the documentation. Then, every feature has multiple scenarios, which technically are your different test case definitions. Finally, each scenario is defined by multiple steps, using the BDD keywords: Given, When, Then (plus And and But).

Each scenario (or test case definition) runs within its own context. This concept is very important to understand to implement our tests correctly. We can share the state between steps of the same scenario, but not across scenarios. For instance, we can save values returned by some steps and use them later to verify the whole flow, but we can’t use those values within a different scenario.

The steps can be parameterized using arguments. We can reuse the same step definition in multiple scenarios with different values, or we can also pass a data table to the same scenario. If we pass a data table, each row is called example.

Mapping Scenarios to Code

Let’s use this step to understand how arguments work:

When the user john_snow sends 1 right attempts

Gherkin itself does not have any tagging to specify which words are arguments. We define that at the code level. In this case, we’d like to pass to our step the user alias, the number of attempts and if they are right or wrong. Let’s have a look at how this step needs to be implemented in Java to support our arguments:

@Given("^the user ([^\\s]+) sends (\\d+) ([^\\s]+) attempts")
public void the_user_sends_attempts(final String userAlias, final int attempts, final String rightOrWrong) throws Throwable {
    // Implement the logic
}

We’ll have a look at the coding details in the following section. For now, you see that we only need to annotate our step method and use some regular expressions. Two word-expressions and a numeric one will do the trick in this particular case.

Combining Cucumber with other tools

Now that we know how to link the Features to Java code, how do we continue? Keep in mind that Cucumber is not giving you a full testing framework for Java, it’s just the link between your test definitions and code. To make our scenarios work, we should include in our project some other useful tools. In our case, we’ll introduce:

  • JUnit: since it's giving us the core testing support for Java.
  • AssertJ: I prefer its way of asserting results, but you could also use the JUnit style.
  • Apache Fluent HttpClient: because we want to keep it lightweight and at the same time have a straightforward way to perform requests to our microservices.
  • Jackson 2: we want to (de)serialize JSON requests/responses.

Note that, even though we’ll use Spring Boot for our microservices, we don’t need to use it for our test system.

Coding the solution with Java and Cucumber

Goal

It’s time to get into the code. I’ll use an existing microservices application, the one included in my book’s Learn Microservices with Spring Boot repository. If you’re wondering how the application works you can benefit from Cucumber and read the feature included above (cool!).

End to end tests for Spring Boot with Cucumber
End to end tests for Spring Boot with Cucumber
All the code in this post is available on GitHub, inside my book's repository version 9. If you find it useful, please give it a star!

The best way for you to get a good understanding is to clone or download that repository and experiment with it (don’t forget to give it a star!).

Project Structure

So, let’s assume you have the microservices application up and running. We want to check the different cases in which the user posts HTTP requests to one of the services (via UI and the gateway). Then, that service triggers an event. Finally, a different microservice will consume it and perform some extra operations. This is a typical End-to-End microservice test suite, exercising an Event-Driven architecture.

Cucumber tests with Gherkin
Cucumber tests with Gherkin

The E2E project is structured as shown in the picture:

  • Our JSON expected responses are modeled as simple beans (Stats, User, etc.)
  • The main bridge with the application is the MultiplicationApplication class. We use it to make our requests, etc., from the functional perspective (e.g. send an attempt instead of send HTTP POST).
  • The technical implementation to contact with our app is developed in ApplicationHttpUtils, which uses Apache Fluent HTTP.

Then we have the main classes modeling our tests (we have two features thus two tests): LeaderboardFeatureTest and MultiplicationFeatureTest. They are just entry points for the Cucumber runner to find the right .feature file, which is placed in the resources folder. We use two annotations here: @RunWith to use the Cucumber runner, and @CucumberOptions to specify the feature we want to execute and the reporting capabilities. We want to generate a JUnit-compatible report.

@RunWith(Cucumber.class)
@CucumberOptions(plugin = { "pretty", "html:target/cucumber", "junit:target/junit-report.xml" },
        features = "src/test/resources/multiplication.feature")
public class MultiplicationFeatureTest {
}

Step Implementation

Having understood all this, the only piece we miss is the implementation of the steps. We can complete the full story now and check how the steps trigger the Java methods within a fresh context per scenario, and how assertions make their part.

public class MultiplicationFeatureSteps {


    private MultiplicationApplication app;
    private AttemptResponse lastAttemptResponse;
    private Stats lastStatsResponse;

    public MultiplicationFeatureSteps() {
        this.app = new MultiplicationApplication();
    }

    @Before
    public void cleanUp() {
        app.deleteData();
    }

    @Given("^the user ([^\\s]+) sends (\\d+) ([^\\s]+) attempts")
    public void the_user_sends_attempts(final String userAlias, final int attempts, final String rightOrWrong) throws Throwable {
        int attemptsSent = IntStream.range(0, attempts)
                .mapToObj(i -> app.sendAttempt(userAlias, 10, 10, "right".equals(rightOrWrong) ? 100 : 258))
                .peek(response -> lastAttemptResponse = response) // store last attempt for later use
                .mapToInt(response -> response.isCorrect() ? 1 : 0)
                .sum();
        assertThat(attemptsSent).isEqualTo("right".equals(rightOrWrong) ? attempts : 0)
                .withFailMessage("Error sending attempts to the application");
    }

    @Then("^the user gets a response indicating the attempt is ([^\\s]+)$")
    public void the_user_gets_a_response_indicating_the_attempt_is(final String rightOrWrong) throws Throwable {
        assertThat(lastAttemptResponse.isCorrect())
                .isEqualTo("right".equals(rightOrWrong))
                .withFailMessage("Expecting a response with a " + rightOrWrong + " attempt");
    }

    @Then("^the user gets (\\d+) points for the attempt$")
    public void the_user_gets_points_for_the_attempt(final int points) throws Throwable {
        long attemptId = lastAttemptResponse.getId();
        Thread.currentThread().sleep(2000);
        int score = app.getScoreForAttempt(attemptId).getScore();
        assertThat(score).isEqualTo(points);
    }

    @Then("^the user gets the ([^\\s]+) badge$")
    public void the_user_gets_the_type_badge(final String badgeType) throws Throwable {
        long userId = lastAttemptResponse.getUser().getId();
        Thread.currentThread().sleep(200);
        lastStatsResponse = app.getStatsForUser(userId);
        List<String> userBadges = lastStatsResponse.getBadges();
        assertThat(userBadges).contains(badgeType);
    }

    @Then("^the user does not get any badge$")
    public void the_user_does_not_get_any_badge() throws Throwable {
        long userId = lastAttemptResponse.getUser().getId();
        Stats stats = app.getStatsForUser(userId);
        List<String> userBadges = stats.getBadges();
        if (stats.getScore() == 0) {
            assertThat(stats.getBadges()).isNullOrEmpty();
        } else {
            assertThat(userBadges).isEqualTo(lastStatsResponse.getBadges());
        }
    }

    @Given("^the user has (\\d+) points$")
    public void the_user_has_points(final int points) throws Throwable {
        long userId = lastAttemptResponse.getUser().getId();
        int statPoints = app.getStatsForUser(userId).getScore();
        assertThat(points).isEqualTo(statPoints);
    }

    public AttemptResponse getLastAttemptResponse() {
        return lastAttemptResponse;
    }

    public Stats getLastStatsResponse() {
        return lastStatsResponse;
    }

    public MultiplicationApplication getApp() {
        return app;
    }
}
Get the book Practical Software Architecture

Remarks

For the curious ones, two notes:

  • Did you notice the thread pausing at some point of our test? It's there because our system is Eventually Consistent. The second microservice needs some time to consume the event and complete the operation. The sleep() methods are there just to illustrate the eventual consistency so make sure you don't use those waits in a production-code test. They will make your test flaky (e.g. if you have a congested RabbitMQ server). Instead, you should implement a retry mechanism and a longer timeout.
  • If you have a look at the LeaderboardFeatureTest, you'll see how Cucumber steps can be reused across features using the cucumber-picocontainer dependency. It's pretty straightforward to understand: one of the beans in the (pico)context is injected in the other one.

Note that this approach tests the microservices from an external project that is not using Spring Boot. If you want to see an example of how to use Cucumber inside a Spring Boot application (e.g. for Integration Tests inside the service) you can have a look at this other post: Cucumber Tests in Spring Boot with Dependency Injection

If you want to learn many more topics about microservices with a real codebase (the one we used in this post), or you want more details about Cucumber, making your microservice testable, and other good practices, get the Learn Microservices with Spring Boot book.
Moisés Macero's Picture

About Moisés Macero

Software Developer, Architect, and Author.
Do you need help?

Amsterdam, The Netherlands https://thepracticaldeveloper.com

Comments