Loading

Spring Boot 3, JUnit5, Cucumber & Testcontainers

Last Updated on 25/03/2024 by Grant Little

Overview

In this article we will cover combining Spring Boot 3, JUnit 5, Cucumber and Test containers so you can create good quality integration tests.

For many years I’ve been using Spring Boot, JUnit and Cucumber. This combination has been around for many years. It provides some great building blocks for quickly writing microservices based on BDD (Behaviour Driven Development) & TDD (Test Driven Development).

As an approach, I nearly always prefer to use TDD. BDD is just one element of this, but provides the ability to define some high level behaviours of the system.

I won’t go into the make up of BDD in this here, as that could make up numerous articles in its own right. Nor will I cover TDD, other than to say I feel it is the best way to deliver quality software efficiently. Write the test that meets the requirement, implement the code to make the test pass (and no more!), refactor & repeat.

Spring Boot

spring boot logo

These days most of the microservices I create I do in Spring Boot, which at the time of writing is at version 3.2.

Cucumber

Cucumber provides the mechanism to execute our BDD created test cases. It provides an integration into Spring Boot.

I typically use JUnit as my preferred testing framework. It is probably the most commonly used testing framework for Java. As such the Cucumber team have created an integration into Junit5.

Over the last number of years, Spring Boot has changed a lot adding more and more support for various libraries/frameworks and faster ways to innovate and develop.

cucumber bdd logo

JUnit 5

junit5 logo

JUnit is my testing framework of choice. It is the most widely used testing framework for Java.

It is now in version 5 and there are many articles out there on how to integrate JUnit 4 with Spring Boot and Cucumber, but not many on JUnit 5

Testcontainers

One such change is the addition of support for Testcontainers. Testcontainers provide a standard mechanism to create and manage containers as part of your test cases. This is ideal for integration type testing which is really the type of testing I am covering here.

These containers can either be something that Spring supports directly or not. Either way it allows us to quickly spin up things like databases, data stores, streams etc to allow for a full integration style test.

test containers logo

Use Case

I certainly do not recommend this type of testing for all your tests. There should be a balance of lower level unit tests which are much faster and mock where required at the boundaries.

These tests provide better integration but should be a lot smaller in number as they generally an order of magnitude slower to run

BDD is not Cucumber (or Gherkin!)

BDD is an approach, not a specific technology. Cucumber is just one framework available out there to help bridge the gap between the natural language the behaviours are defined in and the code that makes sure those behaviours are correctly implemented.

Generally the output of the BDD process results in a number of behaviours defined in the Given, When, Then format. They are generally high level, and only contain as much detail as required.

Requirements

Container Runtime

In order for Test Containers to work you will need to have a Container Runtime available. Generally one that matches the Docker API. Not long ago you wouldn’t have had many options, but now there seems to be a number of “Desktop” offerings available depending on your operating system and licensing obligations. Some you might consider using (in order specific order)

Our Use Case

Define our Expected Behaviours

It is expected this will have been done before hand and is provided to the implementing team as an input artefact for the work to be implemented.

In our case, lets say we have been given the following high level feature and scenario as a starting point.

Feature: Customer API
  
  Scenario: As an API consumer I want to be able to create a new customer
    Given some customer details
    When I invoke the create customer operation with the customer details
    Then the customer details should be persisted
Gherkin

Note the high level provided in this acceptance criteria. It doesn’t stipulate the how, just the what.

Our Demo Project

Show how the various technologies interact

Creating a Project

As much as I love TDD and writing tests first. I see no sensible reason to not use good code generation tools, as long as they also generate the building blocks of my tests. It also implements one test that makes sure the application can actually start.

For that reason, I will use the Spring Initializer project to create a new microservice for this. I already have a blog post on Creating a Spring Boot application using the Spring Initializr and a video on Youtube, so check those out.

Library dependencies

Determine the latest version of Cucumber. I generally just look this up in mvnrepository.com.

Create a new property for the cucumber version, in my case I have:-

<properties>
	<cucumber.version>7.15.0</cucumber.version>
</properties>
XML

Make sure you have the following dependencies. I’m using Maven, but you can easily convert it to Gradle or any other build tool.

<!-- Important application jars (not all) -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
	<groupId>org.postgresql</groupId>
	<artifactId>postgresql</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- JUnit 5 -->
<dependency>
	<groupId>org.junit.jupiter</groupId>
	<artifactId>junit-jupiter</artifactId>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>org.junit.platform</groupId>
	<artifactId>junit-platform-suite</artifactId>
	<scope>test</scope>
</dependency>

<!-- Spring -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-testcontainers</artifactId>
	<scope>test</scope>
</dependency>

<!-- Cucumber -->
<dependency>
	<groupId>io.cucumber</groupId>
	<artifactId>cucumber-java</artifactId>
	<version>${cucumber.version}</version>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>io.cucumber</groupId>
	<artifactId>cucumber-junit-platform-engine</artifactId>
	<version>${cucumber.version}</version>
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>io.cucumber</groupId>
	<artifactId>cucumber-spring</artifactId>
	<version>${cucumber.version}</version>
	<scope>test</scope>
</dependency>

<!-- Test containers -->
<dependency>
	<groupId>org.testcontainers</groupId>
	<artifactId>junit-jupiter</artifactId>
	<scope>test</scope>
</dependency>
XML

Implement the Step Definitions

Let cucumber do the work, create the feature file and some wiring and then let cucumber create the Step Definitions

Wiring in Cucumber

I’m going to create a new Test class and add the required Cucumber annotations

import io.cucumber.junit.platform.engine.Constants
import io.cucumber.spring.CucumberContextConfiguration
import org.junit.platform.suite.api.ConfigurationParameter
import org.junit.platform.suite.api.IncludeEngines
import org.junit.platform.suite.api.SelectClasspathResource
import org.junit.platform.suite.api.Suite

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("me/grantlittle/examples/kotlinspringjunit5cucumber") // This selector is picked up by Cucumber
@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "pretty")
@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "usage")
@ConfigurationParameter(key = Constants.PLUGIN_PROPERTY_NAME, value = "html:target/cucumber-reports.html")
class IntegrationTests

@SpringBootTest
@CucumberContextConfiguration
object Bootstrap {

}
Kotlin

I will also create a new class called StepDefinitions, which we will use to hold the step definitions we will create shortly.

class StepDefinitions {

}
Kotlin

If I now execute these tests using

mvn test
Bash

At part of the test phase, the tests should fail due to the missing step definitions. Cucumber will generate suggestions for these steps in the output. Here is an example output.

[INFO] Results:
[INFO] 
[ERROR] Errors: 
[ERROR]   The step 'some customer details' and 2 other step(s) are undefined.
You can implement these steps using the snippet(s) below:
@Given("some customer details")
public void some_customer_details() {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@When("I invoke the create customer operation with the customer details")
public void i_invoke_the_create_customer_operation_with_the_customer_details() {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
@Then("the customer details should be persisted")
public void the_customer_details_should_be_persisted() {
    // Write code here that turns the phrase above into concrete actions
    throw new io.cucumber.java.PendingException();
}
[INFO] 
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
Bash

I typically copy this code into my StepDefinitions file. In my case I’m using Kotlin within IntelliJ. As such, IntelliJ will detect it’s Java code being pasted into a Kotlin class and offer to convert it. Here is the output

import io.cucumber.java.PendingException
import io.cucumber.java.en.Given
import io.cucumber.java.en.Then
import io.cucumber.java.en.When

class StepDefinitions {

	@Given("some customer details")
	fun some_customer_details() {
		// Write code here that turns the phrase above into concrete actions
		throw PendingException()
	}

	@When("I invoke the create customer operation with the customer details")
	fun i_invoke_the_create_customer_operation_with_the_customer_details() {
		// Write code here that turns the phrase above into concrete actions
		throw PendingException()
	}

	@Then("the customer details should be persisted")
	fun the_customer_details_should_be_persisted() {
		// Write code here that turns the phrase above into concrete actions
		throw PendingException()
	}
}
Kotlin

Now I can run my tests again. They continue to fail, but this time it’s because we have to define our implementation.

Implement the Test Code

This article isn’t meant to define how to do TDD, so I won’t go through each step. However I’m still going to use a TDD approach.

Lets look at each of our Given/When/Then steps in turn

Given some customer details

First lets write the basic code we want. Here is what I have created:-

private var createCustomer: CreateCustomer? = null

@Given("some customer details")
fun some_customer_details() {
	// Write code here that turns the phrase above into concrete actions
	createCustomer = CreateCustomer(
		firstName = "John",
		lastName = "Doe",
		email = "john.doe@example.com",
	)

}
Kotlin

Next we need to create our customer objects. In my case, I don’t want the client to be responsible for creating the customers ID. So I have 2 classes.

  • Customer – A class representing our Customer, including an ID
  • CreateCustomer – This has all fields of a Customer, except the ID
data class Customer(
    val id: String,
    val firstName: String,
    val lastName: String,
    val email: String
)

data class CreateCustomer(
    val firstName: String,
    val lastName: String,
    val email: String
)
Kotlin

At this point we can try and run our tests again. The step “Given some customer details” should now be complete.

When I invoke the create customer operation with the customer details

Define the test code

Let’s start by writing what we would expect to happen. In my case, I want to call to use a WebClient to call our REST API and create a new customer. So let’s wire that together

@LocalServerPort
private var serverPort: Int? = null

private var customerId: String? = null

@When("I invoke the create customer operation with the customer details")
fun i_invoke_the_create_customer_operation_with_the_customer_details() {
	val webClient = WebClient.create("http://localhost:$serverPort")
	val customer = webClient
		.post()
		.uri("/api/1.0/customers")
		.bodyValue(createCustomer!!)
		.retrieve()
		.bodyToMono(Customer::class.java)
		.block(Duration.ofSeconds(5))

	assertNotNull(customer)
	assertAll(
		{ assertNotNull(customer!!.id) },
		{ assertEquals("John", customer!!.firstName) },
		{ assertEquals("Doe", customer!!.lastName) },
		{ assertEquals("john.doe@example.com", customer!!.email) }
	)
	customerId = customer!!.id
}
 
Kotlin

At this point our code should compile, but the tests would still not pass. So let’s fix that.

Enable the Test Web Server

First I need to define how our SpringBoot application loads when running the test. We do this using the @SpringBootTest annotation. Here is the code I’m going to use for now

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@CucumberContextConfiguration
object Bootstrap { }
Kotlin

The reason I’m using an Kotlin object (static class in Java). Is because we will be using TestContainers later, it required the initialization to be done in a static class.

Now the code should compile again, but the test will fail as we haven’t not implemented the server endpoint. So you will get an error something like the following:-

org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from POST http://localhost:56359/api/1.0/customers
Bash

So let’s go ahead and implement our RestController.

Create the REST Controller

The aim of this post is to help the reader understand the wiring of Spring Boot, JUnit5, Cucumber and TestContainers. It is not a tutorial on how to implement server logic. As such I’m creating a simple use case here that will receive the CreateCustomer object -> Convert it to a JPA Entity -> Save it to a JPA repository -> Return a Customer object back to the client.

@RestController
@RequestMapping("/api/1.0/customers")
class CustomerController(
    private val customerRepository: CustomerRepository
) {

    @PostMapping(consumes = ["application/json"], produces = ["application/json"])
    fun createCustomer(@RequestBody createCustomer: CreateCustomer): Mono<Customer> {
        return createCustomer
            .let {
                CustomerEntity(
                    firstName = it.firstName,
                    lastName = it.lastName,
                    email = it.email
                )
            }
            .let {
                //Don't inline blocking call
                Mono.fromCallable { customerRepository.save(it) }.subscribeOn(Schedulers.boundedElastic())
            }
            .map {
                Customer(
                    id = it.id!!,
                    firstName = it.firstName!!,
                    lastName = it.lastName!!,
                    email = it.email!!
                )
            }

    }
}

@Entity
@Table(name = "customer")
open class CustomerEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    open var id: String? = null,
    open var firstName: String? = null,
    open var lastName: String? = null,
    open var email: String? = null
)

interface CustomerRepository: CrudRepository<CustomerEntity, String>
Kotlin

Now to get the code to work correctly with PostgreSQL, I want to use JPA to automatically create the database schema etc. So I’ve added the following to my application.yml

spring:
  jpa:
    open-in-view: false
    generate-ddl: true
    properties:
      hibernate:
        ddl-auto: update
YAML

Adding the PostgreSQL TestContainer

So now on to adding the TestContainers, and specifically in this case, PostgreSQL. I’ve added the following code

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@CucumberContextConfiguration
@Testcontainers
object Bootstrap {

	@Container
	@ServiceConnection
	var postgresContainer: PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres"))
}

@BeforeAll
fun setUp() {
	Bootstrap.postgresContainer
		.withReuse(true)
		.start()

}

@AfterAll
fun tearDown() {
	Bootstrap.postgresContainer.stop()
}
Kotlin

There are a few things going on here. The first is on the same class as my @SpringBootTest, annotation, I have said we want to use Test Containers using the @Testcontainers annotation. I then create a new variable called postgresContainer and instantiate it.

The @Container annotation helps define this as a Testcontainer. The @SpringConnection is important and allows spring to automatically set spring configuration properties to work with the associated test container. This save us having to write this code manually.

You also need to tell the JUnit5 testing framework when to start and stop your containers. We do this using the @BeforeAll and @AfterAll annotations against our setUp() and tearDown() methods respectively.

We are now in a position where the second step in our acceptance criteria passes

Then the customer details should be persisted

This last part of our acceptance criteria is our primary validation phase.

To meet the acceptance criteria we must retrieve the Customer from the database and make sure the fields match what we expect.

We could create a new “get” method on our REST API to retrieve the Customer, but I argue that’s a different scenario as there may well be bugs in the “get” operation. Therefore we will reuse the “CustomerRepository” we already have in place. We just need to inject it into our class implementing the scenarios and use it.

@Autowired
lateinit var customerRepository: CustomerRepository

@Then("the customer details should be persisted")
fun the_customer_details_should_be_persisted() {

	val customer = customerRepository.findById(customerId!!).orElse(null)

	assertNotNull(customer)
	assertAll(
		{ assertNotNull(customer.id) },
		{ assertEquals("John", customer.firstName) },
		{ assertEquals("Doe", customer.lastName) },
		{ assertEquals("john.doe@example.com", customer.email) }
	)
}
Kotlin

Running our tests again

Report

As part of our CI/CD pipeline we sometimes need to generate a report to show as evidence what test we ran. You may have noticed that that as part of our IntegrationTests we defined a ConfigurationParameter

@ConfigurationParameter(key = Constants.<em>PLUGIN_PROPERTY_NAME</em>, value = "html:target/cucumber-reports.html")
class IntegrationTests
Kotlin

This is what tells the Cucumber Maven plugin to generate a report. In my case I have used a HTML report. This will result in a report that looks something like this

example cucumber report showing testing output and acceptance criteria

There are a number of options available, including uploading the Report to the Cucumber Reports service. Have a look at the maven plugin options or go to the Cucumber Reports Service page

Resources

Glossary

  • BDD – Behaviour Driven Development
  • TDD – Test Driven Development

Leave a Reply

Your email address will not be published. Required fields are marked *