A Practical Example of Cucumber's Step Definitions in Java

A Practical Example of Cucumber's Step Definitions in Java

This third section of the guide focuses on the Step Definition Java classes. We use the real example project, and map the Gherkin sentences to Java methods with the so-called Step Definition files. You’ll learn about Cucumber’s built-in Parameter Types and you’ll create your own one. Besides, we’ll put into practice the best practices to keeping the state in a separate abstraction layer.

> The Cucumber Java Guide
GitHub
Learn how to build end-to-end tests with Java and Cucumber, using this step-by-step tutorial with practice code examples. This guide is part of the book's extra chapters.
Part 3. Step definitions and keeping the state in Cucumber (this article)
Part 3 - Table of Contents

Keeping the state between Cucumber steps

When writing Cucumber tests in Java, it’s common to use the Step Definition class to keep the state within the multiple methods that take part in the execution. In our case, let’s use the scenario in Listing 1 as an example.

  Scenario: Users solve challenges, they get feedback and their stats.
    Given a new user John
    And he requests a new challenge
    When he sends the correct challenge solution
    Then his stats include 1 correct attempt

Listing 1. A Cucumber test scenario that requires saving the state

Remember: All the code in this post is available on GitHub: Book - Cucumber Tests. If you find it useful, please give it a star!

In this test case, we introduce the alias of the user in the first Given sentence. Later, we refer to the user with a pronoun. This implies that we have to save the alias between the steps, because we’ll need it within the scope, for example, of the Java method that maps the sentence “his stats include 1 correct attempt”. See Listing 2.

public class ChallengeStepDefinitions {

    private String userAlias;
    private ApiClient apiClient;

    // constructor

    @Given("a new user {word}")
    public void aNewUser(String user) {
        this.userAlias = user;
    }

    // ... (other methods)

    @Then("her/his stats include {int} {correct} attempt(s)")
    public void statsIncludeAttempts(int attemptNumber, boolean correct) {
        var stats = this.apiClient.getStats(this.userAlias);
        // verify that the stats contain the expected number of (in)correct attempts
    }
}

Listing 2. We can keep the state between Cucumber steps in instance variables

Without diving into details of the Cucumber expressions (we’ll do that later), you can see in this code block how we use the first step to store the user’s alias in an instance variable, at the class level. The step annotated with @Then assumes that the former step has been executed before, and it gets the userAlias variable value from the instance. This approach works because Cucumber uses the same Step Definition instances for the complete scenario.

Note: You can’t share state across scenarios because Cucumber instantiates new step definition objects. This is good. Sharing state between test cases is a bad practice since you’re making them depend on each other. It could also lead to undesired situations when, for example, you run your test scenarios in parallel. If you really need it, consider using Dependency Injection.

The approach shown in the code snippet above is a valid one, but it doesn’t scale that well. Imagine that you want to test features that use multiple actors, each one with a different state. As an example, to test the Leaderboard feature, we want to send multiple challenges from multiple users. In principle, that’s not an issue since we could use a Java map to keep the state for each user alias (keys) to their corresponding stats (values). But, what if we need to store also the last challenge that each user has sent? Hmm, maybe we need another map. And, what if we need to set a similar state with multiple challenges and users in a different Step Definition class (which we created separately to make our test project modular and follow a domain approach)? In that case, we could be tempted to copy all these maps there as well.

Instead of replicating the state structures in each step definition class, we can introduce an abstraction layer that models the possible interactions with your system and their state, and reuse them where needed. That’s what we’ll do in the next section.

Learn Microservices with Spring Boot - Second Edition

Simulate user behavior in Cucumber

We can create two different classes to model the interactions with both domains in our system: Challenges, and Gamification. These actor classes will contain methods that simulate domain interactions, and also keep the last state for us to verify their values in the test assertions.

The Challenge class

The process of asking for a new multiplication challenge and sending an attempt to solve it will be captured by the class Challenge. See Listing 3. Read the notes below for a better understanding of what this class does.

package microservices.book.cucumber.actors;

import java.util.List;
import java.util.UUID;

import microservices.book.cucumber.api.APIClient;
import microservices.book.cucumber.api.dtos.challenge.AttemptRequestDTO;
import microservices.book.cucumber.api.dtos.challenge.AttemptResponseDTO;
import microservices.book.cucumber.api.dtos.challenge.ChallengeDTO;

import static org.assertj.core.api.Assertions.*;

public class Challenge {

    private final String userName;
    private final String originalName;
    private final APIClient apiClient;
    private long userId;
    private ChallengeDTO currentChallenge;

    public Challenge(String userName) {
        this.userName = userName + "-" + UUID.randomUUID().toString();
        this.originalName = userName;
        this.apiClient = new APIClient();
    }

    public void askForChallenge() throws Exception {
        var challenge = apiClient.getChallenge();
        assertThat(challenge.statusCode()).isEqualTo(200);
        this.currentChallenge = apiClient.getChallenge().body();
    }

    public void solveChallenge(boolean correct) throws Exception {
        assertThat(this.currentChallenge)
                .as("You have to get a challenge first").isNotNull();
        var attemptResponse = apiClient.sendAttempt(
                AttemptRequestDTO.solve(this.currentChallenge, 
                        correct, this.userName)
        );
        assertThat(attemptResponse.statusCode()).isEqualTo(200);
        this.userId = attemptResponse.body().getUser().getId();
    }

    public List<AttemptResponseDTO> retrieveStats() throws Exception {
        var stats = apiClient.getStats(this.userName);
        assertThat(stats.statusCode()).isEqualTo(200);
        return stats.body();
    }

    public ChallengeDTO getCurrentChallenge() {
        return currentChallenge;
    }

    public String getOriginalName() {
        return originalName;
    }

    public long getUserId() {
        return userId;
    }
}

Listing 3. The Challenge actor class model interactions and keep state

This class encapsulates an APIClient object, so we don’t need to make network calls from our step definition files but just call the actor’s methods:

  • askForChallenge gets a new multiplication challenge and stores it in an instance variable as the current state. There is also a getter for this property (getCurrentChallenge), so we can use it from the test scripts.
  • solveChallenge is a convenient method to quickly send an attempt which can be correct or incorrect. Note that the logic to send the corresponding result for a challenge was already included inside the AttemptRequestDTO class.
  • retrieveStats contacts the server to get the current statistics for the user and returns them to the caller.

Additionally, this class also abstracts the randomization of the user alias. In end-to-end tests, we should avoid reusing the same actors (e.g. users) because we probably keep their state on the backend side (e.g. a database). If we would use the same user alias in all our test cases, we would need to give access from the backend to the client (cucumber tests) to delete all previous data for that user, to make sure we run tests with a clean state. To avoid this, you can simply create unique users all the time. This way, you can just wipe the complete test databases when you want to do the cleaning. To abstract the randomization and pretend that we use the alias passed to the Challenge class, we keep both the original username (originalName) and the randomized reference (userName).

Since our actor class deals with HTTP requests and responses, we can include assertions to verify if the status code is OK before unpacking the response as an object. In Listing 3 you can see how we included these assertions in each method calling the REST APIs. This is a good practice because in case the server fails, we’ll get a more-friendly message than if we leave it to fail with an NPE when calling body(), for example.

Learn Microservices with Spring Boot - Second Edition

The Leaderboard class

The second actor in our tests is the Leaderboard class. See Listing 4 and the corresponding description below.

package microservices.book.cucumber.actors;

import java.util.List;
import java.util.Optional;

import microservices.book.cucumber.api.APIClient;
import microservices.book.cucumber.api.dtos.leaderboard.LeaderboardRowDTO;

import static org.assertj.core.api.Assertions.*;

public class Leaderboard {

    private final APIClient apiClient;
    private List<LeaderboardRowDTO> leaderboard;

    public Leaderboard() {
        apiClient = new APIClient();
    }

    public Leaderboard update() throws Exception {
        var leaderboard = apiClient.getLeaderboard();
        assertThat(leaderboard.statusCode()).isEqualTo(200);
        this.leaderboard = leaderboard.body();
        return this;
    }

    public int whatPosition(long userId) {
        for (int i = 0; i < leaderboard.size(); i++) {
            if (leaderboard.get(i).getUserId() == userId) {
                return i + 1;
            }
        }
        return -1;
    }

    public Optional<LeaderboardRowDTO> getByUserId(long userId) {
        return leaderboard.stream()
                .filter(row -> row.getUserId() == userId).findAny();
    }
}

Listing 4. The Leaderboard class models the interaction with the Gamification API

This class also uses an APIClient to retrieve the leaderboard and store it locally. We call the method update(). Note that the leaderboard is the same for everybody, so doesn’t depend on the user.

We added two useful methods that, based on a user identifier, retrieve the ranking position (whatPosition) or the leaderboard row (getByUserId) respectively. We’ll use them in our tests.

Now that we completed our Actor Abstraction Layer, we can keep our step definitions simple. Let’s see how.

Step definitions using Cucumber JVM

On one hand, we have the Gherkin files defining the features. On the other hand, we already implemented the API Client and encapsulated it in two Actor classes to simulate the User’s behavior and the interaction with the leaderboard. We’re only missing the mapping between the feature scripts and their corresponding Java Step Definitions.

In the first part of this guide, we introduced Gherkin, and we had a quick look at how Cucumber Expressions map each scenario step to Java methods in the Step Definition classes. Part 2 defined the Gherkin features of our practical use case. It’s time to put into practice the concepts about mapping Gherkin steps to Java methods using Cucumber expressions.

First, we’ll create a new class ChallengeStepDefinitions. In this class, we’ll include the mapping for all the steps required to interact with the Challenge domain. See Listing 5. We’ll describe the main concepts introduced in this file soon.

package microservices.book.cucumber.steps;

import io.cucumber.java.ParameterType;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;

import microservices.book.cucumber.actors.Challenge;

import static org.assertj.core.api.Assertions.*;

public class ChallengeStepDefinitions {

    private Challenge challengeActor;

    @Given("a new user {word}")
    public void aNewUser(String user) {
        this.challengeActor = new Challenge(user);
    }

    @When("(s)he requests a new challenge")
    public void userRequestsANewChallenge() throws Exception {
        this.challengeActor.askForChallenge();
    }

    @Then("(s)he gets a mid-complexity multiplication to solve")
    public void getsAMidComplexityMultiplicationToSolve() {
        assertThat(this.challengeActor.getCurrentChallenge().getFactorA())
                .isBetween(9, 100);
        assertThat(this.challengeActor.getCurrentChallenge().getFactorB())
                .isBetween(9, 100);
    }

    @When("(s)he sends the {correct} challenge solution")
    public void userSendsTheCorrectChallengeSolution(boolean correct) throws Exception {
        this.challengeActor.solveChallenge(correct);
    }

    @Then("her/his stats include {int} {correct} attempt(s)")
    public void statsIncludeAttempts(int attemptNumber, boolean correct) throws Exception {
        var stats = this.challengeActor.retrieveStats();
        assertThat(stats)
                .filteredOn("correct", true)
                .hasSize(attemptNumber);
    }

    @ParameterType("correct|incorrect")
    public boolean correct(String input) {
        return "correct".equalsIgnoreCase(input);
    }

}

Listing 5. The ChallengeStepDefinitions class provides steps to interact with the Challenge domain

Keep step definition classes simple

When comparing this implementation to the draft we saw in Listing 2, the first thing we notice is that we’re using now the proper level of abstraction. This time, we keep the state as a single challengeActor instance field. Adding this layer also helped us keep this class concise and simple, and focused on its main responsibility: mapping Gherkin steps to Java methods.

Cucumber expressions

We set the state within the first method, annotated with @Given. It uses the mapping:

a new user {word}

This text pattern to match steps is a Cucumber expression. Any Gherkin step that begins with a new user and is followed by a single word will be mapped to this Java method. In this expression, {word} is a Cucumber parameter. The framework captures that word and passes it to our method, so we must define in this case one argument of type String.

Parameter types

Besides word, there are other built-in parameter types that you can use in Java: int, float, string, bigdecimal, long, etc. When using them, you need to match your method argument types accordingly. If you need to capture text in a more advanced way, Cucumber also supports Regular Expressions.

In our example, we also defined a Custom Parameter Type, correct. This method in Listing 5 defines it:

    @ParameterType("correct|incorrect")
    public boolean correct(String input) {
        return "correct".equalsIgnoreCase(input);
    }

As you see, you can define your own types using the Cucumber annotation @ParameterType and a regular expression (correct|incorrect). You could also use explicit annotation parameters to define the regular expression and the parameter name. When the name is not provided, it’s set by default to the method name, in our case correct. Once defined, we can use it in our expressions, for example in this method:

    @When("(s)he sends the {correct} challenge solution")
    public void userSendsTheCorrectChallengeSolution(boolean correct) throws Exception {
        this.challengeActor.solveChallenge(correct);
    }

You may prefer to use built-in parameters, but that can make your Gherkin scenarios less readable. In our example, we could use a plain boolean, but the sentence should be rephrased to something like she sends a true challenge solution. Not readable at all.

Learn Microservices with Spring Boot - Second Edition

Annotation interchangeability

The methods that implement Cucumber Expressions to match Gherkin steps must be preceded by one of the annotations @Given, @When, @Then, @And, or @But. However, the specific annotation that you use is irrelevant for Cucumber. The framework will load all definitions and will try to map steps no matter which preposition or adverb you use.

In our specific example case, this means we could use any of these sentences, and all of them would be mapped to the same step definition:

  • Given she sends the correct challenge solution
  • And she sends the correct challenge solution
  • * she sends the correct challenge solution
  • etc.

You can use, for example, the annotation that matches the first sentence where you use the step.

Assertions

As we planned in Part 1, we’re using AssertJ with Cucumber to get extra assertion capabilities and (in my opinion) improve the readability of the code. To know more about it, you can save this other post for later.

In Cucumber step definitions, the assertions should be mostly limited to the methods that verify the expected outcome (those usually annotated with @Then).

We use a simple assertion to verify that factors are between the expected limits:

assertThat(this.challengeActor.getCurrentChallenge().getFactorA())
        .isBetween(9, 100);

And we also use a more sophisticated one to verify that the number of successful attempts in the user’s history matches what we expect:

assertThat(stats)
        .filteredOn("correct", true)
        .hasSize(attemptNumber);

In the next part of this Guide, we’ll see how to combine AssertJ and Awaitility to run assertions on an Eventually Consistent system.

Running a Cucumber feature with Java

We completed the implementation of all the step definitions we need to run the “Solving Challenges” feature. Remember that we defined this feature using Gherkin in Part 2 (you can also check the code on GitHub). This means we can already run these test scenarios.

First, you need to run the backend system. Otherwise, you don’t have anything to test :) The easiest way to do this is to download the docker-compose-public.yml file from the book repositories, and then execute it using Docker Compose.

$ docker-compose -f docker-compose-public.yml up

Depending on your computer resources, it could take one or a few minutes to initiate the complete set of microservices and surrounding tools. For more instructions about how to set up the backend system, check out the README file in the repository.

If everything goes well, the backend should be accessible via the Gateway at localhost:8000. The Docker Compose file also includes the frontend, so you can play a bit if you navigate with your browser to localhost:3000.

Now, to run our Cucumber tests for the first time, we can use our IDE or a plain Maven command. See Listing 6 for the examples of how to run the tests using the Maven wrapper included in the GitHub repo.

# In Mac/Linux
$ ./mvnw test
# In Windows
> mvnw.cmd test

Listing 6. Running Cucumber tests using the Maven Wrapper

The output of this command should contain something similar to what is shown in Listing 7.

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running microservices.book.RunCucumberTest

Scenario: Users get points and badges when solving challenges, and they # microservices/book/leaderboard.feature:4
  Given the following solved challenges                                 # null
  Then Karen has 50 points                                              # null
  * Karen has the "First time" badge                                    # null
  And Laura has 70 points                                               # null
  * Laura has the "First time" badge                                    # null
  * Laura has the "Bronze" badge                                        # null
  And Laura is above Karen in the ranking                               # null

Scenario: Users get new attempts.                        # microservices/book/solving_challenges.feature:6
  Given a new user Mary                                  # microservices.book.cucumber.steps.ChallengeStepDefinitions.aNewUser(java.lang.String)
  When she requests a new challenge                      # microservices.book.cucumber.steps.ChallengeStepDefinitions.userRequestsANewChallenge()
  Then she gets a mid-complexity multiplication to solve # microservices.book.cucumber.steps.ChallengeStepDefinitions.getsAMidComplexityMultiplicationToSolve()

Scenario: Users solve challenges, they get feedback and their stats. # microservices/book/solving_challenges.feature:11
  Given a new user John                                              # microservices.book.cucumber.steps.ChallengeStepDefinitions.aNewUser(java.lang.String)
  And he requests a new challenge                                    # microservices.book.cucumber.steps.ChallengeStepDefinitions.userRequestsANewChallenge()
  When he sends the correct challenge solution                       # microservices.book.cucumber.steps.ChallengeStepDefinitions.userSendsTheCorrectChallengeSolution(boolean)
  Then his stats include 1 correct attempt                           # microservices.book.cucumber.steps.ChallengeStepDefinitions.statsIncludeAttempts(int,boolean)

Scenario: Users get feedback also about incorrect attempts. # microservices/book/solving_challenges.feature:17
  Given a new user Horatio                                  # microservices.book.cucumber.steps.ChallengeStepDefinitions.aNewUser(java.lang.String)
  When he requests a new challenge                          # microservices.book.cucumber.steps.ChallengeStepDefinitions.userRequestsANewChallenge()
  * he sends the incorrect challenge solution               # microservices.book.cucumber.steps.ChallengeStepDefinitions.userSendsTheCorrectChallengeSolution(boolean)
  * he sends the correct challenge solution                 # microservices.book.cucumber.steps.ChallengeStepDefinitions.userSendsTheCorrectChallengeSolution(boolean)
  Then his stats include 1 correct attempt                  # microservices.book.cucumber.steps.ChallengeStepDefinitions.statsIncludeAttempts(int,boolean)
  * his stats include 1 incorrect attempts                  # microservices.book.cucumber.steps.ChallengeStepDefinitions.statsIncludeAttempts(int,boolean)

Tests run: 4, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 3.17 sec <<< FAILURE!

Listing 7. Cucumber test results when running them from the command line

Cucumber runs four scenarios: the three included in the Solving Challenges features, but also the scenario we added to the Leaderboard feature. Therefore, it outputs some failures because we didn’t implement the step definitions for this second feature yet. It even generates some code snippets that we could copy and paste in a new class as a baseline (see Listing 8).

io.cucumber.junit.UndefinedStepException: The step "the following solved challenges" is undefined. You can implement it using the snippet(s) below:

@Given("the following solved challenges")
public void the_following_solved_challenges(io.cucumber.datatable.DataTable dataTable) {
    // Write code here that turns the phrase above into concrete actions
    // For automatic transformation, change DataTable to one of
    // E, List<E>, List<List<E>>, List<Map<K,V>>, Map<K,V> or
    // Map<K, List<V>>. E,K,V must be a String, Integer, Float,
    // Double, Byte, Short, Long, BigInteger or BigDecimal.
    //
    // For other transformations you can register a DataTableType.
    throw new io.cucumber.java.PendingException();
}

Some other steps were also undefined:

@Then("Karen has {int} points")
public void karen_has_points(Integer int1) {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@Then("Karen has the {string} badge")
public void karen_has_the_badge(String string) {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@Then("Laura has {int} points")
public void laura_has_points(Integer int1) {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@Then("Laura has the {string} badge")
public void laura_has_the_badge(String string) {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@Then("Laura is above Karen in the ranking")
public void laura_is_above_karen_in_the_ranking() {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}

Listing 8. Cucumber helps us create the missing step definitions

As we can see, Cucumber provides not only some basic auto-generated code but even some comments about the usage of DataTable. Besides, it identified correctly some potential Cucumber parameters. It missed the user names though, so it created duplicated methods with missing arguments. That can be fixed easily. You could use this approach if you like it, and generate the code based on your Gherkin features, but remember to structure the steps following your system domains, and don’t put all of them together in the same class.

In any case, let’s come back to the test results. The three scenarios included in our first Solving Challenges feature were executed successfully, and they all passed. If you have a decent terminal, they’ll be also colored in green.

The output in the terminal where you’re running the Docker Compose file shows some interactions with the backend system. Moreover, you can see the leaderboard if you access from your browser to the main page at localhost:3000.

Figure 1. The application's frontend

That’s already a great result! All scenarios in our first feature are passing, so our system works as we expected and described in our Feature file.

Fake it until you make it

One of the key aspects in BDD and Cucumber is that you should go through the Discovery phase first of all, and then write your features before writing the implementation. As described in Part 1, the book doesn’t follow this BDD approach because it’s impossible to “simulate a discussion” within the book, and it would distract the reader too early from the main topics.

Next time you’re defining new features in your project, try a real BDD approach. If that’s not possible, you can also use Cucumber anyway to run your end-to-end tests. Even after the implementation is in place, you may find scenarios that you didn’t cover in your code yet.

Cucumber reports

Cucumber allows us to publish an online HTML version of the test results report for free. To enable this, we just need to add a property in a new cucumber.properties file that we can place inside the src/main/resources directory. See Listing 9.

cucumber.publish.enabled=true

Listing 9. Enabling Cucumber online reports

To generate the report, we just need to run our tests again. This time, the output will include a link that we can use to access the results online. See Figure 2 for the HTML report generated from my laptop.

Figure 2. A Cucumber report available online

As we can see, it shows how three tests (scenarios) passed and one of them is undefined (steps were not implemented yet). It also gives some context about the system where we ran the tests. Below the summary, we can find the details of the scenarios that passed, and those which didn’t.

These reports look nice and are a good way to make your test results accessible for others, especially when you run the tests from a CI system. However, keep in mind that anyone with the link can access them (although they’re difficult to guess).

What’s next?

In the next section, we’ll implement the step definitions for our second feature. These come with a big challenge: the system under test is Eventually Consistent. When a user sends a successful attempt, it might take some time until the score and badges are updated in the corresponding Gamification microservice, because it involves an asynchronous process that works based on an Event-Driven Architecture. We don’t want to write tests with delays that could fail now and then, so we’ll smartly cope with this.

Learn Microservices with Spring Boot - Second Edition
This article is part of a guide:
> The Cucumber Java Guide
GitHub
Learn how to build end-to-end tests with Java and Cucumber, using this step-by-step tutorial with practice code examples. This guide is part of the book's extra chapters.
Part 3. Step definitions and keeping the state in Cucumber (this article)
Moisés Macero's Picture

About Moisés Macero

Software Developer, Architect, and Author.
Are you interested in my workshops?

Málaga, Spain https://thepracticaldeveloper.com

Comments