Testcontainers in Spring Boot: From Quick Wins to Power Moves

“Testcontainers is like having SpongeBob at the helm of your CI: cheerful, reliable, and somehow turning chaos into perfection.” - Me

As a junior developer, I’m constantly learning new tools and practices. One lesson from my seniors that really stuck with me was this: mocking will only take you so far. At some point, you need to test against the real thing: a real database, a real message broker, a real service.

That’s when I discovered Testcontainers, and it completely changed how I write integration tests in Java and Spring Boot. After experimenting with it and sharing my findings with colleagues, I realized I wanted to capture everything I’d learned in one place.

This post is my attempt to do just that: explaining the why, the how, and the real-world patterns I now use in my projects.

The Problem I Used to Have

I have to admit. I used to avoid integration tests whenever I could. Mocks were my best friends: fast, predictable, and easy to set up.

But as much as I loved them, some things just can’t be faked. A mail server, a real PostgreSQL database, no amount of mocking could truly replicate them.

I quickly ran into the same frustrating scenarios we’ve all seen: tests that pass on my machine but fail in CI, or teammates spending hours just to get the environment ready to run tests.

For a Spring Boot app that needs PostgreSQL and sends emails, here’s how the options stack up:

Approach Pros Cons
Local Installation
Install PostgreSQL & SMTP locally
- Tests against real services
- No mocking
- Different versions across machines
- “Works on my laptop” syndrome
- Painful CI setup
- Leftover test data pollution
Mock Everything
Use mock databases & services
- Fast test execution
- No external dependencies
- Not testing real integration
- Mocks drift from reality
- False confidence
Testcontainers
Real services in Docker
- Real integration testing
- Consistent across all machines
- Zero installation hassle
- Clean state every run
- Requires Docker
- Slightly slower than mocks

Testcontainers was the game-changer I needed.

Quick Setup

Before I dive deep, let me get you set up:

Prerequisites:

  • Java 21+
  • Maven
  • Docker Desktop running

Add the dependencies:

Maven:

<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>

The Standard Approach (Good, But Limited)

Here’s what most tutorials show you, and honestly, it’s great for simple cases:

@Testcontainers
@DataJpaTest
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    UserRepository userRepository;
    
    @Test
    void givenNewUser_whenSaving_thenUserIsPersisted() {
        User user = new User("Spongebob");
        userRepository.save(user);
        assertEquals(1, userRepository.findAll().size());
    }
}

This is beautiful for simple cases. JUnit starts a PostgreSQL container, Spring Boot wires it up via @DynamicPropertySource, your test runs against a real database, and the container gets cleaned up afterward.

What Actually Happens Behind the Scenes?

When you hit “Run Test”, here’s the magic that unfolds (simplified):

Behind the scenes (simplified)

The waiting phase is crucial. Testcontainers doesn’t just check if the port is open, it waits until the service is actually ready to accept connections. This is why your tests “just work” without flaky failures.

But in real projects, I quickly ran into situations where I needed to:

  • Start multiple containers that talk to each other
  • Preload my database with schema or seed data
  • Wait for a REST API to be ready, not just a port
  • Test email sending against a fake SMTP server

That’s where things get interesting….

Level Up: ApplicationContextInitializer

When I need more control, Spring Boot gives me a powerful hook: ApplicationContextInitializer<ConfigurableApplicationContext>.

This runs before your application context starts, letting you:

  • Start containers manually with full control
  • Configure complex wait strategies
  • Inject custom properties into Spring’s environment
  • Reuse container configurations across multiple test classes

I think of it as the “professional” way to wire up Testcontainers in Spring Boot.

How the Professional Setup Works

Here’s what happens when you use ApplicationContextInitializer:

What happens when you use ApplicationContextInitializer

This gives you complete control over the startup sequence and lets you configure containers exactly how you need them.

Real-World Example: Testing Email with smtp4dev (or Mailpit)

My app sends emails, and I needed to test that realistically without spamming real inboxes.

smtp4dev is a fake SMTP server that catches emails and exposes them via REST API. It’s been around for years, has a mature codebase, and includes features like relay testing and authentication simulation. Perfect for comprehensive email testing scenarios.

Mailpit is a modern alternative written in Go, known for its lightweight footprint, faster startup times, and slick UI. It also has excellent search capabilities and built-in spam score analysis. If you’re starting fresh, Mailpit is often the go-to choice in 2025, but smtp4dev remains solid for existing projects.

Both expose REST APIs for programmatic testing, support SMTP authentication, and provide web UIs for manual inspection. The main differences:

  • Performance: Mailpit starts faster and uses less memory (~10-20MB vs ~50-100MB)
  • UI: Mailpit has a more modern, responsive interface
  • Features: smtp4dev has more enterprise features (relay, conditional rules); Mailpit focuses on simplicity
  • Docker size: Mailpit image is smaller (~15MB vs ~200MB)

For most Spring Boot integration tests, either works great. I’ll show you smtp4dev here, but switching to Mailpit is as simple as changing the image name.

Here’s smtp4dev in action:

public class Smtp4devTestContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    public static final GenericContainer<?> smtp4devContainer = new GenericContainer<>(DockerImageName.parse("rnwood/smtp4dev:3.10.3"))
        .withExposedPorts(80, 25)
        .waitingFor(Wait.forHttp("/api/messages")
        .forPort(80)
        .forStatusCode(200));
    
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        smtp4devContainer.start();
        
        TestPropertyValues.of(
        "spring.mail.host=" + smtp4devContainer.getHost(),
        "spring.mail.port=" + smtp4devContainer.getMappedPort(25)
        ).applyTo(context.getEnvironment());
    }
}

Now in a test:

@SpringBootTest
@ContextConfiguration(initializers = Smtp4devTestContainerInitializer.class)
class KrustyKrabEmailServiceTest {

    @Autowired
    EmailService emailService;
    
    @Test
    void givenBikiniBottomCitizen_whenSendingWelcome_thenMessageSends() {
        emailService.sendWelcome("patrick.star@bikinibottom.com");
        
        RestTemplate rest = new RestTemplate();
        String messagesApi = "http://" +
        Smtp4devTestContainerInitializer.smtp4devContainer.getHost() + ":" +
        Smtp4devTestContainerInitializer.smtp4devContainer.getMappedPort(80) +
        "/api/messages";
        
        List<Map<String, Object>> messages = rest.getForObject(messagesApi, List.class);
        
        assertEquals(1, messages.size());
        assertThat(messages.get(0).get("subject").toString()).isEqualTo("Welcome"));
    }
}

Key things:

  • withExposedPorts(80, 25) exposes the web UI + SMTP port
  • waitingFor(Wait.forHttp(...)) waits until the API is really alive
  • TestPropertyValues wires the container config into Spring

Want to use Mailpit instead? Here’s the equivalent initializer:

public class MailpitTestContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    public static final GenericContainer<?> mailpitContainer = new GenericContainer<>(DockerImageName.parse("axllent/mailpit:v1.27.8"))
        .withExposedPorts(8025, 1025)
        .waitingFor(Wait.forHttp("/api/v1/messages")
        .forPort(8025)
        .forStatusCode(200));
    
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        mailpitContainer.start();
        
        TestPropertyValues.of(
        "spring.mail.host=" + mailpitContainer.getHost(),
        "spring.mail.port=" + mailpitContainer.getMappedPort(1025)
        ).applyTo(context.getEnvironment());
    }
}

The key differences:

  • Port 8025 for web UI (vs 80 in smtp4dev)
  • Port 1025 for SMTP (vs 25)
  • API path is /api/v1/messages instead of /api/messages

Both work identically in your tests. Choose smtp4dev if you need advanced relay features or are already using it. Choose Mailpit if you want faster container startup and a smaller Docker footprint.

PostgreSQL with Initialization Scripts

Sometimes I don’t want an empty database. I want schema + seed data loaded.

public class TestDatabaseInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("myapp")
        .withCopyFileToContainer(MountableFile.forClasspathResource("init_schema.sql"), "/docker-entrypoint-initdb.d/");
    
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        postgres.start();
        
        TestPropertyValues.of(
        "spring.datasource.url=" + postgres.getJdbcUrl(),
        "spring.datasource.username=" + postgres.getUsername(),
        "spring.datasource.password=" + postgres.getPassword()
        ).applyTo(context.getEnvironment());
    }
}

The trick: withCopyFileToContainer → place schema in /docker-entrypoint-initdb.d/. Postgres runs it automatically on startup.

Specialized Containers vs GenericContainer

You might have noticed I used both PostgreSQLContainer and GenericContainer.

Here’s the rule of thumb:

  • Use specialized containers when they exist (PostgreSQLContainer, MongoDBContainer, KafkaContainer, …). They give you nice defaults and shortcuts.
  • Use GenericContainer when no module exists (smtp4dev, Mailpit, custom APIs, your own Docker images).

Think of GenericContainer as the Swiss Army knife of Testcontainers. Everything else is built on top of it.

Pro Tips from My Experience

Reuse containers across tests

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16.3");

Make them static to avoid restarting for every method. Here’s why this matters:

Without static With static

Pin image versions

new PostgreSQLContainer<>("postgres:16-alpine"); // stable
new PostgreSQLContainer<>("postgres:latest"); // risky

Share initializers across test classes

@SpringBootTest
@ContextConfiguration(initializers = {
TestDatabaseInitializer.class,
Smtp4devTestContainerInitializer.class
})
class MyIntegrationTest { ... }

Don’t worry too much about test speed

Yes, Docker containers take a few seconds to start. But with static containers and proper reuse, I’ve found the startup cost is negligible compared to the confidence I gain. My test suite with PostgreSQL and smtp4dev adds maybe 5-10 seconds total. Well worth it.

Check logs when debugging

System.out.println(postgres.getLogs());

Wrapping Up

Testcontainers transformed how I think about integration testing.

I started with @Container and @DynamicPropertySource for simple cases. When I needed more control, I reached for ApplicationContextInitializer. And when I had to test against any Docker image, GenericContainer had my back.

The beauty is that I’m always testing against the real thing. Real databases, real SMTP servers, real APIs and this without any of the traditional headaches of managing those services locally.

If you’re not using Testcontainers yet, I encourage you to give it a try on your next Spring Boot project. Your future self (and your teammates) will thank you.

Now go forth and containerize those tests

Resources

Official Documentation:

Docker Images Used:

Related Tools: