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)