Sitemap

Using Testcontainers with PostgreSQL in Spring Boot Kotlin

7 min readFeb 11, 2025

This guide demonstrates how to set up and use Testcontainers with PostgreSQL in a Spring Boot Kotlin application, including Flyway migrations and proper testing setup.

Project setup

First, ensure your build.gradle.kts has the necessary dependencies:

plugins {
kotlin("jvm") version "1.9.25"
kotlin("plugin.spring") version "1.9.25"
kotlin("plugin.jpa") version "1.9.25"
id("org.springframework.boot") version "3.4.2"
id("io.spring.dependency-management") version "1.1.7"
}

dependencies {
// Spring Boot core dependencies
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")

// Flyway dependencies
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql")

// PostgreSQL driver
runtimeOnly("org.postgresql:postgresql")

// Test dependencies
testImplementation("org.springframework.boot:spring-boot-starter-webflux")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

// Required for JPA entities in Kotlin
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}

You can generate a new project using this start.spring.io/kotlin-gradle-flyway-testcontainers-postgres link.

JPA Entity

Create your JPA entity class:

import jakarta.persistence.*

@Entity
@Table(name = "app_user")
data class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int? = null,

@Column(name = "name")
val name: String,
)

JPA Repository

Create a JPA repository interface:

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface UserRepo : JpaRepository<User, Int> {
fun findByName(name: String): User?
}

REST Controller

Create a REST controller to handle user operations:

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

@RestController
@RequestMapping("/api/v1/users")
class UserController(
private val userRepo: UserRepo,
) {

@RequestMapping("")
fun getAllUsers(): List<User> {
return userRepo.findAll()
}

data class UserRequest(
val name: String,
)

@PostMapping("/add")
fun addUser(@RequestBody request: UserRequest): User {
return userRepo.save(User(name = request.name))
}
}

Testcontainers configuration

Create a configuration class for Testcontainers:

import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName

@TestConfiguration(proxyBeanMethods = false)
class PostgresTestcontainersConfiguration {

@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer<*> {
return PostgreSQLContainer(DockerImageName.parse("postgres:latest"))
}
}

Flyway migrations

1. Create a directory structure for migrations:

src/main/resources/db/migration/

2. Create your first migration file V001.001__create_user_table.sql:

CREATE TABLE app_user (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);

3. (optional) Configure Flyway in application.properties or application.yml:

spring:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration

Writing tests

Create integration tests using WebTestClient:

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.reactive.server.WebTestClient

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerTest(
@Autowired var webTestClient: WebTestClient,
) {

@Test
fun `fetches users, adds users, fetches users again`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)

webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk
.expectBody(User::class.java)
.isEqualTo(User(1, "John Doe"))

webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(1)
}
}

Test cleanup strategies

Here are different approaches to handling test data cleanup, each with its own advantages.

1. Using @DirtiesContext

This approach recreates the entire Spring context (including the database) for each test method:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.web.reactive.server.WebTestClient

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Add this annotation to make the tests dirty the context after each test method
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class UserControllerDirtiesContextTest(
@Autowired var webTestClient: WebTestClient,
) {

@Test
fun `empty list is returned when no users exist`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)
}

@Test
fun `can add a user and retrieve it`() {
// Add user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk
.expectBody(User::class.java)
.consumeWith { response ->
val user = response.responseBody
assert(user?.name == "John Doe")
}

// Verify user was added
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(1)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}

@Test
fun `can add multiple users and retrieve them`() {
// Add first user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk

// Add second user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("Jane Doe"))
.exchange()
.expectStatus().isOk

// Verify both users
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(2)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}
}
  • Pros: Most thorough cleanup, completely fresh context.
  • Cons: Slowest approach, recreates entire Spring context!
  • Best for: Complex integration tests where complete isolation is crucial and count of tests is small, so the tests do not run for a long time.

2. Using @Transactional

This approach rolls back transactions after each test:


import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.transaction.annotation.Transactional
import org.junit.jupiter.api.Assertions.assertEquals

@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional // <--- Add this annotation to make the tests transactional
class UserControllerTransactionalTest(
@Autowired private val userController: UserController,
) {

@Test
fun `empty list is returned when no users exist`() {
val users = userController.getAllUsers()
assertEquals(0, users.size)
}

@Test
fun `can add a user and retrieve it`() {
// Add user
val addedUser = userController.addUser(UserController.UserRequest("John Doe"))
assertEquals("John Doe", addedUser.name)

// Verify user was added
val users = userController.getAllUsers()
assertEquals(1, users.size)
assertEquals("John Doe", users[0].name)
}

@Test
fun `can add multiple users and retrieve them`() {
// Add users
userController.addUser(UserController.UserRequest("John Doe"))
userController.addUser(UserController.UserRequest("Jane Doe"))

// Verify both users were added
val users = userController.getAllUsers()
assertEquals(2, users.size)
assertEquals(setOf("John Doe", "Jane Doe"), users.map { it.name }.toSet())
}
}
  • Pros: Fast, automatic rollback.
  • Cons: May not catch transaction-related bugs. Won’t work for integration tests, for example, when REST API is called via HTTP (because a transaction cannot be propagated).
  • Best for: Simple CRUD tests.

3. Using @AfterEach

This approach manually cleans up after each test:

import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.reactive.server.WebTestClient

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerManualCleanupTest(
@Autowired var webTestClient: WebTestClient,
@Autowired var userRepo: UserRepo,
) {
@AfterEach
fun cleanup() {
// Clean up all users after each test
userRepo.deleteAll()
}

@Test
fun `empty list is returned when no users exist`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)
}

@Test
fun `can add a user and retrieve it`() {
// Add user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk
.expectBody(User::class.java)
.consumeWith { response ->
val user = response.responseBody
assertEquals("John Doe", user?.name)
}

// Verify user was added
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(1)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}

@Test
fun `can add multiple users and retrieve them`() {
// Add first user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk

// Add second user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("Jane Doe"))
.exchange()
.expectStatus().isOk

// Verify both users
val returnResult = webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(2)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}
}
  • Pros: Fine-grained control, clear what’s being cleaned.
  • Cons: Need to maintain cleanup code (which can get messy, but can be easily extracted into reusable code blocks to avoid increased maintenance complexity).
  • Best for: When you need specific cleanup logic, or for simple tests for services, repoistories, etc.

4. Using @SQL

This approach uses SQL scripts to clean up before each test:

Create src/test/resources/cleanup.sql:

TRUNCATE TABLE app_user RESTART IDENTITY CASCADE;

Create the test class:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.reactive.server.WebTestClient

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Add this annotation to run the SQL script before each test method
@Sql("/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class UserControllerSqlCleanupTest(
@Autowired var webTestClient: WebTestClient,
) {
@Test
fun `empty list is returned when no users exist`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)
}

@Test
fun `can add a user and retrieve it`() {
// Add user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk
.expectBody(User::class.java)
.consumeWith { response ->
val user = response.responseBody
assert(user?.name == "John Doe")
}

// Verify user was added
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(1)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}

@Test
fun `can add multiple users and retrieve them`() {
// Add first user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("John Doe"))
.exchange()
.expectStatus().isOk

// Add second user
webTestClient
.post()
.uri("/api/v1/users/add")
.bodyValue(UserController.UserRequest("Jane Doe"))
.exchange()
.expectStatus().isOk

// Verify both users
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(2)
.consumeWith<WebTestClient.ListBodySpec<User>> { response ->
val users = response.responseBody
assertEquals("John Doe", users?.first()?.name)
}
}
}
  • Pros: Database-level cleanup, can handle complex data structures. We can reset sequences or anything that might be difficult to re-created from code running in the JVM.
  • Cons: Need to maintain SQL scripts.
  • Best for: When you need specific database state before tests.

Improving test maintainability

Adding many annotations to a test can be tricky situation to maintain. Here is what we can do to define specific annotations for specific types of the tests. We can, and should, adjust the name depending on our needs. Here is an examples of SqlTest that always cleans up the DB:

import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.jdbc.Sql

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Add this annotation to run the SQL script before each test method
@Sql("/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
annotation class SqlTest()

Then we can use this annotation in our tests:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.reactive.server.WebTestClient

@SqlTest
class UserControllerSqlCleanupTest(
@Autowired var webTestClient: WebTestClient,
) {
@Test
fun `empty list is returned when no users exist`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)
}
}

Or we can create annotations like this, specifying integration (or any other types of tests that make sense for our code bases):

import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext

@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Add this annotation to make the tests dirty the context after each test method
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
annotation class IntegrationTest()

Now we can use it to create an integration test:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.reactive.server.WebTestClient

@IntegrationTest
class UserControllerDirtiesContextTest(
@Autowired var webTestClient: WebTestClient,
) {

@Test
fun `empty list is returned when no users exist`() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(0)
}
}

Integrating with Gradle

Another benefit of defining various types of tests via annotations is that we can execute them selectively in Gradle. First we add a tag to an annotation.

import org.junit.jupiter.api.Tag
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext

@Tag("IntegrationTest")
@AutoConfigureWebTestClient
@Import(PostgresTestcontainersConfiguration::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Add this annotation to make the tests dirty the context after each test method
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
annotation class IntegrationTest()

And then we can define a new task that runs specific tests, that we can include or exclude specific tests depending on the need (e.g. exclude e2e tests during CI pipeline).

// build.gradle.kts
// ...

tasks.register<Test>("integrationTest") {
useJUnitPlatform {
includeTags("IntegrationTest")
}
testClassesDirs = sourceSets["test"].output.classesDirs
classpath = sourceSets["test"].runtimeClasspath
}

Resources

--

--

Responses (1)