Como usar testcontainers para testes de integração com dependências reais

1. Introdução aos Testcontainers e o problema das dependências reais

1.1. O desafio de testar integrações com bancos de dados, filas e serviços externos

Testes de integração frequentemente falham por dependerem de ambientes externos instáveis. Bancos de dados temporários como H2 possuem diferenças sintáticas e comportamentais em relação ao PostgreSQL ou MySQL. Filas de mensagens locais não reproduzem fielmente o comportamento do Kafka ou RabbitMQ em produção. Serviços mockados com WireMock não simulam corretamente timeouts, concorrência ou consistência eventual. Essas limitações geram falsos positivos — testes que passam em desenvolvimento mas quebram em produção.

1.2. O que são Testcontainers: containers descartáveis para testes

Testcontainers é uma biblioteca open-source que gerencia containers Docker diretamente a partir do código de teste. Cada container é criado, configurado e destruído programaticamente, garantindo isolamento total entre execuções. Diferente de bancos embutidos, você usa exatamente a mesma imagem do banco de dados que rodará em produção — mesma versão, mesma engine, mesmas configurações.

1.3. Vantagens sobre mocks e bancos embutidos (H2, HSQLDB)

Abordagem Realismo Manutenção Velocidade
H2/HSQLDB Baixo (dialeto diferente) Média (adaptar queries) Alta
Mocks (Mockito/WireMock) Baixo (comportamento simulado) Alta (atualizar mocks) Alta
Testcontainers Máximo (mesma imagem produção) Baixa (container real) Média (start/stop)

2. Configuração inicial do ambiente com Testcontainers

2.1. Adicionando dependências (Java/Spring Boot)

Para projetos Maven com Spring Boot, adicione ao pom.xml:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.20.4</version>
    <scope>test</scope>
</dependency>

2.2. Configuração básica de um container PostgreSQL com @Container

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public class UsuarioRepositoryTest {

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

    @Test
    void deveConectarAoBancoReal() {
        String jdbcUrl = postgres.getJdbcUrl();
        String username = postgres.getUsername();
        String password = postgres.getPassword();
        // Aqui você injeta essas configurações no DataSource
        System.out.println("Conectado em: " + jdbcUrl);
    }
}

2.3. Gerenciamento de ciclo de vida: start, stop e reuso entre testes

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withReuse(true); // Reutiliza container entre execuções (acelera CI)

// Para parada explícita:
// postgres.stop();

3. Testes de integração com bancos de dados relacionais

3.1. Subindo um container PostgreSQL com dados de seed

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withInitScript("init-schema.sql"); // Cria tabelas e insere dados iniciais

// init-schema.sql:
// CREATE TABLE usuarios (id SERIAL PRIMARY KEY, nome VARCHAR(100));
// INSERT INTO usuarios (nome) VALUES ('João'), ('Maria');

3.2. Executando consultas SQL e verificando resultados com JPA ou JDBC

@Test
void deveBuscarUsuarios() {
    // Usando JDBC diretamente
    try (Connection conn = DriverManager.getConnection(
            postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM usuarios");
        rs.next();
        assertEquals(2, rs.getInt(1));
    }
}

3.3. Estratégias para resetar dados entre testes (clean-up e rollback)

@BeforeEach
void limparDados() {
    try (Connection conn = DriverManager.getConnection(
            postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
        conn.createStatement().execute("TRUNCATE TABLE usuarios RESTART IDENTITY CASCADE");
    }
}

4. Simulação de dependências de mensageria e filas

4.1. Testando com RabbitMQ ou Kafka em containers isolados

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

@Container
static RabbitMQContainer rabbitMQ = new RabbitMQContainer(
    DockerImageName.parse("rabbitmq:3.13-management"));

4.2. Publicação e consumo de mensagens: assertions em filas

@Test
void devePublicarEMensagem() {
    // Configura producer
    Properties props = new Properties();
    props.put("bootstrap.servers", kafka.getBootstrapServers());
    props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

    try (Producer<String, String> producer = new KafkaProducer<>(props)) {
        producer.send(new ProducerRecord<>("test-topic", "chave", "valor"));
    }

    // Consome e verifica
    try (Consumer<String, String> consumer = criarConsumidor(kafka.getBootstrapServers())) {
        consumer.subscribe(List.of("test-topic"));
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
        assertFalse(records.isEmpty());
        assertEquals("valor", records.iterator().next().value());
    }
}

4.3. Lidando com timeouts e ordenação de mensagens

Use awaitility para esperar assíncronamente:

await().atMost(10, SECONDS).until(() -> {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    return records.count() >= 3;
});

5. Integração com bancos NoSQL e serviços de cache

5.1. Testando com MongoDB ou Redis em containers

@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");

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

5.2. Operações CRUD e verificação de dados no cache

@Test
void deveArmazenarERecuperarDoRedis() {
    String redisHost = redis.getHost();
    Integer redisPort = redis.getMappedPort(6379);

    try (Jedis jedis = new Jedis(redisHost, redisPort)) {
        jedis.set("chave", "valor");
        assertEquals("valor", jedis.get("chave"));
    }
}

5.3. Limpeza automática de coleções/keys entre testes

@BeforeEach
void limparRedis() {
    try (Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379))) {
        jedis.flushAll();
    }
}

6. Boas práticas e otimizações para pipelines CI/CD

6.1. Reutilização de containers para acelerar execução

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

Configure ~/.testcontainers.properties:

testcontainers.reuse.enable=true

6.2. Configuração de recursos (memória, portas) e isolamento

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withCreateContainerCmdModifier(cmd -> cmd.withMemory(512 * 1024 * 1024L))
    .withExposedPorts(5432);

6.3. Integração com Docker Compose para dependências múltiplas

@Container
static DockerComposeContainer<?> environment = new DockerComposeContainer<>(
        new File("src/test/resources/docker-compose.yml"))
    .withExposedService("postgres", 5432)
    .withExposedService("kafka", 9092);

7. Casos avançados: dependências customizadas e redes

7.1. Criando containers com imagens customizadas (Dockerfile) para testes

GenericContainer<?> customService = new GenericContainer<>(
        new ImageFromDockerfile("meu-servico-test", false)
            .withDockerfileFromBuilder(builder -> builder
                .from("openjdk:17-slim")
                .copy("app.jar", "/app.jar")
                .cmd("java", "-jar", "/app.jar")))
    .withExposedPorts(8080);

7.2. Testando com redes Docker entre containers

Network network = Network.newNetwork();

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withNetwork(network)
    .withNetworkAliases("postgres");

@Container
static GenericContainer<?> app = new GenericContainer<>("minha-app:latest")
    .withNetwork(network)
    .dependsOn(postgres);

7.3. Simulação de falhas: parada de container e testes de resiliência

@Test
void deveLidarComFalhaDoBanco() {
    postgres.stop();
    assertThrows(DataAccessException.class, () -> repositorio.buscarTodos());
}

8. Conclusão e próximos passos

8.1. Resumo dos benefícios: confiabilidade, realismo e baixa manutenção

Testcontainers elimina a lacuna entre ambiente de teste e produção. Você testa contra exatamente as mesmas versões de banco, fila e cache que rodam em produção. Mocks e bancos embutidos geram manutenção constante; com Testcontainers, a imagem é a fonte da verdade. O custo inicial de configuração é rapidamente amortizado pela redução de falsos positivos e pela confiança nos testes.

8.2. Comparação com outras abordagens (Testcontainers vs WireMock vs mocks)

Use Testcontainers quando precisar de realismo total (banco, fila, cache). Use WireMock para serviços HTTP externos que você não controla. Use mocks (Mockito) para lógica de negócio isolada. A combinação ideal: testes unitários com mocks + testes de integração com Testcontainers + testes de contrato com WireMock.

8.3. Referências para aprofundamento