Testcontainers: Real Databases in Your Integration Tests
Stop mocking your database and start testing against a real one, spun up in seconds with Docker.
When you test a repository or a service that talks to Postgres or Mongo, you face a fork in the road. You can mock the database client and assert that the right methods were called, or you can run your code against a real database engine. Mocks are fast and have no external dependencies, but they verify your *assumptions* about the database rather than its actual *behavior*. The mock never enforces a unique constraint, never rejects a malformed query, never applies a default value, and never reveals that your JPA mapping silently truncates a column. Testcontainers closes that gap by booting a genuine database inside a throwaway Docker container, dedicated to your test run.
Testcontainers is a JVM library that programmatically starts and stops Docker containers from your test lifecycle. You declare a container as a field, annotate it, and the library pulls the image, starts it before your tests, and tears it down afterward. Because it is the same engine you run in production (the exact Postgres or Mongo version you pin), the SQL dialect, indexes, transaction semantics, and JSON operators all behave identically. The cost is that Docker must be available on the machine running the tests, and the first run pays a one-time image pull. After that, container startup is typically a few seconds, and a warm image makes it nearly instant.
The idiomatic Spring Boot setup uses two annotations. `@Testcontainers` is a JUnit 5 extension that scans the test class for container fields and manages their lifecycle. `@Container` marks each field that holds a container so the extension knows to start and stop it. A field in a `companion object` (static in Java terms) is started once for the whole class and shared across every test method, which is what you almost always want for a database. An instance-level `@Container` field, by contrast, is restarted before each test, giving perfect isolation at the price of speed. For most persistence suites, one shared container plus per-test cleanup or transactional rollback strikes the right balance.
The remaining problem is wiring: Docker assigns the container a random host port every run, so you cannot hardcode the JDBC URL in `application.properties`. This is what `@DynamicPropertySource` solves. You write a static method that receives a `DynamicPropertyRegistry` and registers properties whose values are computed *after* the container has started. Spring evaluates these before it builds the `ApplicationContext`, so the `DataSource` and any auto-configuration pick up the real, live coordinates. For Postgres this means the JDBC URL, username, and password; for Mongo it is the connection string. Each registry entry takes a supplier, so the value is resolved lazily once the mapped port is known.
Here is the critical sequencing detail that trips people up. JUnit starts the `@Container` field, then Spring TestContext invokes your `@DynamicPropertySource` method, and only then is the context refreshed and your `@Autowired` repository injected. If you accidentally read `container.jdbcUrl` at field-initialization time instead of inside the supplier lambda, the container will not have a port yet and you will get a connection refused error. Always pass a method reference or lambda to the registry so resolution is deferred. With the `PostgreSQLContainer` and `MongoDBContainer` modules, the library even exposes typed helpers like `jdbcUrl`, `username`, and `replicaSetUrl` so you do not assemble strings by hand.
Why does this matter beyond purity? Integration tests against a real engine catch an entire class of bugs that mocks structurally cannot. They validate your schema migrations (Flyway or Liquibase actually run), they prove your queries compile against the real dialect, they surface lazy-loading and N+1 issues, and they verify constraint violations and cascade behavior. A mock that returns a hand-built entity will happily let you ship a query with a typo in a column name; a real Postgres rejects it immediately. Mocks still have their place for fast unit tests of pure business logic, but for the persistence layer the question 'does my data actually round-trip correctly?' can only be answered by a database.
To keep these tests fast and maintainable, lean on a few patterns. Use a single shared container in a base class that all your `@DataJpaTest` or `@SpringBootTest` suites extend, so the image starts once per run rather than once per class. Pin the image tag explicitly (`postgres:16-alpine`) so tests are reproducible and never silently upgrade. Enable container reuse via `~/.testcontainers.properties` during local development to skip restarts between runs, while keeping a clean start in CI. And reset state between tests with a transactional rollback or a truncate helper rather than recreating the container, which preserves both isolation and speed.
The payoff is confidence. A green integration suite backed by Testcontainers tells you that your code, your mapping, your migrations, and your queries all work together against the same database you deploy to production, without anyone needing to install Postgres locally or share a fragile staging database. You trade a small amount of startup time and a Docker dependency for tests that fail when something is genuinely broken and pass when it genuinely works. For the persistence layer of a Kotlin and Spring service, that trade is almost always worth making.
@SpringBootTest@Testcontainersclass UserRepositoryIntegrationTest {@Autowiredlateinit var userRepository: UserRepositorycompanion object {@Container@JvmStaticval postgres = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")).withDatabaseName("app").withUsername("test").withPassword("test")@DynamicPropertySource@JvmStaticfun props(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)}}@Testfun `unique email constraint is enforced by the real database`() {userRepository.save(User(email = "ada@kotlin.dev"))// A mock would never throw here - a real Postgres does.assertThrows<DataIntegrityViolationException> {userRepository.saveAndFlush(User(email = "ada@kotlin.dev"))}}}
A full @SpringBootTest with a shared Postgres container. The companion-object container starts once for the class; @DynamicPropertySource wires the random port into Spring before the context is built. Note the supplier lambdas (::method references) defer resolution until the container is up.
@SpringBootTest@Testcontainersabstract class AbstractPostgresTest {companion object {@Container@JvmStaticval postgres = PostgreSQLContainer(DockerImageName.parse("postgres:16-alpine")).withReuse(true)@DynamicPropertySource@JvmStaticfun datasource(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)}}}class OrderRepositoryTest : AbstractPostgresTest() {@Autowired lateinit var orders: OrderRepository@Testfun `round-trips an order through the real schema`() {val saved = orders.save(Order(total = 4200))val found = orders.findById(saved.id!!).orElseThrow()assertEquals(4200, found.total)}}
A reusable base class pattern. Every suite that extends this shares one container, so the image starts a single time per test run instead of once per class. Subclasses just add their @Autowired beans and @Test methods.
@DataMongoTest@Testcontainersclass ProductDocumentTest {@Autowiredlateinit var template: MongoTemplatecompanion object {@Container@JvmStaticval mongo = MongoDBContainer(DockerImageName.parse("mongo:7"))@DynamicPropertySource@JvmStaticfun props(registry: DynamicPropertyRegistry) {registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl)}}@Testfun `persists and reads a document`() {template.save(Product(name = "Keyboard", price = 120))val all = template.findAll(Product::class.java)assertEquals(1, all.size)}}
MongoDB works the same way - swap the container module and register the single connection-string property the Spring Data Mongo auto-configuration needs.
import kotlinx.coroutines.*class DuplicateKeyException(message: String) : Exception(message)// 'Real' store: enforces a uniqueness rule, like a real database would.class RealStore {private val rows = mutableMapOf<Int, String>()suspend fun insert(id: Int, name: String) {delay(10) // simulate I/Oif (rows.containsKey(id)) throw DuplicateKeyException("id $id exists")rows[id] = name}}// Naive fake (a mock): never enforces the rule, so bugs slip through.class FakeStore {val rows = mutableMapOf<Int, String>()suspend fun insert(id: Int, name: String) { rows[id] = name }}fun main() = runBlocking {val fake = FakeStore()fake.insert(1, "Ada")fake.insert(1, "Grace") // silently overwrites - no errorprintln("Fake accepted duplicate: " + fake.rows)val real = RealStore()real.insert(1, "Ada")try {real.insert(1, "Grace")} catch (e: DuplicateKeyException) {println("Real store caught the bug: " + e.message)}}
Pure Kotlin + coroutines: the SAME conceptual gap Testcontainers fixes, shown without any framework. A fake in-memory store happily accepts a duplicate id, while a 'real' store enforces the constraint - illustrating why testing against real behavior matters. This runs on the kotlinx.coroutines stdlib alone.
Arena IDE🧠Check your understanding
0/1 · 0/1 answered1. Why must the JDBC URL be registered through a supplier inside @DynamicPropertySource rather than read directly when the container field is declared?