Tipos de Teste - Unit, Integration e E2E
A Pirâmide de Testes
Seção intitulada “A Pirâmide de Testes”Cohn propôs a pirâmide para comunicar uma regra de proporção: quanto mais alto na pirâmide, mais lento, caro e frágil o teste, e menos deles você deve ter. Martin Fowler consolidou o conceito e alertou que os nomes importam menos que a ideia de muitos testes rápidos e poucos testes lentos.
graph TD
E2E["E2E<br/>poucos · lentos · caros · frágeis"] --> INT
INT["Integração<br/>alguns · I/O real via Testcontainers"] --> UNIT
UNIT["Unitários<br/>muitos · milissegundos · isolados"]
A lógica: um bug numa regra de negócio deve ser pego por um teste unitário rápido e preciso, que aponta a linha exata. Reservar o E2E — lento e de diagnóstico difícil — para validar que as peças, uma vez individualmente corretas, funcionam juntas no fluxo real.
Unitário — isolado e rápido
Seção intitulada “Unitário — isolado e rápido”Um teste unitário exercita uma unidade de comportamento em isolamento, sem tocar em I/O (banco, rede, disco, relógio real). Roda em milissegundos e falha por um único motivo. Colaboradores externos são substituídos por dublês (Test Doubles - Stub, Spy, Mock e Fake).
A fronteira da unidade é uma decisão de design, não uma regra sintática. Não precisa ser “uma classe”: frequentemente é um agregado de domínio ou um caso de uso e seus objetos de valor. Essa fronteira costuma coincidir com o núcleo de um Bounded Context — a lógica que não depende de infraestrutura. É exatamente o núcleo da Arquitetura Hexagonal: domínio e casos de uso testados sem adapters reais.
class PrecificadorTest {
@Test void aplicaDescontoDeVipAcimaDeCemReais() { var precificador = new Precificador(); var pedido = new Pedido(new BigDecimal("150.00"), TipoCliente.VIP);
BigDecimal total = precificador.precoFinal(pedido);
assertThat(total).isEqualByComparingTo("135.00"); }}Sem Spring, sem banco, sem mock sequer — domínio puro. Esse é o formato ideal quando a lógica está corretamente isolada da infraestrutura.
Integração — com I/O real, via Testcontainers
Seção intitulada “Integração — com I/O real, via Testcontainers”Testes de integração verificam que a sua unidade conversa corretamente com um recurso externo real: o mapeamento JPA de fato persiste, a query nativa retorna o esperado, o serializador produz o JSON certo. Aqui não se usa banco em memória fajuto (H2 dialeta diferente do Postgres) — usa-se o banco de produção em contêiner efêmero com Testcontainers.
O Spring oferece slices que carregam só a fatia relevante do contexto, mais rápidos que @SpringBootTest completo:
@DataJpaTest— camada de persistência (repositórios, entidades, EntityManager).@WebMvcTest— camada web (controllers, serialização, validação) sem subir o serviço inteiro.@SpringBootTest— contexto completo, reservado para o que realmente precisa dele.
@DataJpaTest@Testcontainers@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)class PedidoRepositoryIT {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@Autowired PedidoRepository repository;
@Test void persisteERecuperaPedidoComItens() { var pedido = new Pedido(TipoCliente.VIP); pedido.adicionar(new Item("Café", new BigDecimal("12.50")));
var salvo = repository.save(pedido); var recuperado = repository.findById(salvo.getId()).orElseThrow();
assertThat(recuperado.getItens()).hasSize(1); assertThat(recuperado.total()).isEqualByComparingTo("12.50"); }}Na camada web, @WebMvcTest isola o controller e mocka a camada de serviço:
@WebMvcTest(PedidoController.class)class PedidoControllerIT {
@Autowired MockMvc mockMvc;
@MockitoBean CriarPedido criarPedido;
@Test void retorna201ComLocationAoCriarPedido() throws Exception { when(criarPedido.executar(any())).thenReturn(new PedidoId("42"));
mockMvc.perform(post("/pedidos") .contentType(MediaType.APPLICATION_JSON) .content(""" {"cliente":"VIP","itens":[{"nome":"Café","preco":12.50}]} """)) .andExpect(status().isCreated()) .andExpect(header().string("Location", "/pedidos/42")); }}E2E — fluxo completo
Seção intitulada “E2E — fluxo completo”Testes de ponta a ponta exercitam o sistema inteiro como um usuário ou cliente externo faria: sobem a aplicação real, atravessam controller, serviço, domínio e banco real, e verificam o resultado observável. Dão a maior confiança de integração e a pior relação custo/velocidade — são lentos, frágeis e de diagnóstico difícil quando falham.
Com @SpringBootTest + RestAssured, batendo em HTTP real contra o serviço no ar:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)@Testcontainersclass PedidoE2ETest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }
@LocalServerPort int porta;
@Test void criaPedidoEConsultaTotal() { String id = given() .port(porta) .contentType(ContentType.JSON) .body(""" {"cliente":"VIP","itens":[{"nome":"Café","preco":12.50}]} """) .when() .post("/pedidos") .then() .statusCode(201) .extract().header("Location").replace("/pedidos/", "");
given() .port(porta) .when() .get("/pedidos/{id}", id) .then() .statusCode(200) .body("total", equalTo(12.50f)); }}Quando usar cada um
Seção intitulada “Quando usar cada um”| Camada | Use para | Evite quando |
|---|---|---|
| Unitário | Regras de negócio, cálculos, invariantes de domínio, casos de borda | Precisa validar mapeamento real de banco ou serialização HTTP |
| Integração | Persistência (JPA/SQL), contrato web (MockMvc), integração com mensageria/HTTP externo | Quer cobrir toda combinação de regra de negócio (é caro e lento demais) |
| E2E | Um punhado de fluxos críticos ponta a ponta (happy path do core de receita) | Casos de borda de regra — pertencem ao unitário |
Regra prática: empurre a cobertura para baixo. Cada caso de borda de regra deve ser um teste unitário; use integração para validar as fronteiras de infraestrutura; reserve E2E para poucos fluxos de negócio de alto valor. (No frontend a proporção muda — unidades isoladas de UI dão pouca confiança, e o modelo vira o Testing Trophy; ver Testes e TDD no Frontend.)
O anti-padrão do sorvete invertido (ice cream cone)
Seção intitulada “O anti-padrão do sorvete invertido (ice cream cone)”Quando o time evita testes unitários e concentra a verificação em testes manuais e E2E, a pirâmide vira de cabeça para baixo: uma bola gigante de E2E/manual em cima, quase nada de unitário embaixo.
graph TD
M["Testes manuais<br/>(a bola do sorvete)"] --> E2E2
E2E2["Muitos E2E lentos e frágeis"] --> INT2
INT2["Poucos de integração"] --> UNIT2
UNIT2["Quase nenhum unitário"]
Sintomas: suíte que leva dezenas de minutos, falhas intermitentes, diagnóstico caro (uma falha de E2E não diz onde quebrou), medo de mudar. A causa raiz costuma ser código pouco testável em unidade — o que remete de novo a Testabilidade e Arquitetura.
Anti-padrões e erros comuns
Seção intitulada “Anti-padrões e erros comuns”- Sorvete invertido: peso da suíte no topo; lento, frágil e de diagnóstico difícil.
- H2 no lugar do banco real: “integração” que não integra com o SGBD de produção; use Testcontainers.
@SpringBootTestpara tudo: sobe o contexto inteiro para testar uma regra pura; use slices ou nem Spring.- E2E cobrindo casos de borda: multiplica testes lentos onde um unitário resolveria.
- Testes de integração sem isolamento de dados: um teste vaza estado no banco e derruba outro.
- Confundir camadas: chamar de “unitário” um teste que abre conexão de banco.
Relações
Seção intitulada “Relações”- Testes e TDD
- Fundamentos da Automação de Testes
- Princípio FIRST
- Test Doubles - Stub, Spy, Mock e Fake
- Arquitetura Hexagonal · Ports · Adapters
- Bounded Context · Repositórios
- Testabilidade e Arquitetura · aplicação na arquitetura: Testando o Núcleo
Perguntas de revisão
Seção intitulada “Perguntas de revisão”1. Por que a base da pirâmide deve concentrar a maior parte dos testes?
Resposta
Porque testes unitários são rápidos, estáveis e precisos: falham por um único motivo e apontam a linha exata. Concentrar a cobertura na base dá feedback veloz e diagnóstico barato, reservando as camadas lentas para validar apenas integração real.
2. Qual a relação entre a fronteira da unidade e o núcleo da Arquitetura Hexagonal?
Resposta
A unidade ideal é a lógica de domínio e casos de uso — o núcleo hexagonal, que não depende de infraestrutura. Como o núcleo se comunica com o mundo por portas, ele pode ser testado isoladamente com dublês, sem adapters reais. A fronteira da unidade tende a coincidir com esse núcleo dentro de um bounded context.
3. Por que usar Testcontainers em vez de um banco em memória como H2?
Resposta
Porque H2 tem dialeto e comportamento diferentes do SGBD de produção; um teste que passa nele pode falhar com Postgres. Testcontainers sobe o banco real em contêiner efêmero e limpo, garantindo que o teste de integração exercite o mesmo motor da produção.
4. O que caracteriza o sorvete invertido e qual sua causa raiz típica?
Resposta
É a pirâmide invertida: muitos testes E2E e manuais no topo, quase nenhum unitário na base. Resulta em suíte lenta, frágil e de diagnóstico caro. A causa raiz costuma ser código pouco testável em unidade — acoplamento à infraestrutura que empurra a verificação para o topo.