Extensão — Bancos de Verdade nos Testes: Testcontainers

Extensão — Bancos de Verdade nos Testes: Testcontainers

Nesta aula os testes passam a rodar contra o PostgreSQL e o MongoDB de verdade, em contêineres descartáveis que nascem e morrem com a suíte. Antes disso, você vai ver com os próprios olhos três mentiras de um banco "de brinquedo" e construir, sem Docker, o mecanismo que o Testcontainers automatiza.
Java

• • 27 min de leitura

Nesta aula os testes passam a rodar contra o PostgreSQL e o MongoDB de verdade, em contêineres descartáveis que nascem e morrem com a suíte. Antes disso, você vai ver com os próprios olhos três mentiras de um banco "de brinquedo" e construir, sem Docker, o mecanismo que o Testcontainers automatiza.

Nas duas aulas anteriores, todo teste usou dublês. O @WebMvcTest trocou o serviço por um mock do Mockito, e nenhuma linha de SQL chegou a um banco. É o certo para testar a camada web, mas deixa uma lacuna enorme: a migração do Flyway roda mesmo? O UNIQUE dispara o 409? A ordem alfabética sai como o usuário espera? Essas respostas só existem no banco real. A tentação histórica é usar um banco embutido, como H2 ou SQLite em memória, "porque é rápido e não precisa instalar nada". Esta aula começa mostrando por que essa economia sai cara.

Três mentiras de um banco falso

O programa abaixo roda em SQLite, com um pool HikariCP, como uma aplicação de verdade. São três armadilhas, cada uma em um método:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

/** Três coisas que um banco "de mentira" nos testes esconde. Tudo em SQLite, com um pool como em produção. */
void main() throws Exception {
    armadilha1_memoriaPorConexao();
    armadilha2_chaveEstrangeiraPorConexao();
    armadilha3_ordemEComparacao();
}

HikariDataSource pool(String url) {
    var cfg = new HikariConfig();
    cfg.setJdbcUrl(url);
    cfg.setMaximumPoolSize(2);
    cfg.setMinimumIdle(2);
    return new HikariDataSource(cfg);
}

/** 1. ":memory:" é um banco POR CONEXÃO. Com pool, cada conexão vê um banco diferente (e vazio). */
void armadilha1_memoriaPorConexao() throws SQLException {
    IO.println("--- 1. banco em memória + pool");
    try (var ds = pool("jdbc:sqlite::memory:");
         Connection a = ds.getConnection(); Connection b = ds.getConnection()) {
        a.createStatement().execute("CREATE TABLE trilha (id INTEGER PRIMARY KEY, nome TEXT)");
        a.createStatement().execute("INSERT INTO trilha (nome) VALUES ('Lagoinha do Leste')");
        IO.println("conexão A conta: " + contar(a));
        try {
            IO.println("conexão B conta: " + contar(b));
        } catch (SQLException e) {
            IO.println("conexão B: " + e.getMessage());
        }
    }
}

/** 2. PRAGMA foreign_keys vale só para a conexão onde foi executado. */
void armadilha2_chaveEstrangeiraPorConexao() throws Exception {
    IO.println("--- 2. chave estrangeira + pool");
    var arquivo = java.nio.file.Files.createTempFile("trilhas", ".db");
    try (var ds = pool("jdbc:sqlite:" + arquivo)) {
        try (Connection a = ds.getConnection(); Statement s = a.createStatement()) {
            s.execute("PRAGMA foreign_keys = ON");                   // só a conexão A liga a verificação
            s.execute("CREATE TABLE trilha (id INTEGER PRIMARY KEY, nome TEXT NOT NULL)");
            s.execute("CREATE TABLE ponto (trilha_id INTEGER NOT NULL REFERENCES trilha (id), nome TEXT)");
            tentar(s, "A (com PRAGMA)");
        }
        try (Connection b = ds.getConnection(); Connection c = ds.getConnection()) {
            // o pool devolve a conexão A e uma conexão B novinha; uma delas não tem o PRAGMA
            for (Connection x : List.of(b, c)) {
                try (Statement s = x.createStatement();
                     ResultSet rs = s.executeQuery("PRAGMA foreign_keys")) {
                    rs.next();
                    tentar(s, "conexão com foreign_keys=" + rs.getInt(1));
                }
            }
        }
    }
    try (var ds = pool("jdbc:sqlite:" + arquivo + "?foreign_keys=on");   // a correção: na URL, vale para TODAS
         Connection a = ds.getConnection(); Connection b = ds.getConnection()) {
        for (Connection x : List.of(a, b)) {
            try (Statement s = x.createStatement()) { tentar(s, "com ?foreign_keys=on na URL"); }
        }
    }
    java.nio.file.Files.delete(arquivo);
}

void tentar(Statement s, String rotulo) {
    try {
        s.executeUpdate("INSERT INTO ponto (trilha_id, nome) VALUES (999, 'órfão')");
        IO.println(rotulo + " → ACEITOU um ponto de trilha inexistente");
    } catch (SQLException e) {
        IO.println(rotulo + " → recusou: " + e.getMessage().replaceAll("\\s+", " "));
    }
}

/** 3. Ordenação e LIKE: as regras do SQLite não são as do PostgreSQL de produção. */
void armadilha3_ordemEComparacao() throws SQLException {
    IO.println("--- 3. ORDER BY e LIKE no SQLite");
    try (var ds = pool("jdbc:sqlite::memory:"); Connection c = ds.getConnection(); Statement s = c.createStatement()) {
        s.execute("CREATE TABLE trilha (nome TEXT)");
        s.execute("""
                INSERT INTO trilha VALUES ('Lagoinha do Leste'), ('lagoa Pequena'), ('Água Branca'),
                                          ('Zimbros'), ('Açores')""");
        IO.println("ORDER BY nome     → " + coluna(s.executeQuery("SELECT nome FROM trilha ORDER BY nome")));
        IO.println("LIKE 'lago%' acha → " + coluna(s.executeQuery("SELECT nome FROM trilha WHERE nome LIKE 'lago%' ORDER BY nome")));
    }
}

long contar(Connection c) throws SQLException {
    try (ResultSet rs = c.createStatement().executeQuery("SELECT count(*) FROM trilha")) {
        rs.next();
        return rs.getLong(1);
    }
}

List<String> coluna(ResultSet rs) throws SQLException {
    var lista = new ArrayList<String>();
    try (rs) { while (rs.next()) lista.add(rs.getString(1)); }
    return lista;
}
$ java -cp sqlite-jdbc.jar:HikariCP.jar:slf4j-api.jar --enable-native-access=ALL-UNNAMED ArmadilhasDoBancoFalso.java
--- 1. banco em memória + pool
conexão A conta: 1
conexão B: [SQLITE_ERROR] SQL error or missing database (no such table: trilha)
--- 2. chave estrangeira + pool
A (com PRAGMA) → recusou: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
conexão com foreign_keys=1 → recusou: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
conexão com foreign_keys=0 → ACEITOU um ponto de trilha inexistente
com ?foreign_keys=on na URL → recusou: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
com ?foreign_keys=on na URL → recusou: [SQLITE_CONSTRAINT_FOREIGNKEY] A foreign key constraint failed (FOREIGN KEY constraint failed)
--- 3. ORDER BY e LIKE no SQLite
ORDER BY nome     → [Açores, Lagoinha do Leste, Zimbros, lagoa Pequena, Água Branca]
LIKE 'lago%' acha → [Lagoinha do Leste, lagoa Pequena]

Cada armadilha ensina uma coisa diferente.

1. Banco em memória com pool. No SQLite, :memory: cria um banco por conexão. Sem pool, com uma conexão só, tudo parece funcionar. Com o pool que a aplicação usa em produção, a conexão B nasce com um banco vazio. O teste falha de forma intermitente, conforme a conexão que o pool entrega, e o sintoma ("tabela não existe", logo depois de criá-la) confunde qualquer um.

2. Configuração por conexão. Em "Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite" executamos PRAGMA foreign_keys = ON no início do programa, e estava certo, porque havia uma conexão só. Com pool, o PRAGMA vale só para a conexão onde rodou. As outras aceitam um ponto de interesse apontando para uma trilha que não existe, e o seu teste de "não aceita órfão" passa ou falha conforme a sorte. A correção é pôr a configuração na URL (?foreign_keys=on), para que toda conexão nova já nasça com ela, ou, num pool genérico, usar o connectionInitSql do Hikari. A lição vai além do SQLite: estado de sessão não sobrevive a um pool.

3. O banco decide a ordem. O SQLite compara strings byte a byte. Maiúsculas vêm antes de minúsculas, e "Água" vai para o fim porque o Á em UTF-8 vale mais que qualquer letra sem acento. O LIKE dele ignora maiúsculas e minúsculas nas letras ASCII. Vejamos, logo adiante, o que o PostgreSQL de produção responde para as mesmas perguntas.

Um PostgreSQL descartável, sem Docker

A ideia do Testcontainers cabe em uma frase: para cada execução dos testes, um banco novo, idêntico ao de produção, que desaparece no fim. Ele faz isso com contêineres Docker, mas o mecanismo fica mais claro se você o construir com as próprias mãos, usando os binários do PostgreSQL instalados na máquina:

import java.io.IOException;
import java.net.ServerSocket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * O coração do Testcontainers, sem Docker: um PostgreSQL novo, numa pasta temporária,
 * numa porta livre, que some quando o teste termina.
 */
static final class PostgresDescartavel implements AutoCloseable {
    private final Path pasta;
    private final Process processo;
    private final String url;

    PostgresDescartavel(Path binarios) throws IOException, InterruptedException, SQLException {
        pasta = Files.createTempDirectory("pg-descartavel-");
        Path dados = pasta.resolve("dados");

        // 1. Um cluster novo, com a MESMA configuração de idioma da produção
        executar(binarios.resolve("initdb").toString(), "-D", dados.toString(), "-U", "teste",
                "--auth=trust", "--encoding=UTF8", "--locale-provider=icu", "--icu-locale=pt-BR",
                "--locale=C.UTF-8", "--no-sync");

        // 2. Uma porta livre: o sistema escolhe (o Docker faz o mesmo ao mapear portas)
        int porta;
        try (var s = new ServerSocket(0)) { porta = s.getLocalPort(); }

        // 3. O servidor, com durabilidade desligada: num teste, perder dados num crash não importa
        processo = new ProcessBuilder(binarios.resolve("postgres").toString(), "-D", dados.toString(),
                "-p", String.valueOf(porta), "-k", pasta.toString(),
                "-c", "listen_addresses=127.0.0.1", "-c", "fsync=off", "-c", "synchronous_commit=off")
                .redirectErrorStream(true)
                .redirectOutput(pasta.resolve("postgres.log").toFile())
                .start();
        url = "jdbc:postgresql://127.0.0.1:" + porta + "/postgres";

        // 4. A "estratégia de espera": só devolve o banco quando ele aceita conexões de verdade
        esperarPronto(Duration.ofSeconds(30));
    }

    Connection conectar() throws SQLException {
        return DriverManager.getConnection(url, "teste", "");
    }

    String url() { return url; }

    private void esperarPronto(Duration prazo) throws InterruptedException, SQLException, IOException {
        long limite = System.nanoTime() + prazo.toNanos();
        while (true) {
            if (!processo.isAlive()) {
                throw new IllegalStateException("o PostgreSQL morreu:\n" + Files.readString(pasta.resolve("postgres.log")));
            }
            try (Connection c = conectar()) {
                return;                                       // conectou: está pronto
            } catch (SQLException e) {
                if (System.nanoTime() > limite) throw e;
                Thread.sleep(50);
            }
        }
    }

    @Override
    public void close() throws IOException, InterruptedException {
        processo.destroy();                                   // SIGTERM: o PostgreSQL encerra direito
        if (!processo.waitFor(10, TimeUnit.SECONDS)) processo.destroyForcibly().waitFor();
        try (var arquivos = Files.walk(pasta)) {              // e não sobra nada no disco
            for (Path p : arquivos.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
        }
    }

    private static void executar(String... comando) throws IOException, InterruptedException {
        Process p = new ProcessBuilder(comando).redirectErrorStream(true).start();
        String saida = new String(p.getInputStream().readAllBytes());
        if (p.waitFor() != 0) throw new IllegalStateException(String.join(" ", comando) + "\n" + saida);
    }
}

// ---- Um "teste de integração" de verdade, sem framework, só para ver o ciclo completo ----
void main(String[] args) throws Exception {
    Path binarios = Path.of(System.getenv().getOrDefault("PG_BIN", "/usr/lib/postgresql/18/bin"));
    Path migracao = Path.of(args[0]);

    long t0 = System.nanoTime();
    try (var pg = new PostgresDescartavel(binarios)) {
        IO.println("PostgreSQL pronto em %d ms em %s".formatted((System.nanoTime() - t0) / 1_000_000, pg.url()));

        try (Connection c = pg.conectar(); Statement s = c.createStatement()) {
            s.execute(Files.readString(migracao));            // o MESMO V1.sql da produção
            s.execute("""
                    INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min) VALUES
                      ('Lagoinha do Leste', 'Sul', 4.5, 'MODERADA', 120), ('lagoa Pequena', 'Leste', 1.2, 'FACIL', 30),
                      ('Água Branca', 'Norte', 3.0, 'FACIL', 70), ('Zimbros', 'Norte', 5.5, 'MODERADA', 140),
                      ('Açores', 'Sul', 2.0, 'FACIL', 50)""");

            IO.println("ORDER BY nome     → " + coluna(s.executeQuery("SELECT nome FROM trilha ORDER BY nome")));
            IO.println("LIKE 'lago%' acha → " + coluna(s.executeQuery("SELECT nome FROM trilha WHERE nome LIKE 'lago%' ORDER BY nome")));
            IO.println("ILIKE 'lago%' acha → " + coluna(s.executeQuery("SELECT nome FROM trilha WHERE nome ILIKE 'lago%' ORDER BY nome")));

            verificar("nome repetido é recusado com 23505", () ->
                    s.execute("INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min) VALUES ('Zimbros','Sul',1,'FACIL',10)"),
                    "23505");
            verificar("ponto de trilha inexistente é recusado com 23503", () ->
                    s.execute("INSERT INTO ponto_de_interesse (trilha_id, ordem, nome) VALUES (999, 1, 'órfão')"),
                    "23503");
            verificar("dificuldade inválida é recusada com 23514", () ->
                    s.execute("INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min) VALUES ('X','Sul',1,'EXTREMA',10)"),
                    "23514");
        }
    }
    IO.println("banco destruído; tempo total %d ms".formatted((System.nanoTime() - t0) / 1_000_000));
}

interface Acao { void executar() throws SQLException; }

void verificar(String descricao, Acao acao, String sqlStateEsperado) {
    try {
        acao.executar();
        IO.println("FALHOU: " + descricao + " (o banco aceitou)");
    } catch (SQLException e) {
        IO.println((sqlStateEsperado.equals(e.getSQLState()) ? "ok: " : "FALHOU: ") + descricao
                + " [" + e.getSQLState() + "]");
    }
}

List<String> coluna(ResultSet rs) throws SQLException {
    var lista = new ArrayList<String>();
    try (rs) { while (rs.next()) lista.add(rs.getString(1)); }
    return lista;
}
$ java -cp postgresql.jar PostgresDescartavel.java V1.sql
PostgreSQL pronto em 727 ms em jdbc:postgresql://127.0.0.1:45667/postgres
ORDER BY nome     → [Açores, Água Branca, lagoa Pequena, Lagoinha do Leste, Zimbros]
LIKE 'lago%' acha → [lagoa Pequena]
ILIKE 'lago%' acha → [lagoa Pequena, Lagoinha do Leste]
ok: nome repetido é recusado com 23505 [23505]
ok: ponto de trilha inexistente é recusado com 23503 [23503]
ok: dificuldade inválida é recusada com 23514 [23514]
banco destruído; tempo total 814 ms

(O initdb se recusa a rodar como root. Se a sua máquina exigir, rode o exemplo com um usuário comum.)

Compare as respostas com as do SQLite. Na ordem alfabética com a collation ICU pt-BR, "Açores" e "Água Branca" ficam no começo, onde um leitor brasileiro espera. Maiúsculas e minúsculas se misturam como num dicionário. O LIKE do PostgreSQL diferencia maiúsculas: quem quiser o comportamento do SQLite precisa pedir ILIKE. Um teste escrito contra o SQLite teria "confirmado" um comportamento que a produção não tem. E as três constraints responderam com SQLStates da classe 23, a das violações de integridade, que o Spring converte em DataIntegrityViolationException e o TratadorDeErros de "Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI" traduz em 409.

As quatro etapas do construtor são exatamente o que o Testcontainers faz por você:

  1. Preparar uma instância nova e isolada. Nós usamos uma pasta temporária; ele usa um contêiner a partir de uma imagem.
  2. Escolher uma porta livre. Nós pedimos ao sistema operacional uma porta efêmera. O Docker mapeia a porta do contêiner para uma porta aleatória do host, e assim dez suítes podem rodar em paralelo na mesma máquina.
  3. Configurar para teste. Desligamos fsync, porque um banco de teste não precisa sobreviver a uma queda de energia, e isso o deixa bem mais rápido.
  4. Esperar de verdade. Um processo que iniciou não é um banco que aceita conexões. Sem a espera, o primeiro teste falha de vez em quando, e esse é o pior tipo de teste. O Testcontainers tem wait strategies prontas. A do PostgreSQL espera a mensagem "database system is ready to accept connections" aparecer duas vezes no log, porque o script de entrada da imagem sobe o banco, roda a inicialização e reinicia.

E o close() limpa tudo. O Testcontainers vai além: um contêiner auxiliar chamado Ryuk vigia a JVM e remove os contêineres mesmo se ela morrer no meio, com um kill -9 ou um teste travado. Nossa miniatura deixaria lixo nesse caso.

Testcontainers com Spring Boot

Com Docker instalado (o Docker Desktop, o Podman em modo compatível ou o Docker Engine no Linux), as dependências de teste são estas:

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

O Spring Boot 4 gerencia as versões, e por isso não há <version>. Na lista falta só o spring-boot-starter-data-mongodb-test, que traz o @DataMongoTest usado mais adiante; os equivalentes para JPA e para a camada web vieram com as aulas anteriores. No Testcontainers 2 os módulos ganharam o prefixo testcontainers- e as classes foram para pacotes próprios (org.testcontainers.postgresql, org.testcontainers.mongodb). Muitos tutoriais na internet ainda mostram o formato 1.x, com org.testcontainers:postgresql e org.testcontainers.containers.PostgreSQLContainer<?>.

Os contêineres ficam declarados em um só lugar, reaproveitado por todos os testes:

package br.com.exemplo.trilhas;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mongodb.MongoDBContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;

/** Os bancos dos testes: as MESMAS versões e o MESMO idioma da produção. */
@TestConfiguration(proxyBeanMethods = false)
public class ContainersDeTeste {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer("postgres:18")
                // a collation da produção: sem isso, ORDER BY nome ordena diferente nos testes
                .withEnv("POSTGRES_INITDB_ARGS", "--locale-provider=icu --icu-locale=pt-BR");
    }

    @Bean
    @ServiceConnection
    MongoDBContainer mongo() {
        return new MongoDBContainer("mongo:8");
    }
}

O @ServiceConnection é a peça que costura tudo. O Spring Boot vê um PostgreSQLContainer e cria sozinho os detalhes de conexão (spring.datasource.url, usuário e senha), apontando para a porta aleatória daquele contêiner. Com o MongoDBContainer, faz o mesmo para spring.mongodb.uri. Nenhuma propriedade escrita à mão, nenhuma porta fixa. Declarar os contêineres como beans também dá ao Spring o controle do ciclo de vida: eles sobem antes de quem depende deles e param quando o contexto fecha.

Duas escolhas ali vêm diretamente da primeira seção. A versão da imagem é a mesma da produção: testar em postgres:latest e rodar no 18 é trocar uma mentira por outra. E o idioma é o da produção: o POSTGRES_INITDB_ARGS é repassado ao initdb pela imagem oficial, exatamente como fizemos na miniatura.

Testando o repositório JPA contra o PostgreSQL

package br.com.exemplo.trilhas;

import br.com.exemplo.trilhas.api.ResumoDeTrilha;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

import java.time.Duration;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@DataJpaTest
@Import(ContainersDeTeste.class)
class RepositorioDeTrilhasIT {

    @Autowired RepositorioDeTrilhas trilhas;

    Trilha nova(String nome) {
        return new Trilha(nome, "Sul", 2.0, Dificuldade.FACIL, Duration.ofMinutes(40), List.of("Início"));
    }

    @Test
    void ordenaComoUmLeitorBrasileiroEspera() {
        // "Lago Azul" separa as regras: o en_US da glibc ignora o espaço e o poria depois de "lagoa Pequena"
        for (String n : List.of("Zimbros", "lagoa Pequena", "Água Branca", "Lagoinha do Leste", "Açores", "Lago Azul")) {
            trilhas.save(nova(n));
        }
        var pagina = trilhas.findAllBy(PageRequest.of(0, 10, Sort.by("nome")));
        assertThat(pagina.getContent()).extracting(ResumoDeTrilha::nome)
                .containsExactly("Açores", "Água Branca", "Lago Azul", "lagoa Pequena", "Lagoinha do Leste", "Zimbros");
    }

    @Test
    void nomeRepetidoEBarradoPeloBanco() {
        trilhas.saveAndFlush(nova("Morro das Aranhas"));
        assertThatThrownBy(() -> trilhas.saveAndFlush(nova("Morro das Aranhas")))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void migracoesCriaramAColunaDeBusca() {
        trilhas.saveAndFlush(nova("Cachoeira do Poção"));
        // a coluna gerada da busca textual (tsvector 'portuguese') só existe no PostgreSQL real
        assertThat(trilhas.count()).isEqualTo(1);
    }
}

Quatro coisas acontecem aqui sem que você as peça.

  • O Flyway roda. O @DataJpaTest aplica as migrações de verdade, com os mesmos arquivos que vão para produção. Uma migração quebrada falha aqui, e não no deploy.
  • O banco não é substituído. Por padrão, o @AutoConfigureTestDatabase embutido no @DataJpaTest troca o DataSource por um banco em memória. A exceção é um banco definido pelo próprio teste, que é o caso de um @ServiceConnection. O padrão (Replace.NON_TEST) faz exatamente isso, e o PostgreSQL do contêiner é mantido.
  • Cada teste é uma transação desfeita. O @DataJpaTest é @Transactional, e no fim de cada teste há um rollback. Por isso o primeiro teste pode inserir cinco trilhas sem atrapalhar o segundo. Um detalhe: sequências não voltam atrás no rollback, e por isso os testes nunca comparam ids.
  • saveAndFlush, e não save. Em geral, o save só agenda o comando, que sairia no flush do commit, e como o teste termina em rollback, o commit nunca acontece. A trilha usa IDENTITY, e aí há uma exceção: para conhecer o id, o Hibernate executa o INSERT já no save. Medido no Hibernate 7.4.5 com o PostgreSQL 18: o segundo persist com o mesmo nome lança ConstraintViolationException na hora, enquanto trocar o nome de uma trilha já salva por um repetido não dá erro nenhum até o flush. Um teste de alteração sem flush passaria sem testar nada, e o mesmo valeria para a inserção se a entidade usasse SEQUENCE. O saveAndFlush deixa explícito que o teste depende do banco, seja qual for a estratégia de id. É o descarregamento (flush) de "Extensão — Objetos no Banco: JPA e Hibernate" armando uma armadilha para testes.

Testando os relatos contra o MongoDB

package br.com.exemplo.trilhas.relatos;

import br.com.exemplo.trilhas.ContainersDeTeste;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest;
import org.springframework.context.annotation.Import;

import java.time.Instant;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@DataMongoTest
@Import(ContainersDeTeste.class)
class RelatosNoMongoIT {

    @Autowired RelatosNoMongo relatos;

    @BeforeEach
    void limpar() {
        relatos.deleteAll();          // Mongo não participa do rollback: a limpeza é explícita
    }

    RelatoDocumento relato(long trilhaId, String autor, String dia, int nota, Map<String, Object> condicoes) {
        return new RelatoDocumento(null, trilhaId, "Trilha " + trilhaId, autor, Instant.parse(dia + "T00:00:00Z"),
                nota, "texto", List.of("praia"), condicoes, List.of());
    }

    @Test
    void dezMaisRecentesDaTrilhaEmOrdemDecrescente() {
        relatos.saveAll(List.of(
                relato(1, "ana", "2026-08-02", 5, Map.of()),
                relato(1, "bruno", "2026-08-15", 4, Map.of()),
                relato(2, "diego", "2026-09-01", 3, Map.of())));

        assertThat(relatos.findTop10ByTrilhaIdOrderByDataDesc(1))
                .extracting(RelatoDocumento::autorId)
                .containsExactly("bruno", "ana");
    }

    @Test
    void consultaPorCampoAninhado() {
        relatos.saveAll(List.of(
                relato(1, "ana", "2026-08-02", 5, Map.of("lama", "pouca")),
                relato(1, "bruno", "2026-08-15", 4, Map.of("lama", "muita"))));

        assertThat(relatos.comLama("muita")).extracting(RelatoDocumento::autorId).containsExactly("bruno");
    }

    @Test
    void oIdGeradoVoltaNoRecord() {
        RelatoDocumento salvo = relatos.save(relato(3, "ana", "2026-09-10", 5, Map.of()));
        assertThat(salvo.id()).isNotBlank();      // prova que o withId() do record foi usado
    }
}

O último teste existe por um motivo específico. Em "Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo", avisei que a gravação de records pelo Spring Data depende do método withId. Esse tipo de detalhe é o que a documentação descreve e só um banco real confirma, e agora há um teste para ele.

A pilha inteira: token, API e dois bancos

O teste mais valioso atravessa tudo: segurança, controller, serviço, JPA, Flyway e PostgreSQL, como uma requisição de verdade.

package br.com.exemplo.trilhas;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(properties = "trilhas.jwt.segredo=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
@AutoConfigureMockMvc
@Import(ContainersDeTeste.class)
class ApiDeTrilhasIT {

    @Autowired MockMvcTester mvc;
    @Autowired JdbcClient jdbc;
    @Autowired PasswordEncoder encoder;

    @BeforeEach
    void estadoConhecido() {
        jdbc.sql("TRUNCATE trilha, usuario RESTART IDENTITY CASCADE").update();
        jdbc.sql("INSERT INTO usuario (login, senha_hash, papeis) VALUES ('edu', ?, '{EDITOR}')")
                .param(encoder.encode("senha-de-teste")).update();
    }

    String token() {
        var resposta = mvc.post().uri("/api/v1/auth/token").contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"usuario": "edu", "senha": "senha-de-teste"}""").exchange();
        assertThat(resposta).hasStatusOk();
        return resposta.getResponse().getContentAsString().replaceAll(".*\"access_token\":\"([^\"]+)\".*", "$1");
    }

    @Test
    void editorCriaELeituraPublicaEncontra() throws Exception {
        String jwt = token();

        assertThat(mvc.post().uri("/api/v1/trilhas").header("Authorization", "Bearer " + jwt)
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"nome":"Caminho do Ribeirão","regiao":"Sul","distanciaKm":5,
                         "dificuldade":"FACIL","duracaoMin":100,"pontosDeInteresse":["Igreja","Engenho"]}"""))
                .hasStatus(HttpStatus.CREATED)
                .headers().containsHeader("Location");

        assertThat(mvc.get().uri("/api/v1/trilhas?regiao=Sul"))       // sem token: leitura é pública
                .hasStatusOk()
                .bodyJson().extractingPath("$.itens[0].nome").isEqualTo("Caminho do Ribeirão");
    }

    @Test
    void mesmoNomeDuasVezesDa409() {
        String jwt = token();
        String corpo = """
                {"nome":"Repetida","regiao":"Sul","distanciaKm":1,"dificuldade":"FACIL","duracaoMin":10}""";
        for (int i = 0; i < 2; i++) {
            var r = mvc.post().uri("/api/v1/trilhas").header("Authorization", "Bearer " + jwt)
                    .contentType(MediaType.APPLICATION_JSON).content(corpo);
            assertThat(r).hasStatus(i == 0 ? HttpStatus.CREATED : HttpStatus.CONFLICT);
        }
    }
}

Aqui não há @Transactional no teste. Cada requisição faz o seu próprio commit, como em produção. Por isso a limpeza é explícita, no @BeforeEach, com TRUNCATE ... RESTART IDENTITY CASCADE. Começar cada teste de um estado conhecido é mais robusto que tentar desfazer tudo no fim, porque um teste que falhou no meio não deixa sujeira para o próximo.

Velocidade: o que custa e como pagar menos

Subir um PostgreSQL leva de 1 a 3 segundos em contêiner (a nossa miniatura levou entre 580 e 730 ms), e o MongoDB, algo parecido. Multiplicado por cem classes de teste, isso seria inviável. Três mecanismos resolvem.

  • Cache de contexto. O Spring guarda o contexto de aplicação entre classes de teste com a mesma configuração. Como todos os testes importam o mesmo ContainersDeTeste, o contexto (e os contêineres, que são beans dele) sobe uma vez por execução da suíte. A regra prática é não inventar variações de configuração sem necessidade: cada @MockitoBean diferente ou cada properties diferente cria outro contexto, e com ele outros contêineres.
  • Contêineres reutilizáveis durante o desenvolvimento. Com .withReuse(true) no contêiner e testcontainers.reuse.enable=true no arquivo ~/.testcontainers.properties, o contêiner sobrevive entre execuções. É útil quando você roda um teste de cada vez na IDE. Na integração contínua, deixe isso desligado.
  • Separar testes rápidos de lentos. Por convenção, as classes *Test rodam no mvn test pelo Surefire (unidade e @WebMvcTest, em segundos), e as classes *IT rodam no mvn verify pelo plugin Failsafe (com contêineres). No dia a dia você roda os rápidos, e antes do push, tudo.

Há ainda um bônus. O mesmo ContainersDeTeste serve para rodar a aplicação durante o desenvolvimento, sem instalar banco nenhum. Basta criar, em src/test/java:

package br.com.exemplo.trilhas;

import org.springframework.boot.SpringApplication;

public class AplicacaoComContainers {
    public static void main(String[] args) {
        SpringApplication.from(Aplicacao::main)          // a classe principal de sempre...
                .with(ContainersDeTeste.class)           // ...com os bancos dos testes
                .run(args);
    }
}

Rode com ./mvnw spring-boot:test-run e a aplicação sobe com PostgreSQL e MongoDB em contêineres. Um colega novo na equipe precisa só de Java e Docker para ter o sistema inteiro funcionando.

Na integração contínua, os executores Linux do GitHub Actions já trazem Docker, e o passo é um simples ./mvnw verify. Em ambientes sem Docker, um serviço de contêineres remoto ou o próprio Testcontainers Cloud cumprem o papel.

Com os testes de integração, o código conversa com os bancos de verdade e prova isso a cada execução. O que ainda não existe é uma cara para o sistema: tudo até aqui é JSON, legível para programas e opaco para pessoas.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Na miniatura, a porta é escolhida abrindo e fechando um ServerSocket(0) e só depois passada ao PostgreSQL. Que problema raro isso pode causar numa máquina de CI que roda muitas suítes em paralelo, e como o Docker o evita?

Ver resposta

✓ Resposta: Existe uma janela entre fechar o ServerSocket e o PostgreSQL abrir a porta. Nesse intervalo, outro processo pode receber a mesma porta efêmera do sistema operacional, e o PostgreSQL falha com "address already in use". É raro, mas em CI com dezenas de suítes simultâneas acontece. O Docker evita a corrida porque quem escolhe a porta do host e já a reserva é o próprio daemon, ao mapear -p 0:5432. O processo dentro do contêiner escuta sempre na 5432, dentro de uma rede isolada, e não disputa portas com ninguém.

Exercício 2

Um colega quer remover o POSTGRES_INITDB_ARGS do ContainersDeTeste, "porque é configuração demais para um teste". Qual teste desta aula fica vermelho, e por que a lista de nomes desse teste inclui a "Lago Azul"?

Ver resposta

✓ Resposta: ordenaComoUmLeitorBrasileiroEspera falha, por causa de um nome só. Sem o argumento, a imagem oficial cria o banco com a locale en_US.utf8 da glibc, cuja regra ignora o espaço na primeira comparação: "Lago Azul" é comparado como se fosse "LagoAzul" e vai para depois de "lagoa Pequena". Na ICU pt-BR, o espaço conta, e "Lago Azul" vem antes. Medido no PostgreSQL 18: os outros cinco nomes saem na mesma ordem nas duas regras, e sem a "Lago Azul" o teste continuaria verde mesmo sem a configuração. É por isso que ela está na lista: um teste só protege uma configuração se tiver um caso em que as duas regras discordam. Testes verdes depois de uma mudança provam apenas que nenhum deles a exercitava. O objetivo do banco de teste é ser igual ao de produção, e cada diferença é um lugar onde um bug pode se esconder.

Exercício 3

Reescreva o teste nomeRepetidoEBarradoPeloBanco usando save em vez de saveAndFlush nas duas chamadas. O que acontece, e por quê?

Ver resposta

✓ Resposta: O teste continua passando, mas por um motivo que não tem nada a ver com o flush. A trilha usa IDENTITY, e o Hibernate só conhece o id depois do INSERT; por isso cada save executa o comando na hora, e o segundo esbarra no UNIQUE. O Hibernate lança ConstraintViolationException, que o repositório do Spring traduz para DataIntegrityViolationException. O teste ficou frágil, porém. Se a entidade passar a usar SEQUENCE, o Hibernate busca o id na sequência e adia o INSERT para o flush, que só aconteceria no commit, e o @DataJpaTest termina com rollback: o teste falharia dizendo que nenhuma exceção foi lançada. O mesmo já vale hoje para uma alteração, que espera o flush com qualquer estratégia de id. Medido no Hibernate 7.4.5 com o PostgreSQL 18: com SEQUENCE, os dois persist com o mesmo nome só consultam a sequência e não lançam nada, e a troca do nome de uma trilha já salva por um repetido só falha no flush(). Sempre que o teste depende de uma regra do banco, force o flush.

Exercício 4

Por que o ApiDeTrilhasIT limpa as tabelas no @BeforeEach em vez de marcar a classe com @Transactional para desfazer tudo no fim?

Ver resposta

✓ Resposta: Neste teste, cada requisição passa pelo serviço, que abre e confirma a sua própria transação. Um @Transactional no teste até envolveria as chamadas com MockMvc (que rodam na mesma thread), mas daria um retrato falso: as transações do serviço seriam absorvidas pela transação do teste, e o comportamento de commit, como o UNIQUE disparando no commit, ficaria diferente do real. Limpar no início também garante um estado conhecido mesmo depois de um teste que falhou no meio e deixou dados para trás.

Exercício 5

Adapte a miniatura PostgresDescartavel para que ela também receba uma pasta de migrações e as aplique em ordem de versão (V1__..., V2__...) antes de devolver o banco, como o Flyway faria.

Ver resposta

✓ Resposta: Acrescente ao construtor um parâmetro Path migracoes e, depois de esperarPronto, faça algo como:

try (var arquivos = Files.list(migracoes); Connection c = conectar(); Statement s = c.createStatement()) {
    List<Path> ordem = arquivos.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
            .sorted(Comparator.comparingInt(p -> Integer.parseInt(
                    p.getFileName().toString().substring(1, p.getFileName().toString().indexOf("__")))))
            .toList();
    for (Path p : ordem) s.execute(Files.readString(p));
}

A ordenação precisa ser numérica: em ordem de texto, V10 viria antes de V2. Um MiniFlyway completo também registraria cada versão com seu checksum numa tabela, e rodaria cada migração em sua própria transação.

Comentários

Mais em Java

Listas, Conjuntos e Mapas — o Collections Framework
Listas, Conjuntos e Mapas — o Collections Framework

O Collections Framework do Java: interfaces e implementações, ArrayList e a…

Cada Valor em sua Caixa — Tipos Primitivos, Variáveis e Conversões
Cada Valor em sua Caixa — Tipos Primitivos, Variáveis e Conversões

Variáveis, tipagem estática e os oito tipos primitivos do Java: faixas…

Hierarquias Fechadas — Sealed Classes e Pattern Matching
Hierarquias Fechadas — Sealed Classes e Pattern Matching

Sealed classes e pattern matching em Java: permits, final, sealed e…