Testing | Testcontainers for Spring Boot — Real Databases, Real Confidence

I got burned by mocked repository tests at DWP Digital. A service had 95% test coverage, every test green, and a production deployment that failed because the Mongo query our team had written didn’t behave the way the mock said it would. The mock had been configured to return data in a specific order; the real MongoDB returned it in a different order under load. An entire sprint of bug investigation traced back to a mocked dependency that lied.

Since then, I use Testcontainers for anything that touches a database, message broker, or external system. The tests are slower, but they tell the truth.

The Core Idea

Testcontainers starts real Docker containers as part of your test lifecycle. Your tests connect to a genuine PostgreSQL instance, a genuine MongoDB replica set, a genuine Kafka broker — not a fake. When the tests finish, the containers are torn down. The feedback is accurate because the environment is accurate.

The dependency is straightforward:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
</dependency>

Setting Up PostgreSQL Integration Tests

With Spring Boot 3.1+, you can declare container beans directly in your test configuration:

@SpringBootTest
@Testcontainers
class ClaimRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("claims_test")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureDataSource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private ClaimRepository claimRepository;

    @Test
    void shouldFindClaimsByStatus() {
        claimRepository.save(new Claim("CLM-001", ClaimStatus.PENDING));
        claimRepository.save(new Claim("CLM-002", ClaimStatus.APPROVED));

        List<Claim> pending = claimRepository.findByStatus(ClaimStatus.PENDING);

        assertThat(pending).hasSize(1)
            .extracting(Claim::getReference)
            .containsExactly("CLM-001");
    }
}

@DynamicPropertySource wires the container’s actual JDBC URL into the Spring context before beans are initialised. Spring then creates the DataSource pointing at the real container.

Reusing Containers Across Tests

Starting a container per test class adds meaningful time. Use Testcontainers’ static container pattern with Spring Boot’s service connection to reuse containers across the test suite:

@TestConfiguration(proxyBeanMethods = false)
public class TestContainersConfig {

    @Bean
    @ServiceConnection // Spring Boot 3.1+ auto-wires datasource properties
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }

    @Bean
    @ServiceConnection
    MongoDBContainer mongoContainer() {
        return new MongoDBContainer("mongo:7");
    }

    @Bean
    @ServiceConnection
    KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
    }
}

With @ServiceConnection, Spring Boot automatically configures the datasource, MongoDB client, and Kafka properties from the running containers. No @DynamicPropertySource needed. Import this configuration into your test classes:

@SpringBootTest
@Import(TestContainersConfig.class)
class ClaimServiceIntegrationTest {
    // Spring context wired to real containers
}

Declare the containers as static beans at the @TestConfiguration level and they’re shared across the entire test run rather than restarted per class.

Testing Kafka Consumers

Testing a Kafka consumer integration requires producing a message and asserting the downstream effect:

@SpringBootTest
@Import(TestContainersConfig.class)
class BenefitClaimConsumerIntegrationTest {

    @Autowired
    private KafkaTemplate<String, BenefitClaimEvent> kafkaTemplate;

    @Autowired
    private ClaimRepository claimRepository;

    @Test
    void shouldProcessClaimEventAndPersist() throws Exception {
        BenefitClaimEvent event = new BenefitClaimEvent("CLM-TEST-001", ClaimType.HOUSING);

        kafkaTemplate.send("benefit.claims", event.getClaimId(), event).get();

        // Poll with timeout — consumer is async
        await().atMost(10, SECONDS).untilAsserted(() -> {
            Optional<Claim> claim = claimRepository.findByReference("CLM-TEST-001");
            assertThat(claim).isPresent()
                .get()
                .extracting(Claim::getStatus)
                .isEqualTo(ClaimStatus.PENDING);
        });
    }
}

The await() from Awaitility handles the asynchronous nature of Kafka consumption without introducing arbitrary Thread.sleep() calls.

Container Startup Speed

Testcontainers tests are slower than unit tests — accept that and manage it. A few techniques keep the time reasonable:

Ryuk and container reuse: Testcontainers includes a Ryuk container for cleanup. In CI, set TESTCONTAINERS_RYUK_DISABLED=true if containers are cleaned up by your CI system anyway.

Image caching: Pre-pull images in your CI pipeline before tests run. Docker image pull time is often the biggest overhead.

Test slicing: Not every test needs the full Spring context and all containers. A @DataJpaTest slice loads only the JPA layer. A @WebMvcTest slice loads only the web layer with a mock service layer. Reserve full @SpringBootTest + Testcontainers for genuine integration scenarios.

ProTips

If you’re looking for a Java contractor who knows this space inside out, get in touch.