Unit test bom: nomenclatura, estrutura e cobertura

1. Visão geral

Um bom unit test não é apenas um teste que “passa”. Ele deve ser fácil de ler, fácil de manter, e quando falha deve te dizer exatamente onde está o problema sem precisar debugar a tarde toda. Este artigo passa por três pilares: nomear testes de forma autodescritiva, estruturar testes seguindo AAA (Arrange-Act-Assert), e ter a perspectiva correta sobre cobertura (coverage) para não cair na armadilha de “100% de cobertura mas inútil”.

Ao fim da leitura, você terá um checklist concreto para revisar seus testes antes de criar um pull request.

2. Dependências necessárias

Os exemplos neste artigo usam JUnit 5 e AssertJ. Se você tem um projeto Spring Boot, spring-boot-starter-test já inclui ambos.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Nomenclatura de testes: o nome deve contar a história

O nome do teste é o primeiro documento que alguém lê quando um teste falha. Um bom nome responde três perguntas: o que está sendo testado, sob qual condição, e qual é o resultado esperado.

3.1. Padrão methodName_condition_expectedResult

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

Explicação:

  • O nome começa com o método sendo testado (calculateTotal), depois when... indica a condição, e finalmente o resultado esperado.
  • Quando um teste falha no relatório da CI, você já sabe em qual ramo lógico o erro está apenas pelo nome — não precisa abrir o código.

Aviso: evite nomes como test1(), testCalculateTotal(). Eles não dizem nada sobre o contexto, forçando o leitor a entrar no corpo do teste para entender o que está sendo verificado.

3.2. @DisplayName quando o nome do método não é suficiente

Com regras de negócio complexas, nomes de método longos ficam difíceis de ler. Use @DisplayName para escrever sentenças mais naturais, mantendo o nome do método curto para navegação de código.

@Test
@DisplayName("Quando o cliente é VIP e o pedido acima de 1 milhão, aplica 5% de desconto adicional")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

O resultado ao executar com Maven Surefire ou IDE mostrará a frase do @DisplayName em vez do nome do método, mais legível no relatório.

4. Estrutura AAA: Arrange – Act – Assert

Cada teste deve ter três partes claras, na ordem correta, sem misturar. Isso ajuda o leitor a escanear rapidamente: qual linha prepara os dados, qual linha executa a ação, qual linha verifica o resultado.

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

Explicação:

  • Arrange cria o objeto Account com o saldo inicial.
  • Act chama exatamente uma ação sendo testada (withdraw).
  • Assert verifica o resultado, usando AssertJ para afirmações que leem como prosa natural.

4.1. Um teste, uma ação

Quanto mais coisas um teste “faz”, mais difícil é debugar quando falha. Evite escrever testes assim:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // erro: saldo insuficiente
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

Se a primeira afirmação falha, as duas ações seguintes não executam — você perde informação. Separe em três testes: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.

4.2. Testes de exceção devem verificar o tipo e a mensagem corretos

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Saldo insuficiente");
}

A linha assertThatThrownBy executa a ação e captura a exceção em uma etapa. Não use @Test(expected = ...) do estilo antigo porque não permite verificar a mensagem ou o estado após a exceção.

5. Use @ParameterizedTest para reduzir código duplicado

Quando múltiplas entradas diferentes testam a mesma lógica, use testes parametrizados em vez de copiar e colar vários métodos.

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

Explicação:

  • @CsvSource fornece pares de entrada/saída, cada linha é um caso de teste independente.
  • O relatório mostrará cada caso separadamente, você sabe exatamente qual falhou, sem precisar adivinhar.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. Mock na medida certa, não em excesso

Use mock para dependências externas (banco de dados, cliente HTTP, outro serviço), não para o objeto sendo testado.

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

Explicação:

  • @Mock cria um objeto falso para PaymentGateway — a chamada para fora (pode ser uma API real).
  • @InjectMocks injeta esse mock no PaymentService sendo testado.
  • verify confirma que charge foi chamado com os parâmetros corretos, não apenas verifica o resultado retornado.

Aviso: se você precisa fazer mock de mais de 3-4 dependências para um teste, é sinal de que a classe está fazendo demais. Considere dividir a classe antes de dividir o teste.

7. Cobertura (coverage): métrica de referência, não um objetivo

Alta cobertura não significa testes bons. Por exemplo, o teste a seguir atinge 100% de cobertura de linhas para o método divide mas não detecta o bug:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

Este teste passa por todas as linhas de código (100% de cobertura) mas não testa o caso b = 0 — onde ArithmeticException será lançada em produção. Cobertura mede apenas quais linhas foram executadas, não se foram verificadas corretamente.

7.1. Use cobertura para encontrar pontos cegos, não para relatórios bonitos

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

Execute mvn test, JaCoCo gera um relatório HTML em target/site/jacoco/index.html. Use este relatório para responder “qual ramo ainda não foi tocado pelos testes”, depois pergunte a si mesmo “esse ramo tem lógica importante?” — não persiga números de % absolutos.

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← ainda não testou o caso b=0

7.2. Casos extremos (edge cases) são mais importantes que o %

Adicione um teste para o caso que foi perdido acima:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

Uma linha de teste como essa tem muito mais valor do que escrever 5 testes repetindo casos normais apenas para aumentar o % de cobertura.

8. Checklist para autoavaliação de testes

Antes de criar um pull request, faça uma autoavaliação:

  • O nome do teste deixa claro a condição e o resultado esperado, ou é só test1, testX?
  • Cada teste tem exatamente uma ação e uma razão para falhar?
  • Há casos extremos testados: null, empty, 0, valores negativos, limites superiores?
  • Há testes para exceções, ou apenas o caminho feliz?
  • Onde a cobertura é baixa — é lógica importante que foi perdida, ou apenas getters/setters que não precisam teste?

9. Conclusão

Um bom unit test é aquele em que, quando falha, você sabe exatamente onde está o problema sem precisar debugar. Nomeie seguindo o padrão method_condition_expected, mantenha a estrutura AAA clara com uma ação por teste, e use cobertura como uma ferramenta para encontrar pontos cegos, não como um objetivo — esses são três princípios simples mas suficientes para elevar a qualidade da sua test suite um degrau. A parte difícil não é escrever afirmações, mas dedicar tempo para pensar nos casos extremos que seu código pode encontrar em produção.

testingjunitjavabest-practicesspring-boot