In this quick post I will show you how to use Testcontainers with Kotlin Spring Boot app. Testcontainers is a Java library that you can use to write integration tests that work with real third-party services like databases, message queues like Kafka, or any other service that can be packaged as a Docker container image.
Most developers use in-memory databases or fakes to test with external dependencies but to test against real stack you need to use real services. This week I faced as issue where one of my test was failing because a MySQL function was unavailable in H2. I was using in-memory H2 database in tests. My application used MySQL in the production mode. So, my valid MySQL query was not working when run in a test. This made me think of replacing H2 with MySQL database for tests.
I was aware of Testcontainers but had never used it in any application. So, this was the first time I used it. For most parts I liked it. I don’t have to work around limitations of H2 and I can hope I will not discover any issues because of difference between H2 and MySQL. Testcontainers is not perfect. It makes test slow. My build time has increased from 6 mins to 9 mins just because of Testcontainers. I am relying more on my CI server to run the complete test suite.
Testcontainers can do much more than running databases. You can use Testcontainers for:
- Stream processing solutions like Apache Kafka and Apache Pulsar
- AWS Localstack
- Selenium tests
- Chaos tests
Using Testcontainers with Kotlin Spring Boot App
I faced issues getting Testcontainers work with my Kotlin Spring Boot app. In this post I am documenting the steps I used to make it work.
Let’s start by creating a Spring Boot app using spring
CLI. You can read more about Spring CLI here.
spring init --build=gradle --java-version=11 \ --language=kotlin \ --dependencies=web,data-jpa,testcontainers,mysql myapp
In the command shown above, we have created a Spring Boot application that uses Kotlin language and Gradle build tool. We also specified to use JDK 11. We also specified dependencies we need for our demo app. We specified web
, data-jpa
, testcontainers
, and mysql
.
Make sure Docker is installed on your machine. Testcontainers uses docker containers.
If you run the build ./gradlew clean build
it will fail because of test failure. Spring Boot creates a test that bootstraps the full application context. Since, we are not using an in-memory database for tests test will try to create datasource for MySQL. It will fail to create datasource because we have not specified the spring.datasource.url
.
In the src/main/resources/application.properties
add following.
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}/myapp
Now if you run the build again build will pass but it will log error on console as shown below.
java.sql.SQLException: Access denied for user ''@'localhost' (using password: NO) at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:129) ~[mysql-connector-java-8.0.22.jar:8.0.22]
The reason is that we need to specify MySQL username and password. Also, we need to create myapp
database.
You can do that if you don’t want to see this error in the console. We can ignore it as it is not important in the context of this post.
Lets’ create a simple REST controller that saves a todo item to database. Replace code in the DemoApplication
with the code shown below.
package com.example.myapp import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.JpaRepository import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import javax.persistence.* @SpringBootApplication class DemoApplication fun main(args: Array<String>) { runApplication<DemoApplication>(*args) } @RestController @RequestMapping("/todos") class TodoController(val todoRepository: TodoRepository) { @PostMapping fun create(@RequestBody todoRequest: TodoRequest): ResponseEntity<Void> { todoRepository.save(Todo(task = todoRequest.task!!)) return ResponseEntity.status(HttpStatus.CREATED).build() } } data class TodoRequest(var task: String? = null) @Entity @Table(name = "todos") class Todo( @Id @GeneratedValue(strategy = GenerationType.AUTO) var id: Long? = null, var task: String ) interface TodoRepository : JpaRepository<Todo, Long>
Let’s write our test case now. If we are not using Testcontainers we will write integration test using MockMvc as shown below.
package com.example.myapp import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.jdbc.EmbeddedDatabaseConnection import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.containers.MySQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers @SpringBootTest @AutoConfigureMockMvc class TodoControllerTest { @Autowired lateinit var mockMvc: MockMvc @Autowired lateinit var objectMapper: ObjectMapper @Test fun `should create a todo item`() { val json = objectMapper.writeValueAsString(TodoRequest("Write a blog on Testcontainers")) this.mockMvc.perform(post("/todos") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andDo(print()) .andExpect(status().isCreated) } }
If you had an in-memory database like H2 on your classapath then you just had to define spring.datasource.url
and you would be done. Since we want to use Testcontainers we have to tell our test to use that.
We will start by marking a test class with @Testcontainers
.
@SpringBootTest @AutoConfigureMockMvc @Testcontainers class TodoControllerTest { }
Next, we will have to create a class that extends org.testcontainers.containers.MySQLContainer
. This is required because Kotlin does not work well with self types used by Testcontainers library. Create a new class KMySQLContainer
as shown below.
@SpringBootTest @AutoConfigureMockMvc @Testcontainers class TodoControllerTest { } internal class KMySQLContainer(val image: String) : MySQLContainer<KMySQLContainer>(image)
Next, we have to create a companion object that will create the MySQL container and dynamically inject the datasource properties as shown below
@SpringBootTest @AutoConfigureMockMvc @Testcontainers class TodoControllerTest { companion object { @Container private val mysqlContainer = KMySQLContainer(image = "mysql:8.0.22") .withDatabaseName("myapp") @JvmStatic @DynamicPropertySource fun properties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl) registry.add("spring.datasource.password", mysqlContainer::getPassword) registry.add("spring.datasource.username", mysqlContainer::getUsername) registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" } } } } internal class KMySQLContainer(val image: String) : MySQLContainer<KMySQLContainer>(image)
The full test case is shown below.
package com.example.myapp import com.fasterxml.jackson.databind.ObjectMapper import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.testcontainers.containers.MySQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers @SpringBootTest @AutoConfigureMockMvc @Testcontainers class TodoControllerTest { @Autowired lateinit var mockMvc: MockMvc @Autowired lateinit var objectMapper: ObjectMapper companion object { @Container private val mysqlContainer = KMySQLContainer(image = "mysql:8.0.22") .withDatabaseName("myapp") @JvmStatic @DynamicPropertySource fun properties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl) registry.add("spring.datasource.password", mysqlContainer::getPassword) registry.add("spring.datasource.username", mysqlContainer::getUsername) registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" } } } @Test fun `should create a todo item`() { val json = objectMapper.writeValueAsString(TodoRequest("Write a blog on Testcontainers")) this.mockMvc.perform(post("/todos") .contentType(MediaType.APPLICATION_JSON) .content(json)) .andDo(print()) .andExpect(status().isCreated) } } internal class KMySQLContainer(val image: String) : MySQLContainer<KMySQLContainer>(image)