Testcontainers: Integration Testing with Docker

The biggest lie in software engineering is: “It works on my machine.”

Why does this happen? Because your local machine runs H2 (in-memory DB), but production runs PostgreSQL. H2 has different SQL syntax, locking semantics, and performance characteristics.

Testcontainers solves this by spinning up real Docker containers for your dependencies (PostgreSQL, Redis, Kafka) during the test execution.

1. The Genesis: Mocks vs Reality

Mocking (e.g., Mockito) is great for unit tests, but terrible for integration tests.

  • Mocks make assumptions: You assume the DB returns 5 rows. What if the SQL query is actually broken?
  • Mocks drift: The real API changes, but your mocks stay the same.

Testcontainers ensures Environment Parity. You test against the exact same binary that runs in production.

2. Core Concepts

  1. Container Object: Represents a Docker container.
  2. Wait Strategy: How do we know the database is ready to accept connections? (Port open? Log message?)
  3. Dynamic Ports: Containers bind to random ephemeral ports to avoid conflicts.

Java Implementation

```java import org.testcontainers.containers.PostgreSQLContainer; import org.junit.jupiter.api.Test; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers class UserRepoTest { // 1. Define the container (matches prod version) @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine"); @Test void shouldSaveUser() { // 2. Get dynamic JDBC URL String jdbcUrl = postgres.getJdbcUrl(); // 3. Run test against REAL Postgres UserRepo repo = new UserRepo(jdbcUrl, ...); repo.save(new User("Alice")); // 4. Container is killed automatically } } ```
```go package user_test import ( "context" "testing" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) func TestUserRepo(t *testing.T) { ctx := context.Background() // 1. Start Container req := testcontainers.ContainerRequest{ Image: "postgres:15-alpine", ExposedPorts: []string{"5432/tcp"}, WaitingFor: wait.ForLog("database system is ready"), Env: map[string]string{"POSTGRES_PASSWORD": "password"}, } postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { t.Fatal(err) } defer postgres.Terminate(ctx) // 4. Cleanup // 2. Get Mapped Port mappedPort, _ := postgres.MappedPort(ctx, "5432") // 3. Run Test... } ```

3. Hardware Reality: The Cost of Isolation

Spinning up a fresh container for every test method is mathematically sound (perfect isolation) but physically expensive.

  • Latency: Starting a JVM + Docker Engine + Postgres Container takes 2-5 seconds.
  • IOPS: Docker containers fight for disk I/O.
  • Context: The kernel has to context switch between the Test Process, Docker Daemon, and the Container Process.

Optimization: The Singleton Pattern

Start the container once for the entire test suite, and truncate tables between tests. This trades perfect isolation for speed (100x faster).

4. Interactive: Container Lifecycle

Visualize the orchestration between your Test Code and the Docker Daemon.

Test Process
junit.run()
➡️
IDLE
Docker Daemon
Waiting for command...

5. Advanced Modules

Testcontainers isn’t just for databases. It supports:

  • Kafka: new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"))
  • LocalStack: Simulate AWS S3, SQS, DynamoDB locally.
  • Selenium: Run browser tests in a containerized Chrome instance (recording video of failures!).