Maratona de Testes em Java: Da Teoria à Prática
Step 1: Testes Unitários - A Base Sólida da Pirâmide de Testes
Índice
- Por que testes unitários são fundamentais?
- O que realmente define um teste unitário?
- O que exatamente devemos testar?
- Estruturando seus testes com clareza
- As ferramentas do ofício: JUnit e Mockito
- Exemplo completo: Testando um Controller REST
- Solitary vs. Sociable: Duas abordagens
- DRY vs. DAMP nos testes
- Evitando armadilhas comuns
- Práticas avançadas e técnicas eficazes
- Integração Contínua
- Lições finais
- Conclusão
- Recursos recomendados
- Referências Bibliográficas
Por que testes unitários são fundamentais?
Testes unitários são como os alicerces de um edifício: invisíveis para o usuário final, mas absolutamente essenciais para a integridade e sustentação de todo o sistema. Eles são a base da "Pirâmide de Testes", um conceito desenvolvido por Mike Cohn que organiza os testes em camadas de diferentes granularidades, indicando também quantos testes devemos ter em cada uma dessas camadas.
No artigo "The Practical Test Pyramid", Ham Vocke destaca que "seus testes unitários serão muito rápidos. Em uma máquina razoável, você poderá rodar milhares deles em poucos minutos." Esta velocidade é crucial para o desenvolvimento ágil, permitindo que você obtenha feedback praticamente instantâneo sobre suas alterações de código.
O que realmente define um teste unitário?
Um teste unitário eficaz possui estas características essenciais:
Característica | Descrição | Por quê isso importa |
---|---|---|
⚡ Rápido | Executa em milissegundos | Permite feedback imediato durante o desenvolvimento |
🧩 Isolado | Sem dependência externa (rede, banco, disco) | Evita falhas por causas externas à unidade testada |
🔄 Determinístico | Sempre produz o mesmo resultado | Elimina testes "flaky" (instáveis) |
🎯 Focado | Verifica uma única lógica ou comportamento | Facilita identificar exatamente o que falhou |
📖 Legível | Clara separação entre cenário e verificação | Permite manutenção e entendimento a longo prazo |
Como Ham Vocke menciona, "teste pequenas partes da sua base de código em isolamento e evite acessar bancos de dados, o sistema de arquivos ou fazer requisições HTTP." Este isolamento é o que torna os testes unitários verdadeiramente "unitários".
O que exatamente devemos testar?
Segundo Vocke, "você pode escrever testes unitários para todas as classes do seu código de produção, independentemente da funcionalidade delas." Isso significa que quase tudo pode e deve ser testado unitariamente:
- ✅ Classes de domínio com regras de negócio
- ✅ Controllers (testar mapeamentos e respostas)
- ✅ Services (testar fluxos e orquestrações)
- ✅ Repositórios com lógica própria
- ✅ Utils, validators, formatters (funções de propósito específico)
O importante é focar no comportamento observável da unidade, não em sua implementação interna. Como Vocke recomenda, devemos "testar para o comportamento observável em vez de refletir a estrutura interna do código em nossos testes."
Estruturando seus testes com clareza
Ham Vocke destaca duas formas eficazes de estruturar testes, que ele chama de "uma boa estrutura para todos os seus testes":
📐 1. Arrange → Act → Assert
@Test
public void deveCalcularValorTotalComDesconto() {
// Arrange - Prepara o cenário
Produto produto = new Produto("Notebook", 2000.0);
Cliente clienteVip = new Cliente("João", TipoCliente.VIP);
// Act - Executa a ação
double valorFinal = calculadoraPreco.calcular(produto, clienteVip);
// Assert - Verifica o resultado
assertEquals(1800.0, valorFinal, 0.001);
}
💬 2. Given → When → Then (estilo BDD)
@Test
public void clienteVipDeveReceberDescontoEspecial() {
// Given - Dado um cliente VIP e um produto
Cliente clienteVip = new Cliente("Maria", TipoCliente.VIP);
Produto produto = new Produto("Smartphone", 1000.0);
// When - Quando calcular o preço final
double valorFinal = calculadoraPreco.calcular(produto, clienteVip);
// Then - Então deve aplicar 10% de desconto
assertEquals(900.0, valorFinal, 0.001);
}
Vocke sugere que este padrão "pode ser aplicado a outros testes de nível mais alto também. Em todos os casos, eles garantem que seus testes permaneçam fáceis e consistentes de ler."
As ferramentas do ofício: JUnit e Mockito
JUnit 5
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
class CalculadoraTest {
private Calculadora calc;
@BeforeEach
void setup() {
calc = new Calculadora();
}
@Test
@DisplayName("Soma de dois números positivos")
void deveSomarDoisNumerosPositivos() {
// Act
int resultado = calc.somar(5, 3);
// Assert
assertEquals(8, resultado);
}
@Test
@DisplayName("Divisão por zero deve lançar exceção")
void deveLancarExcecaoAoDividirPorZero() {
// Assert + Act (verificando exceção)
assertThrows(ArithmeticException.class, () -> {
calc.dividir(10, 0);
});
}
}
Mockito
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class PedidoServiceTest {
@Mock
DescontoService descontoService;
@Mock
EmailService emailService;
@InjectMocks
PedidoService pedidoService;
@Test
void deveAplicarDescontoAoFinalizarPedido() {
// Arrange
Pedido pedido = new Pedido(1L, 100.0);
when(descontoService.calcularDesconto(pedido)).thenReturn(10.0);
// Act
pedidoService.finalizarPedido(pedido);
// Assert
assertEquals(90.0, pedido.getValorTotal());
verify(emailService).enviarConfirmacao(pedido);
}
}
Exemplo completo: Testando um Controller REST
Vamos ver um exemplo prático de como testar um controller REST em Spring Boot:
O Controller em produção
@RestController
@RequestMapping("/api/produtos")
public class ProdutoController {
private final ProdutoService produtoService;
public ProdutoController(ProdutoService produtoService) {
this.produtoService = produtoService;
}
@GetMapping("/{id}")
public ResponseEntity<ProdutoDTO> buscarPorId(@PathVariable Long id) {
return produtoService.buscarPorId(id)
.map(produto -> ResponseEntity.ok(new ProdutoDTO(produto)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ProdutoDTO> criar(@RequestBody @Valid ProdutoRequest request) {
Produto produto = produtoService.criar(
new Produto(request.getNome(), request.getPreco())
);
return ResponseEntity
.created(URI.create("/api/produtos/" + produto.getId()))
.body(new ProdutoDTO(produto));
}
}
Teste unitário com MockMVC
Ham Vocke explica que ferramentas como MockMVC são importantes porque "Spring MVC's controller faz uso intensivo de anotações para declarar em quais caminhos eles estão escutando, quais verbos HTTP usar, quais parâmetros eles analisam do caminho da URL ou parâmetros de consulta e assim por diante. Simplesmente invocar um método do controller em seus testes unitários não testará todas essas coisas cruciais."
@ExtendWith(MockitoExtension.class)
class ProdutoControllerTest {
private MockMvc mockMvc;
@Mock
private ProdutoService produtoService;
@InjectMocks
private ProdutoController produtoController;
@BeforeEach
void setup() {
mockMvc = MockMvcBuilders
.standaloneSetup(produtoController)
.build();
}
@Test
void deveRetornarProdutoQuandoExistir() throws Exception {
// Arrange
Produto produto = new Produto("Teclado Mecânico", 350.0);
produto.setId(1L);
when(produtoService.buscarPorId(1L))
.thenReturn(Optional.of(produto));
// Act & Assert
mockMvc.perform(get("/api/produtos/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nome").value("Teclado Mecânico"))
.andExpect(jsonPath("$.preco").value(350.0));
}
@Test
void deveRetornarNotFoundQuandoProdutoNaoExistir() throws Exception {
// Arrange
when(produtoService.buscarPorId(99L))
.thenReturn(Optional.empty());
// Act & Assert
mockMvc.perform(get("/api/produtos/99"))
.andExpect(status().isNotFound());
}
@Test
void deveCriarNovoProduto() throws Exception {
// Arrange
Produto produtoParaSalvar = new Produto("Mouse", 89.9);
Produto produtoSalvo = new Produto("Mouse", 89.9);
produtoSalvo.setId(42L);
when(produtoService.criar(any(Produto.class)))
.thenReturn(produtoSalvo);
// Act & Assert
mockMvc.perform(post("/api/produtos")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nome\":\"Mouse\",\"preco\":89.9}")
)
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/produtos/42"))
.andExpect(jsonPath("$.nome").value("Mouse"))
.andExpect(jsonPath("$.preco").value(89.9));
}
}
Solitary vs. Sociable: Duas abordagens para testes unitários
Segundo Vocke, "alguns argumentam que todos os colaboradores da sua unidade sob teste devem ser substituídos por mocks ou stubs", enquanto "outros argumentam que apenas colaboradores que são lentos ou têm efeitos colaterais maiores deveriam ser substituídos por stubs ou mocks."
Este conceito foi criado por Jay Fields em seu livro "Working Effectively with Unit Tests", que cunhou os termos "testes unitários solitários" para testes que substituem todos os colaboradores, e "testes unitários sociáveis" para testes que permitem interações com colaboradores reais.
Ham Vocke confessa: "Eu me vejo usando ambas as abordagens o tempo todo. Se se torna estranho usar colaboradores reais, usarei mocks e stubs generosamente. Se eu sentir que envolver o colaborador real me dá mais confiança em um teste, apenas faço stub das partes mais externas do meu serviço."
Exemplo de teste solitário (com mocks)
@Test
void deveCalcularImpostoSobreVendaComMocks() {
// Arrange
BigDecimal valorBase = new BigDecimal("1000.00");
// Mockando todas as dependências
when(aliquotaService.buscarAliquota("SP"))
.thenReturn(new BigDecimal("0.18"));
when(descontoService.calcularDesconto(valorBase))
.thenReturn(new BigDecimal("100.00"));
// Act
BigDecimal imposto = calculadoraImpostoService.calcular(valorBase, "SP");
// Assert
assertEquals(new BigDecimal("162.00"), imposto); // (1000 - 100) * 0.18
}
Exemplo de teste sociável (com colaboradores reais)
@Test
void deveCalcularImpostoSobreVendaComColaboradoresReais() {
// Arrange
BigDecimal valorBase = new BigDecimal("1000.00");
// Usando implementação real do serviço de desconto
DescontoService descontoServiceReal = new DescontoServiceImpl();
// Ainda mockando o serviço de alíquota (externo)
AliquotaService aliquotaMock = mock(AliquotaService.class);
when(aliquotaMock.buscarAliquota("SP"))
.thenReturn(new BigDecimal("0.18"));
// Injetando uma mistura de dependências reais e mockadas
CalculadoraImpostoService calculadora =
new CalculadoraImpostoServiceImpl(descontoServiceReal, aliquotaMock);
// Act
BigDecimal imposto = calculadora.calcular(valorBase, "SP");
// Assert
assertEquals(new BigDecimal("180.00"), imposto); // Considerando que o desconto real é 0
}
DRY vs. DAMP nos testes: Equilibrando clareza e repetição
Ham Vocke alerta: "Não tente ser excessivamente DRY (Don't Repeat Yourself). Duplicação é aceitável se melhorar a legibilidade."
Em testes, muitas vezes é melhor priorizar a clareza em vez da não-repetição. Um acrônimo útil para recordar é DAMP: Descriptive And Meaningful Phrases.
Exemplo questionável (muito DRY):
// Definição central, reusada em todos os testes
private Cliente clienteVip;
private Produto produtoPadrao;
@BeforeEach
void setup() {
clienteVip = new Cliente("João", TipoCliente.VIP);
produtoPadrao = new Produto("Item", 100.0);
}
@Test
void deveAplicarDescontoVipEmProduto() {
assertEquals(90.0, calculadoraPreco.calcular(produtoPadrao, clienteVip));
}
@Test
void deveIncluirFreteGratisPara() {
assertTrue(beneficioService.temFreteGratis(clienteVip, produtoPadrao));
}
Exemplo melhor (mais DAMP):
@Test
void deveAplicarDescontoVipEmProduto() {
// Arrange - Dados específicos para este teste
Cliente clienteVip = new Cliente("João", TipoCliente.VIP);
Produto notebook = new Produto("Notebook", 2000.0);
// Act
double valorComDesconto = calculadoraPreco.calcular(notebook, clienteVip);
// Assert - Expectativa clara
assertEquals(1800.0, valorComDesconto,
"Cliente VIP deve receber 10% de desconto");
}
@Test
void deveIncluirFreteGratisPara() {
// Arrange - Dados específicos para este teste
Cliente clienteVip = new Cliente("Maria", TipoCliente.VIP);
Produto smartTV = new Produto("Smart TV", 1500.0);
// Act
boolean temFreteGratis = beneficioService.temFreteGratis(clienteVip, smartTV);
// Assert - Expectativa clara
assertTrue(temFreteGratis,
"Cliente VIP deve ter frete grátis independente do produto");
}
Evitando armadilhas comuns
"Mas eu preciso testar este método privado!"
Ham Vocke alerta: "Se você já se encontrou em uma situação em que realmente precisa testar um método privado, você deve dar um passo atrás e se perguntar por quê." Ele continua: "Estou bastante certo de que isso é mais um problema de design do que um problema de escopo."
Quando você sente essa necessidade, geralmente há um problema de design da classe — ela provavelmente está fazendo muitas coisas e violando o princípio da responsabilidade única.
A solução mais elegante:
// Antes: Classe grande com método privado complexo
public class FaturamentoService {
public BigDecimal calcular(Pedido pedido) {
// lógica...
return calcularImpostos(valorBase); // método privado complexo
}
private BigDecimal calcularImpostos(BigDecimal valor) {
// lógica complexa de cálculo de impostos
}
}
// Depois: Extrair para uma classe dedicada com interface pública
public class FaturamentoService {
private final ImpostoCalculator impostoCalculator;
public FaturamentoService(ImpostoCalculator impostoCalculator) {
this.impostoCalculator = impostoCalculator;
}
public BigDecimal calcular(Pedido pedido) {
// lógica...
return impostoCalculator.calcular(valorBase); // Agora é público!
}
}
public class ImpostoCalculator {
public BigDecimal calcular(BigDecimal valor) {
// mesma lógica complexa, agora testável
}
}
"Preciso ter 100% de cobertura!"
Vocke argumenta contra testar código trivial: "Sim, você deve testar a interface pública. Mais importante, no entanto, você não testa código trivial. Não se preocupe, Kent Beck disse que tudo bem. Você não ganhará nada testando getters ou setters simples ou outras implementações triviais."
Focar em cobertura por si só pode levar a testes ruins que não adicionam valor. Busque qualidade em vez de quantidade.
Práticas avançadas e técnicas eficazes
À medida que você ganha experiência em testes unitários, algumas técnicas avançadas podem ajudar a tornar seus testes mais elegantes, legíveis e fáceis de manter. Vejamos três técnicas importantes que podem elevar significativamente a qualidade da sua suite de testes.
1. Dados de teste expressivos
O que é: Métodos de fábrica (factory methods) ou objetos builders que criam dados de teste com nomes semânticos, em vez de usar configurações inline de objetos diretamente nos testes.
Quando usar:
- Quando os objetos de teste têm muitas propriedades que precisam ser configuradas
- Quando você repete a mesma configuração de objetos em vários testes
- Quando a configuração obscurece a intenção principal do teste
Benefícios:
- Foco no "o quê" está sendo testado, não no "como" os objetos são criados
- Leitura mais natural - o nome do método descreve o cenário
- Encapsulamento de detalhes irrelevantes para a intenção do teste
- Fácil reutilização entre testes similares
// Ruim: Dados aleatórios desconexos
@Test
void deveAprovarCreditoParaClienteBomPagador() {
Cliente cliente = new Cliente("X123", 35);
cliente.setScoreCredito(750);
cliente.setRendaMensal(5000.0);
boolean aprovado = creditoService.analisarCredito(cliente, 10000.0);
assertTrue(aprovado);
}
// Melhor: Nome do método de fábrica expressivo
@Test
void deveAprovarCreditoParaClienteBomPagador() {
Cliente clienteBomPagador = criarClienteComBomHistoricoCredito();
boolean aprovado = creditoService.analisarCredito(clienteBomPagador, 10000.0);
assertTrue(aprovado);
}
private Cliente criarClienteComBomHistoricoCredito() {
Cliente cliente = new Cliente("Maria", 35);
cliente.setScoreCredito(750);
cliente.setRendaMensal(5000.0);
return cliente;
}
Você também pode evoluir para padrões mais sofisticados como builders fluentes:
// Usando um builder expressivo
@Test
void deveAprovarCreditoParaClienteBomPagador() {
Cliente clienteBomPagador = ClienteBuilder.padrao()
.comBomHistoricoCredito()
.comRendaAdequada()
.build();
boolean aprovado = creditoService.analisarCredito(clienteBomPagador, 10000.0);
assertTrue(aprovado);
}
2. Assertion libraries expressivas
O que são: Bibliotecas que oferecem uma sintaxe mais fluente e legível para verificações, substituindo os métodos padrão do JUnit.
Quando usar:
- Para verificações complexas ou múltiplas em um único objeto
- Quando se deseja uma leitura mais próxima da linguagem natural
- Para verificações de coleções, onde se quer verificar múltiplos aspectos
Bibliotecas populares:
- AssertJ: fluente e orientada a objetos
- Hamcrest: baseada em matchers
- Truth (Google): alternativa simples e expressiva
Benefícios:
- Testes mais legíveis, quase como frases em inglês
- Mensagens de erro mais descritivas quando falham
- Encadeamento de verificações reduz duplicação
- Maior expressividade para casos complexos
// JUnit padrão - Funcionais mas menos expressivas
assertEquals(3, lista.size());
assertTrue(lista.contains("item1"));
assertTrue(cliente.isAtivo());
// AssertJ - Mais fluente e expressivo
assertThat(lista)
.hasSize(3)
.contains("item1")
.doesNotContain("item99");
assertThat(cliente.isAtivo()).isTrue();
Quando um teste falha, a mensagem de erro com AssertJ é mais informativa:
Expected size:<3> but was:<2>
Expected to contain:<"item1"> but did not
3. Testes parametrizados para casos similares
O que são: Testes que executam o mesmo código com diferentes entradas e expectativas de saída, gerando automaticamente múltiplos casos de teste a partir de uma única definição.
Quando usar:
- Para testar múltiplas variações de um mesmo caso (comportamento com diferentes entradas)
- Para regras de negócio que têm muitos casos específicos (ex: cálculos tributários)
- Quando a lógica é a mesma, mas os dados variam
- Para evitar duplicação de código em testes semelhantes
Benefícios:
- Redução drástica de código duplicado
- Facilidade para adicionar novos casos de teste
- Documentação clara de todos os casos suportados
- Facilita identificar quais casos específicos falharam
@ParameterizedTest
@CsvSource({
"SP, 0.18",
"RJ, 0.20",
"MG, 0.17",
"RS, 0.17"
})
void deveCalcularImpostoCorretoPorEstado(String estado, BigDecimal aliquotaEsperada) {
// Arrange
Produto produto = new Produto("Genérico", 100.0);
// Act
BigDecimal aliquota = impostoService.obterAliquota(estado, produto);
// Assert
assertEquals(aliquotaEsperada, aliquota);
}
O JUnit 5 suporta múltiplas fontes de dados para testes parametrizados:
-
@ValueSource
: para listas simples de valores (@ValueSource(ints = {1, 2, 3})
) -
@CsvSource
: para pares ou tuplas de valores como mostrado acima -
@EnumSource
: para testar com valores de um enum -
@MethodSource
: para casos complexos onde os dados são gerados por um método -
@ArgumentsSource
: para implementações personalizadas de geradores de dados
Essas técnicas avançadas são particularmente úteis quando sua base de código cresce e você precisa manter um conjunto grande de testes. Elas ajudam a reduzir a duplicação, melhorar a legibilidade e facilitar a manutenção a longo prazo.
Integração Contínua: Executando testes unitários automaticamente
Os testes unitários devem ser executados:
- Localmente antes de commit/push
- Na CI em cada push para qualquer branch
- Antes de merge para branches principais
Este feedback rápido é possível graças à velocidade dos testes unitários.
Lições finais
Ham Vocke resume bem: "Uma vez que você pegar o jeito de escrever testes unitários, você se tornará cada vez mais fluente em escrevê-los. Substitua colaboradores externos por stubs, configure alguns dados de entrada, chame seu assunto sob teste e verifique se o valor retornado é o que você esperava."
Testes unitários são um investimento que:
- ⏱️ Economiza tempo a longo prazo
- 🔒 Permite refatorações com segurança
- 📚 Documenta o comportamento esperado
- 💻 Melhora o design do código
- 🧠 Reduz a carga cognitiva ao desenvolver
Conclusão
Como resumido no artigo de referência: "O código de teste é tão importante quanto o código de produção. Dê a ele o mesmo nível de cuidado e atenção. 'Isso é apenas código de teste' não é uma desculpa válida para justificar código desleixado."
Ao dominar a arte dos testes unitários, você estabelece a base sólida para construir um sistema robusto e confiável, que pode evoluir com segurança ao longo do tempo.
No próximo passo da nossa maratona, abordaremos testes de integração - a camada intermediária da pirâmide de testes que verifica como seus componentes trabalham juntos.
Recursos recomendados
- 📕 "Working Effectively with Unit Tests" por Jay Fields
- 📗 "Test Driven Development: By Example" por Kent Beck
- 📘 "Clean Code" por Robert C. Martin (capítulo sobre testes)
- 🌐 Documentação oficial do JUnit 5
- 🌐 Documentação oficial do Mockito
- 🌐 Documentação oficial do AssertJ
Referências Bibliográficas
- Vocke, Ham. (2018). "The Practical Test Pyramid". Martin Fowler. Disponível em: https://martinfowler.com/articles/practical-test-pyramid.html
- Cohn, Mike. (2009). "Succeeding with Agile: Software Development Using Scrum". Addison-Wesley Professional.
- Fields, Jay. (2014). "Working Effectively with Unit Tests". Leanpub.
- Beck, Kent. (2002). "Test Driven Development: By Example". Addison-Wesley Professional.
- Martin, Robert C. (2008). "Clean Code: A Handbook of Agile Software Craftsmanship". Prentice Hall.
- Fowler, Martin. (2006). "Mocks Aren't Stubs". MartinFowler.com. Disponível em: https://martinfowler.com/articles/mocksArentStubs.html
- Spring Framework Documentation. "Testing". Disponível em: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html
- JUnit 5 User Guide. Disponível em: https://junit.org/junit5/docs/current/user-guide/
- Mockito Reference Documentation. Disponível em: https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
- AssertJ Documentation. Disponível em: https://assertj.github.io/doc/
Top comments (1)
Muito obrigado pelo esse conteúdo de excelência qualidade.