Java: Integration Testing with Testcontainers

Introduction

Unit tests are excellent for checking business logic, but they cannot prove that an application works correctly with its database, message broker, or other external dependencies. Mocking every dependency may hide configuration errors, incompatible SQL, and differences between test doubles and production systems.

Testcontainers helps Java developers run integration tests against real services in disposable containers. This article demonstrates the approach using Spring Boot and PostgreSQL.

1. Why use Testcontainers?

A typical integration test may depend on a database. Developers sometimes use an in-memory database because it is easy to configure, but its behavior may differ from the production database in SQL support, constraints, indexing, or transaction semantics.

Testcontainers starts a real service in a container for the test lifecycle. For example, a test can use PostgreSQL even if the production database is PostgreSQL, improving confidence that the application interacts with the expected database engine.

You need Docker or a compatible container runtime available to run these tests.

2. Add the dependencies

For a Maven-based Spring Boot project, add the appropriate Testcontainers modules and test dependencies. Exact versions should be aligned with the Spring Boot and Testcontainers versions used by your project.

A simplified dependency set looks like this:

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

Use a compatible, explicitly managed version or the dependency-management mechanism recommended for your project’s chosen Spring Boot and Testcontainers releases.

3. Start a PostgreSQL container for the test

With a modern Spring Boot version that supports service connections, a test can declare its database container like this:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class ProductRepositoryIntegrationTest {

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

    @Test
    void applicationContextStarts() {
        // Spring Boot starts with the test database.
    }
}

The @Container annotation integrates the container with the Testcontainers JUnit extension. A static container is normally shared across test methods in that test class, rather than restarted for every method.

The @ServiceConnection annotation lets Spring Boot derive connection details from the container for supported services. If your version does not support it, configure the datasource properties using the container’s JDBC URL, username, and password instead.

4. Test actual database behavior

A context-startup test confirms that the application can start with the container, but repository integration tests should exercise real persistence behavior.

For example:

@SpringBootTest
@Testcontainers
class ProductRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    private final ProductRepository repository;

    ProductRepositoryTest(ProductRepository repository) {
        this.repository = repository;
    }

    @Test
    void savesAndLoadsProduct() {
        Product product = new Product("Keyboard");

        Product saved = repository.save(product);

        Product loaded = repository.findById(saved.getId())
            .orElseThrow();

        assertEquals("Keyboard", loaded.getName());
    }
}

This example assumes a Product entity and repository already exist. In a real test suite, also consider test-data cleanup, unique values, and transaction behavior so tests do not depend on execution order.

5. Test migrations, constraints, and transactions

A major advantage of a real database is that integration tests can verify the behaviors that mocks cannot reproduce accurately.

Useful scenarios include:

  • Applying database migrations from an empty database.
  • Checking unique constraints and foreign keys.
  • Verifying transaction rollback behavior.
  • Testing repository queries and sorting.
  • Confirming that indexes and database-specific features behave as expected.

Avoid writing tests that merely repeat the framework’s own behavior. Focus on the application’s assumptions and the database interactions most likely to fail in production.

6. Keep the test suite practical

Containers introduce startup time and environmental requirements. To keep feedback fast, use unit tests for pure logic, repository tests for database behavior, and a smaller number of end-to-end tests for critical workflows.

In CI, ensure the runner can start containers and allocate enough resources. Pin container image versions rather than relying on a mutable latest tag.

Conclusion

Testcontainers gives Java teams a practical way to test against real infrastructure without requiring every developer to maintain a permanent local test database. Used alongside unit tests, it helps catch configuration, SQL, migration, and persistence problems before deployment.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *