PrepZone Logo
PrepZone

Full-Stack Tests with Testcontainers

Spin up real databases and services in Docker for integration tests.

Why this matters

  • H2 in MySQL compatibility mode still diverges from PostgreSQL on JSON columns, locking, and sequence behaviour — bugs slip through slice tests and appear only in staging.
  • Testcontainers starts disposable containers per test class or suite, giving you a faithful environment without maintaining shared test infrastructure.
  • Combined with @SpringBootTest, you verify the full request path: HTTP → service → repository → real database → response.
JUnit test
Kafka container
Assert events
JUnit spins up a real Kafka broker in Docker — no mocks for integration tests.

Integration test building blocks

  • @SpringBootTest(webEnvironment = RANDOM_PORT) — boots the full application on a random port.
  • @Testcontainers — JUnit 5 extension that manages container lifecycle.
  • @Container + static — shared container across all tests in the class (faster startup).
  • @DynamicPropertySource — injects container host/port into Spring's environment before context loads.
  • TestRestTemplate / RestClient — send real HTTP requests to the running application.

Dependencies

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

PostgreSQL integration test

Java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class BookApiIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("bookstore_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);
        registry.add("spring.flyway.enabled", () -> "true");
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void createAndRetrieveBook_roundTrip() {
        CreateBookRequest request = new CreateBookRequest(
                "978-0134685991", "Effective Java", "Joshua Bloch", 45.99);

        ResponseEntity<BookDetail> created = restTemplate.postForEntity(
                "/api/books", request, BookDetail.class);
        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        ResponseEntity<BookDetail> fetched = restTemplate.getForEntity(
                "/api/books/978-0134685991", BookDetail.class);
        assertThat(fetched.getBody().title()).isEqualTo("Effective Java");
    }
}

Redis cache integration test

Java
@SpringBootTest
@Testcontainers
class BookCacheIntegrationTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private BookService bookService;

    @Autowired
    private BookRepository bookRepository;

    @Test
    void cachedLookup_hitsRedisOnSecondCall() {
        bookRepository.save(new Book("978-1", "Domain-Driven Design", "Eric Evans", 55.00));

        BookDetail first = bookService.getByIsbn("978-1");
        BookDetail second = bookService.getByIsbn("978-1");

        assertThat(first).isEqualTo(second);
        verify(bookRepository, times(1)).findByIsbn("978-1");
    }
}

Kafka end-to-end test

Java
@SpringBootTest
@Testcontainers
class OrderEventIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));

    @DynamicPropertySource
    static void configureKafka(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Autowired
    private OrderService orderService;

    @Autowired
    private Consumer<String, OrderPlacedMessage> testConsumer;

    @Test
    void placingOrder_publishesKafkaMessage() {
        orderService.placeOrder(sampleRequest());

        ConsumerRecords<String, OrderPlacedMessage> records =
                testConsumer.poll(Duration.ofSeconds(5));
        assertThat(records.count()).isGreaterThanOrEqualTo(1);
    }
}

Quick recall

Everything you need if you only revisit this box.

  • @Testcontainers + @Container static starts real infrastructure in Docker.
  • @DynamicPropertySource wires container URLs into Spring before the context boots.
  • @SpringBootTest(RANDOM_PORT) + TestRestTemplate tests the full HTTP stack.
  • Prefer Testcontainers over H2 when production uses PostgreSQL, Redis, or Kafka.
  • Keep integration tests few and focused — slice tests cover most cases faster.

Test yourself

Answer these before moving on — recall is what makes it stick.