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ê:
- Preparar uma instância nova e isolada. Nós usamos uma pasta temporária; ele usa um contêiner a partir de uma imagem.
- 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.
- 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. - 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
@DataJpaTestaplica 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
@AutoConfigureTestDatabaseembutido no@DataJpaTesttroca oDataSourcepor 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ãosave. Em geral, osavesó agenda o comando, que sairia no flush do commit, e como o teste termina em rollback, o commit nunca acontece. A trilha usaIDENTITY, e aí há uma exceção: para conhecer o id, o Hibernate executa oINSERTjá nosave. Medido no Hibernate 7.4.5 com o PostgreSQL 18: o segundopersistcom o mesmo nome lançaConstraintViolationExceptionna 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 usasseSEQUENCE. OsaveAndFlushdeixa 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@MockitoBeandiferente ou cadapropertiesdiferente cria outro contexto, e com ele outros contêineres. - Contêineres reutilizáveis durante o desenvolvimento. Com
.withReuse(true)no contêiner etestcontainers.reuse.enable=trueno 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
*Testrodam nomvn testpelo Surefire (unidade e@WebMvcTest, em segundos), e as classes*ITrodam nomvn verifypelo 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
- Spring Boot — Testcontainers:
@ServiceConnection, contêineres como beans e o uso em tempo de desenvolvimento. - Testcontainers — PostgreSQL Module: opções da imagem, estratégias de espera e scripts de inicialização.
- Testcontainers for Java — releases: as notas da versão 2.0, com os pacotes e artefatos renomeados.
- Spring Boot — Upgrade to Testcontainers 2.0.0 (issue #47639): como o Boot 4 adotou o Testcontainers 2.
- Docker Hub — imagem oficial do PostgreSQL: as variáveis
POSTGRES_*, incluindoPOSTGRES_INITDB_ARGS. - SQLite — Foreign Key Support: por que
PRAGMA foreign_keysé por conexão e vem desligado.
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.