Using Testcontainters with Kotlin Spring Boot App


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)

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s