avatarRupert Waldron

Summary

The provided content is a comprehensive guide on integrating Cucumber with JUnit 5 for testing Java applications, particularly those using Spring Boot and microservices architecture.

Abstract

The article offers a detailed walkthrough on setting up Cucumber with JUnit 5 for behavior-driven development (BDD) in Java. It addresses the challenges of outdated test setups and demonstrates how to create a modern, maintainable testing framework. The author, a fan of Test Driven Development (TDD), shares their experience in overcoming the complexities of integrating Cucumber with Spring and Eureka for testing microservices. The guide covers Gradle configuration, project structure, Cucumber setup with Spring context, property file usage, writing BDD tests, and running tests in various ways. It also includes advanced topics such as parallel test execution, scenario-specific bean configuration, and testing services registered with Eureka. The article concludes with custom Gradle tasks for selective test execution and encourages readers to use the provided examples for their own projects.

Opinions

  • The author emphasizes the importance of keeping test code up-to-date and suggests that Cucumber with JUnit 5 is a superior alternative to older testing frameworks.
  • There is a clear preference for Gradle over Maven for test setup, especially for larger projects.
  • The author expresses that the use of annotations and proper configuration is crucial for a successful Cucumber and Spring integration.
  • The article implies that using Spring's @Value annotation for property injection is a common and useful practice for testing.
  • The author provides a subjective opinion on the ease of use of Lombok's @Slf4j annotation for logging purposes.
  • There is a subtle critique of the fear developers might have when updating annotations in legacy test code.
  • The author suggests that the @LoadBalanced annotation simplifies the process of connecting to services registered with Eureka.
  • The use of custom plugins and the junit-platform.properties file is recommended for enhancing test execution and reporting.
  • The article promotes the idea of using a hashmap (TestDataEnhancer) to store scenario-specific variables when running tests in parallel.
  • The author encourages the use of custom Gradle tasks to provide more control over the test execution process.
  • A personal anecdote about the usefulness of the guide's approach is shared, indicating the author's confidence in their methodology.
  • The author endorses their own GitHub repository as a resource for readers to reference and learn from.

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 results

Here 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:

Main 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 message

This 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: chimp

Run the test again and it should pass.

So, how do we run the test?

There are several ways to run the test:

  1. 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.
CustomTest.feature

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

SpringBeanRunner 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:

Chose the gradle task to run the runner

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 received

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

Cucumber
Java
Spring
Eureka
Rest Api
Recommended from ReadMedium