Last Updated on 04/06/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
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.
JUnit 5
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.
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
GherkinNote the high level provided in this acceptance criteria. It doesn’t stipulate the how, just the what.
Our Demo Project
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>
XMLMake 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>
XMLImplement 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 {
}
KotlinI will also create a new class called StepDefinitions, which we will use to hold the step definitions we will create shortly.
class StepDefinitions {
}
KotlinIf I now execute these tests using
mvn test
BashAt 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
BashI 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()
}
}
KotlinNow 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",
)
}
KotlinNext 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
)
KotlinAt 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
}
KotlinAt 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 { }
KotlinThe 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
BashSo 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>
KotlinNow 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
YAMLAdding 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()
}
KotlinThere 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) }
)
}
KotlinRunning 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
KotlinThis 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
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
- Spring Boot
- Cucumber IO
- Test Containers
- JUnit5
- Article on Creating a Spring Boot application using the Spring Initializr
- Video on Creating a Spring Boot application using the Spring Initializr
- Cucumber Reports Service
- Cucumber Reporting
- Docker Desktop
- Testcontainers Desktop
- Podman Desktop
- Rancher Desktop
Glossary
- BDD – Behaviour Driven Development
- TDD – Test Driven Development