
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.
- Keeping the state between Cucumber steps
- Simulate user behavior in Cucumber
- Step definitions using Cucumber JVM
- Running a Cucumber feature with Java
- Cucumber reports
- What’s next?
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
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.

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.

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.

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
.
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.
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.

Comments