Are you using SpringBoot for backend services and looking to integrate TestContainers into your testing setup?
If so, then this example should give you an idea of how to achieve it in the shortest amount of time using the least possible configuration to set the implementation up. To do this we will be considering Isolated Testing as a use case and use the TestContainers to achieve this in the most simple way possible in order to run an integration test. This setup would be more suitable for the projects where integration tests have higher priority and are part of regular execution in the CI/CD pipeline.
The purpose of isolated test, with or without TestContainers, is very important in Backend applications, because at some point the integration tests should be stable enough to make part of regular execution in CI/CD pipeline chain.
Besides, we want to make sure tests never interfere with each other during the execution and especially the technical setup does not enforce more and more refactoring over the time because of dependencies among the tests.
To ensure the correctness of the test results and the stability of each test method execution, we need to make sure the internal or external services used in the integration test context do not produce a data collision, and each test method execution can set up the data state for the service it requires. This could be done for example on an infrastructure premises by running separate instances of services on which our tests would depend. By doing so, we could achieve the goal, but not in an efficient way. This for example would require more effort to set it up and also to make sure that the test results are not impacting each other before, after and during the execution.
What problems would we encounter with this setup? The earlier will be too expensive and the later one will is not flexible enough to prevent parallel test execution.
Of course, we could make sure the data is clean in the datasource before starting the tests to support parallel execution, but the cost of knowing the whole test system in order to modify or extend it would be a heavy task and never safe because of the risk of producing bugs within the test code.
Luckily, TestContainers would make this easy for us so that we do not need to worry about the data collision or parallel execution. Why? Depending on our setup, the technology would simply take care of it by creating and destroying the type of instance we require for our integration test either for each test case or test class. The execution in this manner might be a little more expensive, but the cost pays us with some values of easy setup, stability, flexibility and independence in tests. Besides, making sure that both old and new tests written at any point in time are still independent of each other could save lots of refactoring time.
With new releases of SpringBoot and TestContainers it is way easier to accomplish the integration. Depending on the use case, as in unit testing, as well as in the integration test, the recommended structure to use in the test code is the classical testing strategy based on GIVEN/WHEN/THEN concept as following:
Given: Data Preparation: Prepares or mocks the test data. .
When: Target Code: Executes the testable production code.
Then: Assertion: Checks the expected data.
What is different here compared to unit testing is that we not only need to prepare the test data but also, depending on the requirements, provide external and/or internal services for the test setup, allowing each test case to manage them individually according to their needs. For example, if our integration test depends on a DB instance, then we would need to provide a Docker based DB instance before the test execution and insert the necessary data.
@RestController
@RequiredArgsConstructor
internal class UserController(val service: UserService) {
@GetMapping("/users")
fun all(): Iterable<User> {
return service.findAll()
}
@PostMapping("/users")
fun newUser(@RequestBody newUser: User): User {
return service.createNew(newUser)
}
@GetMapping("/users/{id}")
fun one(@PathVariable id: Long): User? {
return service.getUser(id) ?: throw UserNotFoundException(id)
}
}
This specific example has been developed as a basic SpringBoot application which exposes endpoints to insert and fetch basic data from PostgreSQL datasource. If we were to write multiple integration tests for this application we could either run multiple PSQL instances by creating and destroying them per test case or class or use 1 instance and prepare the data state before and after each execution.
@Testcontainers
@SpringBootTest(classes = [IsolatedTestingApplication::class])
@ActiveProfiles("test")
@AutoConfigureMockMvc(addFilters = false)
class UserApiIT {
@Autowired
var userRepository: UserRepository? = null
@Autowired
var mockMvc: MockMvc? = null
@Test
fun `Retrieve User from DB via the Endpoint`() {
//GIVEN
val user = User("Name", "Lastname")
userRepository?.save(user)
//WHEN
mockMvc!!.perform(
MockMvcRequestBuilders.get("/users/{id}", 1))
//THEN
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect { result: MvcResult ->
val content = result.response.contentAsString
assertThat(content).contains("Lastname")}
.andReturn())
}
}
In the test case, the requests will be made by using Spring Mock MVC to reach the PSQL Datasource, which we can then set up by using Spring Boot configuration file and TestContainers provided PostgreSQL Docker based instance.
Next, the configuration would make sure that the SpringBoot and TestContainer integration could set up and run a test application before the test case execution:
appication.yml
spring:
datasource:
url: jdbc:postgresql://${DATABASE_HOST:localhost}: ${DATABASE_PORT:5435}/${DATABASE_NAME:template}
username: ${DATABASE_USER:templateUser}
password: ${DATABASE_PASSWORD:templatePassword}
appication-test.yml
spring:
datasource:
url: jdbc:tc:postgresql:14:///template
This will automatically initiate an PSQL Docker based instance by creating the mentioned user as well as the database in it. As soon as the Docker instance is ready SpringBoot application startup will be triggered along with the FlyWay migration scripts to be executed. Based on the given configuration the Docker PSQL instance will have the following setup ready for the application to connect and use...
- Host: localhost
- Username: templateUser
- Password: templatePassword
- Database: template
While running the provided test sample you can simply follow in the Docker Desktop that 2 Docker Containers are appearing and disappearing on the fly to support our test execution on.
The source code for the described use case is available in this GitHub repository provided by Monstarlab:: Having Docker Desktop installed is a pre-requisite to run it.
Executing the following command in CLI should be sufficient to see and check the test results.
./gradlew clean build
The following steps are necessary to run the backend application in a CLI:
- Initiate PSQL Docker Container with a Database.
docker run -d --name template_postgres -e POSTGRES_USER=templateUser -e POSTGRES_PASSWORD=templatePassword -e POSTGRES_DB=template -p 5435:5432 --restart=always postgres
- Start the backend application
./gradlew bootRun
The choice of Kotlin as a language and Gradle as a build tool is merely one step forward towards the modern programming world and has no impact on the setup: this could have been easily also achieved with Maven and Java.
Tools & References used here...
- Monstarlab:: template with Kotlin and Gradle
- Gradle as build tool with KTS
- Kotlin as a programming language
- Hibernate as an ORM layer for JPA
- PostgreSQL as a Datasource
- FlyWay for DB script migrations
- Spring Boot, Note that since 3.1 TestContainers are supported by SrpingBoot
- Test Containers for running PSQL Docker based Container
- Unsplash Images for writing the blog post
- Brain and Mind from Monstarlab::