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:
- Rápido — roda em milissegundos. É o que permite executar centenas deles a cada alteração sem que isso vire um intervalo para o café.
- Determinístico — o mesmo código produz sempre o mesmo resultado. Um teste que às vezes passa e às vezes falha é pior que teste nenhum, porque ensina a equipe a ignorar a cor vermelha.
- Independente — não depende de outro teste ter rodado antes, nem da ordem de execução.
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.
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:
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:
- A ordem dos argumentos é
(esperado, real). O valor que você espera vem primeiro, o valor produzido pelo código vem depois. Inverter não quebra o teste, mas inverte a mensagem de falha — e aí ela passa a mentir sobre quem é quem. - O import é de
org.junit.jupiter.api, como visto acima.
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étodo | Verifica |
|---|---|
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.