Getting started with Cucumber and JUnit 5 using Cucumber-JVM

I am a big fan of Test Driven Development (TDD) and when working on my own projects I will typically have a large integration test to cover the functionality I need, mainly to save me running Postman.
Being a backend Java developer I am very comfortable with Spring, well the easy stuff anyway. Throwing in those integration tests using a potpourri of annotations, some of which are probably not required and some that are probably well out of date. But the tests work, and everyone else is too terrified to change the annotations… just in case. So the code base ends up with out-of-date functionality getting more and more out of date.
The prime example of out of date code, poor design and an explosions of annotations are the Cucumber tests. The original code was written years ago, it is more than likely to be using the JUnit4 variant and it is wired together a menagerie of Cucumber and Spring annotations. You try and add another scenario, by cut and pasting an existing one and modifying it — easy. Then you get braver and add a new feature file, now IntelliJ can’t find your steps, the @Before step you thought was running is no longer running. Do you need to run the main app for this to work?
TL;DR
Check out the repo which is a sensible example of using cucumber and JUnit5; it is fairly self explanatory.
Introducing Cucumber JVM with JUnit 5
It looks like Cucumber JVM has been around for a while, I wasn’t really aware of this and just thought the Cucumber in my old repositories was the norm. When looking to create a good example for myself I found guides with any depth pretty thin on the ground. Hence, I’m hoping this guide will give you enough to get started.
I wrote this code to solve a problem: I need something to test several micro-services running with Eureka — basically to save me using Postman. I know, I’ll create a separate QA repository and use all the latest whizzy Cucumber ‘stuff’. So here goes…
Lets get started with Gradle
For those of your using Maven you’ll be able to get on with this no problem. I personally find Maven easier until you start to hit the 4000 line mark in the pom.xml, at which point you realise xml wasn’t really designed to be read by humans.
This part of the build.gradle file shows what you need to import by way of Cucumber and JUnit.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
testImplementation "io.cucumber:cucumber-java:${cucumberVersion}"
testImplementation "io.cucumber:cucumber-spring:${cucumberVersion}"
testImplementation "io.cucumber:cucumber-jvm:${cucumberVersion}"
// this will use junit 5 cucumber-junit uses Junit4
testImplementation "io.cucumber:cucumber-junit-platform-engine:${cucumberVersion}"
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.junit.platform:junit-platform-suite'
testImplementation 'org.assertj:assertj-core:3.23.1'
}The Cucumber-jvm is the crux of the of what you need, click on the link and you will get plenty of good examples.
Project Structure
To get started and make sure everything is ‘glued’ together we are going to write a test for the SpringBean class, which will demonstrate the basic setup even if you don’t want to test any external micro-services. We are also going to add tests for a translation service to demonstrate how you can run a separate set of tests and the setup required.

Start with the SpringBeanRunner
package com.ruppyrup.qa.runners.beanrunner;
import org.junit.platform.suite.api.ConfigurationParameter;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.IncludeTags;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME;
/**
* @Suite - annotation from JUnit 5 to make this class a run configuration for test suite.
* @IncludeEngines("cucumber") - tells JUnit 5 to use Cucumber test engine to run features.
* @SelectClasspathResource("features") - to change the location of your feature files (if you do not add this annotation classpath of the current class will be used).
* @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.mycompany.cucumber") - this annotation specifies the path to steps and config files - i.e. the top package name.
* @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "com.mycompany.cucumber") - this annotation specifies the path to any plugin.
*/
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.ruppyrup.qa")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "com.ruppyrup.qa.listeners.TestCaseStartedPlugin")
@IncludeTags({"SpringBean"})
public class SpringBeanRunner {
}Here are the bulk of the new annotations to get yourself going with Cucumber-jvm. I have described the annotations in the code above, and included the imports as it can be very frustrating trying to debug when one of your imports in wrong.
Key things to note are the @SelectClasspathResource("features") which will look in the src\test\resources\features directory for the feature files. You can leave this out and use the default package structure, but I always find it best for those reading the code if you include it.
Secondly, we have the GLUE_PROPERTY_NAME which will contain the your step definitions. Note that this is at a higher package level than our actual steps, because if we specified com.ruppyrup.qa.steps the tests would fail because the classes with the configurations annotations would not be found. Most examples place everything in the same package, so you normally end up with a lot of trial and error trying to get everything in the right package.
Last and probably least is the PLUGIN_PROPERTY_NAME which took me a while to work out. This specifies the path to a custom plugin you might want to add. I’ll not go into it in this article, but feel free to drop something like this into your code to impress your tech lead.
How does Cucumber know about Spring?
Enter our CucumberTestContextConfiguration class which will integrate the Cucumber and Spring contexts using the @CucumberContextConfiguration annotation.
import io.cucumber.spring.CucumberContextConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
/**
* @EnableDiscoveryClient - Used so this can find services from a Eureka service
* @CucumberContextConfiguration - Integrates the spring context with that of cucumber
* @SpringBootTest - Uses the spring context when running tests
*/
@EnableDiscoveryClient
@CucumberContextConfiguration
@SpringBootTest
public class CucumberTestContextConfiguration {
}We have included the @EnableDiscoveryClient annotation so that we can test services registered with Eureka; although @SpringBootTest would be sufficient for our SpringBean tests.
What about property files?
You have now pretty much tied everything together… what’s left? Well… Cucumber-jvm will look in the junit-platform.properties file for more configuration:
cucumber.execution.parallel.enabled=true // run in parallel
cucumber.publish.quiet=true // stop cucumber messages in the console
cucumber.plugin=html:target/results.html,message:target/results.ndjson // publish some resultsHere we are we have enabled parallel execution, reduced the messages and set up where the results get published to.
Start writing tests for SpringBean
Lets start with our CustomTest.feature file which will test the SpringBean code in the application. The SpingBean and Main Classes are below:
@Slf4j
@Component
public class SpringBean {
@Value("${jwtToken:oops}")
private String jwtToken;
@Value("${dbPassword:nopassword}")
private String dbPassword;
public String printProperties() {
log.info("Jwt Token = " + jwtToken);
log.info("Db password = " + dbPassword);
return dbPassword + jwtToken;
}
}Lombok fans will recognise the @Slf4j logging annotation, which we are using alongside the @Component annotation to create our Spring bean which will have the name springBean . All this class does is grab some properties using Spring’s @Value annotation and then print them out using the printProperties() method.
@SpringBootApplication
public class Main {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Main.class, args);
SpringBean bean = context.getBean(SpringBean.class);
bean.printProperties();
}
}The Main class just grabs the bean from the spring context and runs printProperties() .
I am purposefully not going to make huge assumptions about your Spring knowledge, but Spring will look in arguments, properties files, yaml files for these properties. If you just want to run it with IntelliJ you can just add vm options to the run configuration:

So, back to our feature file; lets write some Business Drive Development (BDD) tests the product owner would be proud of:
Feature: Custom Test
This feature provides a basic test
@SpringBean
Scenario: get the correct output from parameters
Given there the bean is created
When the message is produced
Then I will receive the correct messageThis is a basic BDD type test which will create the SpringBean, run the method to print the properties and then check the result. Because the cucumber glue is set up correctly, if you do write this first IntelliJ will ask you if you want to create each step and ask you where you want to put them.
To write the steps we will first create the add CustomSteps class in the steps package with the following contents:
public class CustomSteps {
private String resultMessage;
@Autowired
private SpringBean springBean; // This will at autowired in because I am using the @SpringBootTest in the CucumberTestContextConfiguration class
@Given("there the bean is created")
public void thereTheBeanIsCreated() {
assertThat(springBean).isNotNull(); // Checks the bean was autowired in
}
@When("the message is produced")
public void theMessageIsProduced() {
resultMessage = springBean.printProperties(); // Saves the result of printProperties to our class field resultMessage
}
@Then("I will receive the correct message")
public void iWillReceiveTheCorrectMessage() {
assertThat(resultMessage).isEqualTo("chimp99"); // Check the result is equal to our expected message 'chimp99' :/
}
}If you run that now you will get something along the lines of:
expected: "chimp99"
but was: "nopasswordoops"The printProperties() method combines the jwtToken and dbPassword together, but at the moment, it will just pick up the defaults from the @Value annotations, basically whatever is after the colon: @Value(“${jwtToken:oops}”.
We need to set these, and for test purposes we can add an application.yml to the resources folder in the test module. When running SpringBoot tests this file will be searched for this properties. So add the values you need like so:
jwtToken: 99
dbPassword: chimpRun the test again and it should pass.
So, how do we run the test?
There are several ways to run the test:
- Cucumber — IntelliJ should put a green play arrow in the gutter margin next to the scenario or feature. You can just click on this and the test will run.

2. Runners — IntelliJ will also place a little green arrow in the gutter of your runner class.

The first time you do this if you have Gradle set up to run your tests (which you should) then it will ask you to pick a test task if you have more than one — more on Gradle later:

3. Gradle — you can write your own test tasks then just run test or runAllCukesTest :
test { // this is the basic test
useJUnitPlatform() // using JUnit 5
}
tasks.register("runAllCukesTest", Test) { // Similar to above to shows how to add parameters if you aren't using the application.yml
systemProperty "jwtToken", "99"
systemProperty "dbPassword", "chimp"
useJUnitPlatform()
configure { // This will be this task into the verification group in the Gradle tasks
group = 'verification'
}
}Yay, it works
So that should work for you as a nice basic set up, if not, clone the repo and make sure you have the correct imports etc.
So life is rarely that simple, in the next section we will go through the steps to start testing external services just like a real QA repo.
Strap yourself in… the more advanced stuff
The original problem I was trying to solve was to create a QA BDD repo to test external services, some registered with Eureka and some not. If you aren’t sure about Eureka there is a good tutorial here: Spring Boot: Eureka Server, but is not essential to the remainder of the article.
Back to our CucumberTestContextConfiguration
@EnableDiscoveryClient
@CucumberContextConfiguration
@SpringBootTest
public class CucumberTestContextConfiguration {
}Confession… you don’t really need @EnableDiscoverClient these days so long as you have spring-cloud-starter-netflix-eureka-client in your dependencies. However, it gives whoever is reading the code a big clue as two that it will be using Eureka.
Since we want to connect to both a service registered with Eureka and a service directly we will need two RestTemplates. Our @SpringBootTest will bring in a RestTemplate for us, but will need to create two beans in our TestConfig class.
Bean me up :/
@Configuration
public class TestConfig {
@ScenarioScope
@Bean(name = "testDataEnhancer")
public TestData testDataEnhancer() {
return new TestData();
}
@LoadBalanced
@Bean(name = "loadBalancedRestTemplate")
public RestTemplate customRestTemplate() {
return new RestTemplate();
}
@Bean(name = "normalRestTemplate")
public RestTemplate normalRestTemplate() {
return new RestTemplate();
}
}This is just a standard Spring configuration class… the @Configuration gives it away. This configuration class will be picked up by our @SpringBootTest and the beans created. The normalRestTemplate bean is just a basic RestTemplate and will connect to our non-eureka service, the loadBalancedRestTemplate has the @LoadBalanced annotation which means our url that we are using to connect to the Eureka registered service will be intercepted and replaced with a url obtained from Eureka.
For the normal service our url would be: http://localhost:8040/translation , for our Eureka url it would be http://translation-service/translation ; which naturally means we don’t actually have to know the actual url and can communicate with multiple services without knowing it. The @LoadBalanced annotation will replace translation-service with localhost:8040 if we have just one instance running.
So what is that TestDataEnhancer bean?
Oh that, well if you recall we had a variable called resultMessage in our CustomSteps class:
private String resultMessage;Now this is fine for what we wanted, but if we had more scenarios, and want to run them in parallel, as we are (recall our settings in junit-platform.properties ) then both scenarios will be sharing the same resultMessage which will then lead to errors.
The testDataEnhancer bean has cucumber’s @ScenarioScope annotation which means a new one will be created for each scenario. It is basically a hashmap for containing all your scenario variables… even your restTemplates! Here is the class:
public class TestData {
private final Map<String, Object> senarioData = new HashMap<>();
public void setData(String key, Object value) {
senarioData.put(key, value);
}
public <T> T getData(String key, Class<T> clazz) {
return clazz.cast(senarioData.get(key));
}
}This is my go to class for storing scenario variables, feel free to ‘borrow’ it.
Write the steps for our translation service tests
@Translation
Feature: Translator api tests
This feature provides happy path tests for the translator api
Scenario: can translate a message
Given the url with scheme "http", host "localhost", port 8040 and path "translation" can be tested
When a POST request is sent to the url with body "Hello Rupert's from new blog"
Then a response is received with status code 200
And the response "Bonjour Rupert's de nouveau blog" is received
@Eureka
Scenario: can translate a message from a eureka instance
Given the url with scheme "http", host "translation-service", port 0 and path "translation" can be tested
When a POST request is sent to the url with body "Hello Rupert's from new blog"
Then a response is received with status code 200
And the response "Bonjour Rupert's de nouveau blog" is receivedHere are the TranslatorHappyPath.feature BDD tests. The feature is marked with the @Tranlation tag, so we can pick up these scenarios in our specific translation runner. The first scenario will make a POST request to the url in the Given step and confirm a HttpStatus 200 response is received.
The scenario marked with @Eureka will do the same thing, but we will use the service name translation-service rather than localhost:8040 . We need to differentiate these two scenarios because they need to use different restTemplate beans, and for this we’ll use the @Eureka in our step definitions class.
That brings us to the Step class for this feature
@Slf4j
public class ExternalApiSteps {
@Autowired
private TestData testDataEnhancer; // brings in our scenario data store
/** Use the application context to obtain the restTemplate beans, if we autowired the restTemplate beans we would be both beans in a list
* then have to distinguish between the two... this seemed easier.
**/
@Autowired
ApplicationContext ctx;
/**
* This will run Before each scenario, but we can also bring the scenario in as an argument and use it to find any tags / annotations the
* scenario has. Then if we find the @Eureka tag we set the loadbalancedResttemplate as the restTemplate for the scenario
* @param scenario
*/
@Before
public void setRestTemplate(Scenario scenario) {
boolean containsEurekaTag = scenario.getSourceTagNames().stream().anyMatch("@Eureka"::equals);
if (containsEurekaTag) {
testDataEnhancer.setData("restTemplate", ctx.getBean("loadBalancedRestTemplate", RestTemplate.class));
} else {
testDataEnhancer.setData("restTemplate", ctx.getBean("normalRestTemplate", RestTemplate.class));
}
}
/**
* Setting the url data
* @param scheme http or https
* @param host hostname
* @param port port
* @param path rest path
*/
@Given("the url with scheme {string}, host {string}, port {int} and path {string} can be tested")
public void theCanBeTested(String scheme, String host, Integer port, String path) {
testDataEnhancer.setData("scheme", scheme);
testDataEnhancer.setData("host", host);
testDataEnhancer.setData("port", port);
testDataEnhancer.setData("path", path);
log.info("The url to be tested :: " + scheme + "://" + host + ":" + port + "/" + path);
}
/**
* Checks the response from our request is the same as the one required in the tests
* @param requiredResponse
*/
@Then("the response {string} is received")
public void theResponseIsReceived(String requiredResponse) {
log.info("Checking the response is received ::" + requiredResponse);
ResponseEntity<String> response = testDataEnhancer.getData("response", ResponseEntity.class);
assertThat(response.getBody()).isEqualTo(requiredResponse);
}
/**
* Post request to our url
* @param body
*/
@When("a POST request is sent to the url with body {string}")
public void aPOSTRequestIsSentToTheUrlWithBodyMessageHelloFromRupert(String body) {
log.info("Performing POST request to the translation service");
List<MediaType> mediaTypes = new ArrayList<>();
mediaTypes.add(MediaType.APPLICATION_JSON);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(mediaTypes);
HttpEntity<String> entity = new HttpEntity<>(body, headers);
UriComponents uriComponents = UriComponentsBuilder.newInstance()
.scheme(testDataEnhancer.getData("scheme", String.class))
.host(testDataEnhancer.getData("host", String.class))
.port(testDataEnhancer.getData("port", Integer.class))
.path(testDataEnhancer.getData("path", String.class))
.build();
RestTemplate restTemplate = testDataEnhancer.getData("restTemplate", RestTemplate.class);
var responseEntity = restTemplate.postForEntity(uriComponents.toString(), entity, String.class);
testDataEnhancer.setData("response", responseEntity);
}
/**
* Check the status code of the response
* @param statusCode
*/
@Then("a response is received with status code {int}")
public void aResponseIsReceivedWithStatusCode(int statusCode) {
ResponseEntity<String> response = testDataEnhancer.getData("response", ResponseEntity.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(statusCode));
}
}There’s a bit more code here, but it is just making rest calls, storing and retrieving into from our testDataEnhancer i.e. the hashmap. You can see the use of the @Before step to check for the @Eureka annotation and save the select the restTemplate accordingly.
Lets create the runner class
We are going to create a separate runner for our translation service, mainly because these tests are in no way related to the SpringBeanRunner . This runner is very simple… at first sight.
@CukeAnnotation
@IncludeTags("Translation")
public class TranslationRunner {
}Here we can see the use of @IncludeTags("Translation") which will just pick up any features/scenarios with the @Tranlation annotation. You can also pass a list of tags, not just the one.
The @CukeAnnotation is something I created… couldn’t resist. It just combines the other annotations from the other runner. If you have a lot of runners, it looks a bit neater.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.ruppyrup.qa")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "com.ruppyrup.qa.listeners.TestCaseStartedPlugin")
public @interface CukeAnnotation {
}I promised you some Gradle
Chances are your deployment pipeline will be running gradle or maven tasks, so you really want a bit of control of what tests are being run and when. Here are some custom tasks from the full build.gradle.
tasks.register("runTranslationCukesTest", Test) {
include "**/com/ruppyrup/qa/runners/translationrunner/*"
useJUnitPlatform()
configure {
group = 'verification'
}
}
tasks.register("runSpringBeanCukesTest", Test) {
exclude "**/com/ruppyrup/qa/runners/translationrunner/*"
useJUnitPlatform()
configure {
group = 'verification'
}
}The main difference between these tasks and the previously shown runAllCukesTest are the use of include and exclude which work at a directory level. Hence the reason we put the runners into different packages.
That’s all folks
I hope that helps you get somewhere with cucumber-jvm and JUnit5. Please clone the repo, modify and keep it in your back pocket for when you need to do something with cucumber. Thanks for making it through to the end.
If you are hungry for more, check out my story: Create a non-blocking REST Api using Spring @Async and Polling.
Thinking about changing jobs for non-FAANG companies try: Aim low! Get that non-FAANG Java developer job now. By Average Java Joe.






