Pular para o conteúdo

Tipos de Teste - Unit, Integration e E2E

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.

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.

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"));
}
}

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)
@Testcontainers
class 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));
}
}
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.

  • 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.
  • @SpringBootTest para 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.

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.