Testes e qualidade de software
Testes de integração com dependências reais: Testcontainers, WireMock e contratos
Intermediário10 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Um teste de integração se paga quando exercita algo que um teste unitário não alcança: um dialeto SQL real, um serializador real, um broker real. Este guia, independente de framework, mostra como escolher a fronteira sob teste, subir dependências com o Testcontainers, manter o estado limpo entre os testes, simular chamadas HTTP de saída com o WireMock e travar APIs com contratos orientados ao consumidor.
O que você vai aprender
- Identificar com precisão qual fronteira um teste exercita e o que a falha dele significaria
- Subir bancos de dados, brokers e outras dependências reais a partir de um teste com o Testcontainers
- Escolher uma estratégia de reset de estado que mantenha os testes isolados sem deixá-los lentos
- Simular chamadas HTTP de saída com o WireMock, incluindo cenários de falha e de latência
- Verificar a compatibilidade de APIs com testes de contrato orientados ao consumidor em vez de suítes end-to-end compartilhadas
Um teste unitário prova que a sua lógica é coerente consigo mesma. Ele não consegue dizer se o SQL é válido para o banco em que você faz o deploy, se o JSON que o seu cliente envia corresponde ao que o provedor espera, se a migration roda, se a mensagem que você publica pode ser desserializada pelo consumidor, nem se a fronteira de transação que você desenhou realmente faz commit. Essas são perguntas de integração, e cada uma delas causou um incidente em produção em algum lugar nesta mesma semana.
Os testes de integração são também a camada em que as suítes dão errado da forma mais cara: lentas, dependentes da ordem, famintas por ambiente e flaky. A diferença entre uma suíte que se paga e uma que é apagada depois de um ano se resume a poucas decisões: qual fronteira cada teste cobre, de onde vêm as dependências dele e como o estado é resetado. Este guia é independente de framework; para a mecânica específica do Spring, veja o guia de testes com Spring Boot e o guia de testes com Spring Framework.
Dê nome à fronteira primeiro
“Teste de integração” abrange testes cujo escopo vai de duas classes a um deploy inteiro. Antes de escrever um, responda a uma única pergunta: se este teste ficar vermelho, o que eu aprendi? Um teste cuja falha pode significar onze coisas diferentes é um alarme de incêndio sem endereço.
As fronteiras que valem a pena testar separadamente costumam ser estas:
- Persistência. O seu código de repositório contra o banco de dados real, com o schema real gerado pelas migrations reais. Pega SQL inválido, erros de mapeamento, índices faltando em uma constraint de unicidade e migrations que falham em uma tabela com dados.
- Serialização. Um grafo de objetos passando pelo serializador real, ida e volta. Pega formatos de data, tratamento de null, o comportamento diante de propriedades desconhecidas e o campo que alguém renomeou.
- HTTP de saída. O seu cliente contra um stub que você controla. Pega a construção de URLs, os headers, a autenticação, o comportamento de timeouts e retries e o mapeamento de erros.
- Mensageria. Publicar e consumir através de um broker real. Pega a serialização, os headers, o particionamento, a semântica de acknowledgement e a reentrega.
- A aplicação montada. O serviço iniciado do jeito que vai para produção, exercitado pela sua API pública. Pega problemas de wiring, de configuração e de aspectos transversais.
Cada uma examina algo diferente, e a falha de cada uma significa algo diferente. Juntá-las em um único “teste de integração” que sobe tudo e clica em um navegador dá a você a soma dos custos de todas e nada do valor de diagnóstico de nenhuma.
Contrato ou integração?
Existe mais uma divisão que poupa muita dor em um sistema com vários serviços. O teste de integração pergunta se dois componentes funcionam juntos agora, em um mesmo processo ou ambiente. O teste de contrato pergunta se o acordo entre eles continua valendo, e faz isso sem que os dois componentes jamais rodem ao mesmo tempo.
A distinção importa porque testes de integração entre serviços não escalam. Um ambiente compartilhado em que cada time faz deploy antes de rodar os testes end-to-end vira uma fila, depois um gargalo e, por fim, uma fonte de falhas que ninguém assume. O teste de contrato substitui a maior parte disso por duas suítes executáveis de forma independente e um arquivo que circula entre elas.
Dependências reais com Testcontainers
O Testcontainers sobe contêineres Docker a partir do JUnit e os derruba ao final, o que elimina a última desculpa para testar contra substitutos.
@Testcontainers
class OrderRepositoryIT {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("shop")
.withReuse(true);
static DataSource dataSource;
@BeforeAll
static void migrate() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(POSTGRES.getJdbcUrl());
config.setUsername(POSTGRES.getUsername());
config.setPassword(POSTGRES.getPassword());
dataSource = new HikariDataSource(config);
Flyway.configure().dataSource(dataSource).load().migrate();
}
@Test
void findsOrdersPlacedInAWindowInPlacedOrder() {
OrderRepository repository = new JdbcOrderRepository(dataSource);
repository.save(anOrder().placedAt("2026-01-05T10:00:00Z").build());
repository.save(anOrder().placedAt("2026-01-06T10:00:00Z").build());
repository.save(anOrder().placedAt("2026-02-01T10:00:00Z").build());
List<Order> found = repository.placedBetween(
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:59Z"));
assertThat(found).hasSize(2)
.extracting(Order::placedAt)
.isSorted();
}
}
Três detalhes fazem a maior parte do trabalho. Declarar o contêiner como static faz com que ele suba uma vez para a classe inteira, e não uma vez por método de teste. Rodar as migrations reais significa que o teste também as valida. E fixar uma tag de imagem exata — postgres:17-alpine, e não postgres:latest — mantém a suíte reproduzível quando alguém reconstruir a imagem no mês que vem.
Para uma suíte inteira, um contêiner singleton iniciado uma vez em um inicializador estático e nunca parado é ainda mais rápido: a JVM termina e o reaper do Testcontainers remove o contêiner. withReuse(true), combinado com a flag de reúso do arquivo local testcontainers.properties, mantém um contêiner vivo entre execuções locais, cortando o custo de subida durante o desenvolvimento enquanto o CI continua começando do zero.
Além de bancos de dados, há módulos para Kafka, RabbitMQ, Redis, Elasticsearch e LocalStack, e um contêiner genérico que roda qualquer imagem com a estratégia de espera que você escolher:
@Container
static final GenericContainer<?> LEGACY_PRICING =
new GenericContainer<>("registry.example.com/pricing:3.4")
.withExposedPorts(8080)
.withEnv("PRICING_MODE", "test")
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
Estratégias de espera importam mais do que parece. Um contêiner com a porta aberta, mas cuja aplicação ainda não terminou de subir, produz exatamente o tipo de falha intermitente que corrói a confiança em uma suíte.
Impedindo que os testes contaminem uns aos outros
Assim que os testes compartilham um banco de dados real, o isolamento deixa de ser de graça. Três estratégias, com seus trade-offs honestos:
Rollback transacional. Envolva cada teste em uma transação e faça rollback no final. É a opção mais rápida e dispensa código de limpeza, mas esconde tudo o que acontece no commit — constraints diferidas, hooks de commit, triggers — e não funciona quando o código sob teste abre a própria transação ou quando a requisição cruza uma fronteira de processo.
Estado limpo entre os testes. Limpe as tabelas afetadas (ou restaure um banco modelo) antes de cada teste. É mais lento, mas o teste enxerga exatamente o que a produção enxergaria. Um único TRUNCATE ... RESTART IDENTITY CASCADE sobre o punhado de tabelas envolvidas costuma ser rápido o suficiente.
Dados únicos por teste. Dê a cada teste o seu próprio tenant, cliente ou prefixo de chave, para que colisões sejam impossíveis e não haja nada a limpar. É a única estratégia que sobrevive a uma execução paralela agressiva, e ela ainda obriga o código a ser seguro entre tenants, o que muitas vezes é um requisito real.
Seja qual for a escolha, aplique-a de maneira uniforme. Suítes em que algumas classes fazem rollback e outras limpam tabelas falham de formas que dependem da ordem de execução, e diagnosticar isso custa mais do que o tempo que qualquer uma das estratégias economizava.
Dados de teste legíveis
Testes de integração precisam de mais dados de preparação do que testes unitários, e é nessa preparação que eles ficam ilegíveis. Os dois padrões que funcionam:
// Builder with meaningful defaults: the test states only what it cares about.
Order order = anOrder()
.forCustomer(aCustomer().premium().build())
.withLine("SKU-1", 2, "24.00")
.placedAt("2026-01-05T10:00:00Z")
.build();
Um test data builder fornece valores padrão válidos para cada campo e deixa cada teste sobrescrever os dois ou três que importam para ele. Quando uma coluna obrigatória é adicionada, muda um builder em vez de sessenta testes. Um object mother vai além e dá nome a cenários inteiros — Orders.shippedInternationalOrder() —, o que combina com casos que se repetem em muitos testes.
Evite a alternativa que sempre aparece primeiro: um arquivo de fixtures SQL compartilhado, carregado antes da suíte, com cem linhas das quais trinta testes dependem de formas não documentadas. Ninguém consegue apagar uma linha dele, então ele só cresce, e ler um teste não diz nada sobre por que ele passa.
Simulando o HTTP de saída
Quando o sistema sob teste chama a API de outra pessoa, essa dependência precisa ficar sob o seu controle. O WireMock roda um servidor HTTP que você programa:
class PricingClientTest {
@RegisterExtension
static WireMockExtension wiremock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
private PricingClient client;
@BeforeEach
void setUp() {
client = new PricingClient(wiremock.baseUrl(), Duration.ofMillis(500));
}
@Test
void readsThePriceFromTheResponseBody() {
wiremock.stubFor(get(urlPathEqualTo("/v1/prices/SKU-1"))
.withHeader("Accept", equalTo("application/json"))
.willReturn(okJson("""
{"sku":"SKU-1","amount":"24.00","currency":"EUR"}
""")));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
wiremock.verify(getRequestedFor(urlPathEqualTo("/v1/prices/SKU-1"))
.withHeader("X-Request-Id", matching("[0-9a-f-]{36}")));
}
@Test
void failsFastWhenTheProviderIsSlow() {
wiremock.stubFor(get(anyUrl()).willReturn(ok().withFixedDelay(2_000)));
assertThatThrownBy(() -> client.priceOf("SKU-1"))
.isInstanceOf(PricingUnavailableException.class);
}
@Test
void retriesOnceOnA503() {
wiremock.stubFor(get(anyUrl()).inScenario("flaky")
.whenScenarioStateIs(STARTED)
.willReturn(serviceUnavailable())
.willSetStateTo("recovered"));
wiremock.stubFor(get(anyUrl()).inScenario("flaky")
.whenScenarioStateIs("recovered")
.willReturn(okJson("""
{"sku":"SKU-1","amount":"24.00","currency":"EUR"}
""")));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
wiremock.verify(2, getRequestedFor(anyUrl()));
}
}
O ponto central são os cenários de falha. Qualquer cliente funciona contra um stub que só devolve o caminho feliz; o que quebra em produção é o timeout que nunca foi configurado, o retry que martelou um provedor que já estava sofrendo, o 500 que apareceu como um NullPointerException. Os atrasos, a injeção de falhas e a máquina de estados de cenários do WireMock tornam esses caminhos testáveis. E inspecionar o corpo de uma requisição capturada fica bem mais fácil com uma visualização formatada: o formatador de JSON é útil quando um relatório de divergência do stub vem em uma única linha enorme.
Mensageria e assincronia
Os testes de broker seguem o mesmo padrão, com uma dificuldade a mais: o resultado chega depois. Suba o broker em um contêiner, publique pelo seu producer real e consulte o resultado com uma espera limitada em vez de usar sleep.
@Test
void publishesAnOrderPlacedEventConsumersCanRead() {
orderService.place(anOrder().withLine("SKU-1", 1, "24.00").build());
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> {
List<OrderPlaced> received = testConsumer.drain();
assertThat(received).hasSize(1);
assertThat(received.getFirst().sku()).isEqualTo("SKU-1");
});
}
Um Thread.sleep(3000) no mesmo lugar seria ao mesmo tempo mais lento em uma máquina rápida e flaky em um runner de CI sobrecarregado. Teste também os caminhos de falha que só existem em mensageria: uma poison message que não pode ser desserializada, uma entrega duplicada que precisa ser idempotente e um consumidor que falha no meio do processamento e não pode fazer o acknowledgement.
Contratos orientados ao consumidor
Para a compatibilidade entre serviços, o teste de contrato substitui o ambiente end-to-end compartilhado. O consumidor declara o que precisa, essa declaração vira um arquivo e o provedor a verifica.
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "pricing-service", pactVersion = PactSpecVersion.V4)
class PricingContractTest {
@Pact(consumer = "checkout-service")
RequestResponsePact priceExists(PactDslWithProvider builder) {
return builder
.given("a price exists for SKU-1")
.uponReceiving("a request for the price of SKU-1")
.path("/v1/prices/SKU-1")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("sku", "SKU-1")
.decimalType("amount", 24.00)
.stringMatcher("currency", "[A-Z]{3}", "EUR"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "priceExists")
void theClientReadsThePrice(MockServer mockServer) {
PricingClient client = new PricingClient(mockServer.getUrl(), Duration.ofSeconds(1));
assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
}
}
Duas propriedades fazem isso funcionar. O consumidor especifica matchers, e não valores literais — ele precisa de um código de moeda de três letras, não especificamente de EUR —, então o provedor fica livre para mudar os dados sem quebrar o contrato. E a cláusula given nomeia um provider state que o teste de verificação do provedor prepara antes de reexecutar a requisição. Provedores que adicionam campos continuam compatíveis; provedores que removem ou renomeiam um campo lido pelo consumidor falham na verificação antes do deploy, que é exatamente quando você quer saber. As convenções de design que tornam uma API fácil de testar por contrato são as mesmas das boas práticas gerais de design de APIs: nomes de campos estáveis, mudanças aditivas e versionamento explícito.
Mantendo o custo sob controle
Suítes de integração se degradam minuto a minuto. O que realmente ajuda:
- Suba os contêineres uma única vez. Contêineres estáticos por classe, ou um singleton para a suíte inteira. Contêineres por método são, de longe, a causa mais comum de um build de vinte minutos.
- Limite as configurações distintas. Cada combinação única de propriedades, profiles e beans sobrescritos cria um application context separado, que precisa ser construído do zero.
- Rode em paralelo. Ative a execução paralela do JUnit e deixe os testes independentes o bastante para sobreviver a ela; o isolamento por dados únicos e a execução paralela se reforçam mutuamente.
- Divida a suíte. Testes unitários rápidos a cada commit, testes de integração no merge e as jornadas end-to-end mais lentas durante a noite. Convenções de nome (
*Testversus*IT) permitem que a ferramenta de build os separe sem anotações. - Use imagens pequenas. Variantes Alpine e imagens pré-aquecidas cortam segundos de cada subida.
- Apague os testes que já não compensam o tempo de execução. Um teste que nunca falhou por um motivo real em dois anos e que duplica o que outros três já cobrem é um passivo.
Quando os testes de integração ficam flaky
Um teste que falha de forma intermitente destrói mais valor do que gera, porque o time aprende a rodar de novo em vez de investigar. Em suítes de integração, as causas são previsíveis: estado compartilhado deixado por um teste anterior, uma porta fixa que colide na execução paralela, uma espera que presumia um timing, uma dependência da data de hoje, uma suposição sobre a ordem das linhas retornadas por uma query sem ORDER BY, ou um contêiner que aceitava conexões antes de estar pronto.
Trate cada um como um defeito do teste, não como ruído de fundo. Reproduza o problema rodando a suíte repetidamente, em ordem aleatória, em uma máquina sobrecarregada: as condições em que ele falha são informação sobre a causa. A política que impede que isso saia do controle está descrita no guia de estratégia de automação de testes: quarentena, ticket, correção dentro de um prazo fixo ou exclusão.
Principais conclusões
- Decida qual fronteira cada teste cobre antes de escrevê-lo; um teste cuja falha pode significar qualquer coisa não diagnostica nada.
- Teste a persistência contra o banco real com as migrations reais: o Testcontainers eliminou o motivo para substituí-lo por um banco em memória.
- Escolha uma única estratégia de isolamento — rollback, limpeza de tabelas ou dados únicos — e aplique-a em todo lugar; a de dados únicos é a que sobrevive à execução paralela.
- Monte os dados de preparação com builders e object mothers, não com um arquivo de fixtures compartilhado que só cresce.
- Simule o HTTP de saída com o WireMock e dedique a maior parte do esforço a timeouts, retries e respostas de erro, e não ao caminho feliz.
- Substitua as suítes end-to-end entre serviços por contratos orientados ao consumidor, que verificam a compatibilidade sem que os dois serviços rodem ao mesmo tempo.
Fontes e leituras complementares
Perguntas frequentes
- Por que não usar o H2 em vez de um contêiner nos testes de banco de dados?
- Porque um banco em memória é outro produto. Mesmo em modo de compatibilidade, o H2 difere do PostgreSQL ou do Oracle no tratamento de tipos, no suporte a JSON, na cobertura de funções de janela, no comportamento de locks, na alocação de sequences, na sintaxe de upsert e nos códigos de erro. Uma suíte que passa no H2 e falha no banco real é pior do que não ter suíte, porque gerou uma confiança que não foi conquistada. Os contêineres tornaram essa substituição desnecessária.
- Como evito que os testes de integração interfiram uns nos outros?
- Escolha uma estratégia e aplique-a de forma consistente. O rollback transacional é o mais rápido, mas esconde o comportamento no commit e não funciona além de uma fronteira de processo. Limpar as tabelas entre os testes é mais lento, porém honesto. Gerar chaves únicas por teste, para que os dados simplesmente não colidam, costuma reunir o melhor dos dois mundos e funciona com execução paralela. O que nunca funciona é presumir que o teste anterior limpou a própria bagunça.
- O que um teste de contrato orientado ao consumidor verifica de fato?
- Duas metades. No lado do consumidor, o teste executa o seu cliente contra um mock server construído a partir das expectativas que você declarou e gera um arquivo de contrato que registra as requisições que você faz e os campos da resposta dos quais você depende. No lado do provedor, um teste separado reexecuta essas requisições contra o provedor real e verifica se as respostas ainda as satisfazem. Juntas, as duas metades provam a compatibilidade sem nunca implantar os dois serviços no mesmo ambiente.
- Testes de integração precisam ser lentos?
- Mais lentos que os testes unitários, sim; lentos em termos absolutos, não. Os custos dominantes são a subida dos contêineres e a criação do contexto, e ambos têm solução: reutilize um contêiner estático na suíte inteira, mantenha pequeno o número de configurações distintas, execute as classes em paralelo e use as menores imagens que atendam. Uma suíte bem ajustada, com algumas centenas de testes contra banco de dados terminando em dois ou três minutos, é uma meta realista.