Todo mundo testa o próprio código. A diferença está em como: abrindo a aplicação, clicando nos mesmos campos e conferindo o resultado com os olhos — ou escrevendo, uma vez só, uma afirmação que a máquina reconfere sozinha a cada build.

O problema do teste manual não é ser trabalhoso; é ser seletivo. Você só reconfere o que lembra de reconferir, e o que quebra costuma ser justamente aquilo em que ninguém pensou. Um teste automatizado não esquece.

Este artigo é uma introdução prática ao JUnit, a biblioteca padrão de testes do ecossistema Java. Vamos escrever o primeiro teste e — a parte que os guias costumam pular — aprender a ler a saída que ele produz quando falha.

🔬 O que é um teste unitário

A palavra unidade é a chave: um teste unitário exercita um pedaço pequeno do código em isolamento, normalmente um método ou uma classe. Sem banco de dados, sem rede, sem arquivo, sem subir a aplicação inteira.

Na prática, um bom teste unitário tem três características:

Quando o teste precisa de banco, de HTTP ou de outro serviço, ele deixa de ser unitário e vira um teste de integração. Os dois são necessários, mas custam coisas diferentes: o unitário custa milissegundos, o de integração custa segundos. No Budgeting eu separo a suíte exatamente por esse critério — pelo que cada teste custa para rodar.

📌 Sobre a versão: JUnit 5, Jupiter e JUnit 6

Vale esclarecer um ponto que confunde bastante quem procura material hoje. A documentação usada aqui é a do JUnit 6.1.3, mas o modelo de programação continua sendo o JUnit Jupiter — o mesmo que ficou conhecido como "JUnit 5".

Os métodos, os imports e os comportamentos descritos neste artigo valem para os dois. A diferença prática mais visível é o requisito de Java 17 ou superior no runtime.

O que não vale é material de JUnit 4. É uma API diferente, com imports diferentes (org.junit em vez de org.junit.jupiter.api) e regras diferentes. Se a sua IDE sugerir org.junit.Assert, ela está oferecendo a versão antiga.

⚙️ Preparando o projeto

Em um projeto Maven, basta a dependência do agregador Jupiter no escopo de teste:

pom.xml
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Em projetos Spring Boot isso já vem incluído pelo spring-boot-starter-test, junto com Mockito e AssertJ — não é preciso declarar nada.

O código de teste vive em src/test/java, espelhando a estrutura de pacotes de src/main/java. A convenção de nome é a classe testada seguida de Test:

src/
├── main/java/br/com/matheuz/Calculator.java
└── test/java/br/com/matheuz/CalculatorTest.java

Esse sufixo não é decorativo: o Maven Surefire, plugin que executa os testes durante o mvn test, procura por classes que terminem em Test. Um arquivo chamado CalculatorTests ou TesteCalculator simplesmente não roda, e o silêncio resultante é fácil de confundir com sucesso.

✍️ O primeiro teste

Todos os métodos de asserção do Jupiter são estáticos. O padrão da comunidade — e o usado na própria documentação — é importá-los com import static, para que o teste leia como uma frase:

CalculatorTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    @Test
    void somaDoisNumeros() {
        assertEquals(2, calculator.add(1, 1));
    }
}

Duas coisas para gravar já nesse exemplo:

Note também que nem a classe nem o método precisam ser public. No JUnit 4 isso era obrigatório; no Jupiter, visibilidade de pacote basta, e é o estilo recomendado.

🧩 O que é uma asserção

Uma asserção é uma afirmação sobre o seu código que o JUnit verifica na hora em que o teste roda. Você escreve "o resultado disso tem que ser 2"; o JUnit executa, compara e decide.

A mecânica é simples: se a afirmação for verdadeira, nada acontece e o teste segue. Se for falsa, o método de asserção lança um erro (AssertionFailedError, do projeto OpenTest4J), o método de teste é interrompido naquele ponto e o relatório marca o teste como falho.

afirmação verdadeira  →  o teste continua
afirmação falsa       →  erro lançado, teste interrompido

Esse detalhe do "interrompido" é importante e volta a aparecer mais adiante: por padrão, a primeira asserção que falha encerra o teste. As asserções escritas depois dela nem chegam a rodar.

🧱 A anatomia: Arrange, Act, Assert

Testes bem escritos seguem três fases, nessa ordem. O exemplo acima é tão curto que elas se fundem, mas em um caso realista a separação fica visível:

@Test
void aplicaDescontoParaPedidoAcimaDeCem() {
    // Arrange — prepara o cenário
    Pedido pedido = new Pedido(new BigDecimal("150.00"));

    // Act — executa a ação sob teste
    BigDecimal total = calculadora.totalComDesconto(pedido);

    // Assert — confere o resultado
    assertEquals(new BigDecimal("135.00"), total);
}

O valor dessa divisão é diagnóstico: se o bloco Arrange começa a ficar longo, é sinal de que a classe testada depende de coisas demais. Se existe mais de um Act, o teste provavelmente está verificando dois comportamentos e deveria virar dois testes.

📖 Lendo a saída

Essa é a parte que decide se o teste vai te ajudar ou só te irritar. Vamos rodar o CalculatorTest nas duas situações possíveis.

Quando passa, o JUnit não diz nada

A primeira surpresa de quem começa é que sucesso é silêncio. Com o Calculator correto, o mvn test devolve:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Nenhuma menção ao nome do teste, nenhuma confirmação individual. O JUnit só fala quando há algo errado — a contagem no topo é o relatório inteiro.

Quando falha

Agora suponha que o add foi escrito errado e devolve 4. O mesmo teste produz:

[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 -- in CalculatorTest
[ERROR] CalculatorTest.somaDoisNumeros -- Time elapsed: 0.008 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <2> but was: <4>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145)
	at CalculatorTest.somaDoisNumeros(CalculatorTest.java:12)

São cinco informações distintas aí, e vale destrinchar cada uma.

1. Failures e Errors são colunas diferentes. Uma asserção que reprovou é um failure: o código rodou e o resultado não bateu com o esperado. Uma exceção inesperada — um NullPointerException, por exemplo — é um error: o código nem chegou a produzir um resultado para comparar. A distinção orienta a investigação: failure aponta para uma regra implementada errado; error aponta para algo que quebrou antes disso.

2. org.opentest4j.AssertionFailedError, e não java.lang.AssertionError. O OpenTest4J é um projeto minúsculo e separado do JUnit, criado para dar às ferramentas de teste um vocabulário comum de falhas. É graças a ele que a sua IDE consegue oferecer aquele link de "comparar valores" lado a lado: ela reconhece o tipo do erro e sabe que ali dentro existem um valor esperado e um obtido, sem precisar entender o JUnit especificamente.

3. expected: <2> but was: <4>. Os sinais de menor e maior delimitam os valores, e isso importa mais do que parece quando eles têm espaços ou são vazios — expected: <> but was: < > só faz sentido com os delimitadores. É também aqui que a ordem dos argumentos cobra o preço: com (real, esperado) invertido, a mensagem continua aparecendo normalmente, mas passa a afirmar o contrário do que acontece — e você perde um tempo considerável procurando o defeito no lugar errado.

4. A pilha se lê de baixo para cima. As quatro primeiras linhas são encanamento interno do JUnit montando o objeto de erro. A linha que interessa é a última, a única que cita o seu código:

at CalculatorTest.somaDoisNumeros(CalculatorTest.java:12)

O reflexo de ler a primeira linha da pilha é o que mais atrapalha quem está começando — ela leva direto para uma classe chamada AssertionFailureBuilder, que não tem nada a ver com o problema. A regra prática é procurar, de baixo para cima, a primeira linha que menciona uma classe sua.

5. A linha 12 é a da asserção, não a do bug. O defeito está em Calculator.add; a linha 12 é apenas onde a expectativa foi conferida. O teste informa onde a promessa foi quebrada, não onde mora o erro — e entender essa diferença é o que transforma a falha em ponto de partida da investigação, em vez de resposta pronta.

Dando nome à falha

Todo método de asserção aceita um argumento final opcional de mensagem, que aparece na frente do diagnóstico automático sem substituí-lo:

assertEquals(2, calculator.add(1, 1), "a soma de 1 + 1 deve ser 2");
a soma de 1 + 1 deve ser 2 ==> expected: <2> but was: <4>

Existe também a versão que recebe um Supplier<String>, útil quando montar a mensagem custa caro — ela só é construída se o teste realmente falhar:

assertEquals(2, calculator.add(1, 1),
        () -> "falhou com o estado: " + calculator.dump());
O detalhe que fecha o ciclo

O mvn test termina com código de saída diferente de zero quando há falha. É isso — e não o texto vermelho no console — que faz a esteira de integração contínua barrar o merge, o mesmo ponto de controle descrito no artigo sobre fluxo de desenvolvimento.

🧰 As asserções que cobrem quase tudo

A classe Assertions tem dezenas de métodos, mas o dia a dia gira em torno de meia dúzia:

MétodoVerifica
assertEquals(esp, real)Igualdade, via equals()
assertTrue(cond)Que a condição é verdadeira
assertFalse(cond)Que a condição é falsa
assertNull(obj) / assertNotNull(obj)Nulidade
assertThrows(tipo, exec)Que uma exceção foi lançada
assertAll(...)Várias asserções, todas até o fim

O assertThrows merece destaque porque testa o caminho do erro, que é onde os defeitos costumam se esconder. Ele devolve a exceção capturada, permitindo conferir também a mensagem:

@Test
void naoAceitaDivisaoPorZero() {
    ArithmeticException e = assertThrows(
            ArithmeticException.class,
            () -> calculator.divide(1, 0));

    assertEquals("/ by zero", e.getMessage());
}

Já o assertAll é a resposta direta àquele comportamento de interrupção que vimos antes. Compare:

// sem assertAll: se o nome falhar, o CPF nunca é verificado
assertEquals("Amanda", aluno.getNome());
assertEquals("333.111.111-00", aluno.getCpf());

// com assertAll: as duas rodam, e o relatório mostra todas as que falharam
assertAll("dados do aluno",
        () -> assertEquals("Amanda", aluno.getNome()),
        () -> assertEquals("333.111.111-00", aluno.getCpf()));

A diferença aparece na saída: em vez de corrigir um problema, rodar de novo e descobrir o segundo, você vê os dois de uma vez.

⚠️ Três armadilhas de quem está começando

1. Comparar double sem tolerância
assertEquals(0.3, 0.1 + 0.2);
// expected: <0.3> but was: <0.30000000000000004>

Não é bug do JUnit: é como ponto flutuante binário representa decimais. Para comparações desse tipo existe o terceiro parâmetro de tolerância — assertEquals(0.3, 0.1 + 0.2, 0.0001). E quando o valor é dinheiro, a resposta certa não é tolerância, e sim BigDecimal, como uso no Budgeting.

2. Esperar que tipos diferentes sejam iguais
assertEquals(1, 1L);
// expected: <1> but was: <1>

Essa é especialmente cruel, porque a mensagem parece dizer que dois valores idênticos são diferentes. O motivo é que Integer.equals(Long) é sempre false — os valores são iguais, os tipos não. Repare que o JUnit, nesses casos, costuma anexar os tipos à mensagem para desfazer a confusão.

3. Testar a implementação, não o comportamento

Um teste que conhece detalhes internos demais quebra a cada refatoração inofensiva e vira custo em vez de rede de proteção. O critério para escrever um bom teste é perguntar o que essa unidade promete a quem a usa — e verificar só isso. É a mesma lógica que aplico no Budgeting, onde os testes conferem o efeito no banco de dados, e não o texto exato que o modelo de linguagem produziu: o texto muda, a regra não.

🏷️ Nomeando os testes

O nome do teste é lido em dois momentos: quando alguém abre o arquivo e quando algo falha no CI. Vale escrevê-lo como uma frase que descreve o comportamento esperado:

@Test
@DisplayName("não aceita aluno com CPF já cadastrado")
void naoAceitaCpfDuplicado() {
    ...
}

O @DisplayName aceita espaços, acentos e emoji, e é o texto que aparece no relatório e na IDE. Sem ele, o relatório mostra o nome do método — o que já basta, desde que o método não se chame teste1.

✅ Conclusão

Dá para começar hoje com muito pouco: a dependência do Jupiter, uma classe em src/test/java, um @Test e um assertEquals. O restante da API você aprende conforme a necessidade aparece.

O que separa quem escreve testes de quem se apoia neles é o hábito de tratar a falha como informação, e não como obstáculo. Uma suíte verde não diz nada além de "nada mudou". É quando a barra fica vermelha que o teste faz aquilo que você pagou para ele fazer — e, a essa altura, você já sabe ler exatamente o que ele está dizendo.

Os próximos passos naturais são o Mockito, para isolar dependências; a cobertura de testes, para enxergar o que ficou de fora; e o PIT, que faz teste de mutação e mede se a sua suíte realmente detectaria um defeito — ferramenta que uso no AquaVida.