Testes e qualidade de software
Testes unitários em Java com JUnit 5 e Mockito 5
Iniciante8 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Um panorama prático do ferramental de testes unitários em Java como ele está hoje: o modelo de programação do JUnit Jupiter, as asserções fluentes do AssertJ, os cinco tipos de test double e como o Mockito os implementa. Termina com as decisões mais difíceis: testar o tempo, testar exceções e decidir o que nunca deve ser mockado.
O que você vai aprender
- Estruturar uma classe de teste do Jupiter com as anotações de ciclo de vida certas e um nome que descreva o comportamento
- Fazer asserções com assertAll, assertThrows e a API fluente do AssertJ em vez de uma pilha de chamadas a assertEquals
- Substituir testes repetitivos por @ParameterizedTest e organizar os casos com @Nested
- Diferenciar dummy, stub, spy, mock e fake e escolher o mais barato que resolva
- Criar stubs, verificar chamadas e capturar argumentos com o Mockito 5 sem superespecificar a implementação
Um teste unitário é uma verificação pequena, rápida e determinística de um comportamento específico, isolada de tudo o que seja lento o bastante para ser percebido. É o único tipo de teste que um desenvolvedor roda de bom grado dezenas de vezes por hora, e é isso que o torna a base de todo o resto: refatorar é seguro na mesma proporção da rapidez com que você descobre que quebrou alguma coisa.
O ferramental Java para isso está estável há anos (JUnit Jupiter para a estrutura, AssertJ para as asserções, Mockito para os test doubles), mas os detalhes mudaram. O JUnit 5.x e a linha JUnit 6 compartilham o modelo de programação do Jupiter no Java 17 ou superior; o Mockito 5 usa por padrão o inline mock maker, então mockar classes final e métodos estáticos não exige mais uma dependência extra. Os exemplos aqui pressupõem essas versões e um build que coloca tudo em src/test/java.
O que faz de um teste um teste unitário
Duas propriedades importam mais do que qualquer definição de "unidade". Um teste unitário precisa ser rápido: uma suíte de mil testes deve terminar em segundos, então nada pode tocar a rede, um banco de dados real ou o relógio de um jeito que o faça esperar. E precisa ser isolado: rodá-lo sozinho, em outra ordem ou em paralelo com os vizinhos tem que dar o mesmo resultado. Quando isso falha, os três culpados de sempre são estado estático compartilhado, arquivos gravados em um caminho fixo e um banco de dados deixado sujo pelo teste anterior.
Todo o resto é negociável. Um teste que constrói três value objects colaborando entre si continua sendo um teste unitário em qualquer sentido útil. Um teste que mocka as seis dependências de um serviço a ponto de só verificar os próprios stubs não é um bom teste, diga a classificação o que disser.
O modelo de programação do Jupiter
class InvoiceTotalTest {
private Invoice invoice;
@BeforeEach
void newInvoice() {
invoice = new Invoice("INV-1", Currency.getInstance("EUR"));
}
@Test
@DisplayName("an empty invoice totals zero in its own currency")
void emptyInvoiceTotalsZero() {
assertThat(invoice.total()).isEqualTo(Money.zero("EUR"));
}
@Test
void lineTotalsAreSummed() {
invoice.add(new Line("Consulting", 3, Money.of("450.00", "EUR")));
invoice.add(new Line("Licence", 1, Money.of("120.00", "EUR")));
assertThat(invoice.total()).isEqualTo(Money.of("1470.00", "EUR"));
}
}
No Jupiter, classes e métodos de teste não precisam do modificador public, e não existe runner: o engine descobre os métodos @Test diretamente. @BeforeEach e @AfterEach rodam em torno de cada teste; @BeforeAll e @AfterAll rodam uma vez por classe e precisam ser static, a menos que a classe esteja anotada com @TestInstance(Lifecycle.PER_CLASS). Por padrão, uma nova instância da classe de teste é criada para cada método de teste, e é isso que impede um teste de vazar estado para o próximo: resista à tentação de mudar isso por conveniência.
@DisplayName fornece um rótulo legível para os relatórios; @Disabled("reason") pula um teste e, ao contrário de comentá-lo, deixa um rastro visível. @Tag("slow") agrupa testes para que o build possa incluí-los ou excluí-los, e é assim que uma mesma árvore de código-fonte atende tanto a uma execução rápida antes do commit quanto a uma execução mais completa no pipeline.
Asserções que dá gosto de ler
As asserções do próprio Jupiter são adequadas e estão sempre disponíveis. Vale conhecer três delas mesmo em uma base de código que usa AssertJ:
assertAll("customer",
() -> assertEquals("Ada", customer.firstName()),
() -> assertEquals("Lovelace", customer.lastName()),
() -> assertTrue(customer.isActive()));
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> account.withdraw(Money.of("500.00", "EUR")));
assertEquals("INSUFFICIENT_FUNDS", thrown.getErrorCode());
assertTimeoutPreemptively(Duration.ofMillis(200), () -> report.render());
assertAll reporta todas as falhas que contém em vez de parar na primeira, o que transforma três reexecuções em uma. assertThrows devolve a exceção capturada para que você possa fazer asserções sobre o conteúdo dela: prefira sempre um código de erro tipado ou um campo a comparar o texto da mensagem, porque mensagens são reescritas e traduzidas.
O AssertJ troca o jogo de adivinhação sobre qual assertX chamar por um único assertThat e o autocomplete:
assertThat(orders)
.hasSize(3)
.extracting(Order::status)
.containsExactly(Status.NEW, Status.PAID, Status.SHIPPED);
assertThat(findCustomer("unknown")).isEmpty(); // Optional
assertThat(response.body())
.asString()
.contains("\"currency\":\"EUR\"");
assertThatThrownBy(() -> parser.parse("2026-13-45"))
.isInstanceOf(DateTimeParseException.class)
.hasMessageContaining("13");
assertThat(actualOrder)
.usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.isEqualTo(expectedOrder);
usingRecursiveComparison resolve o problema comum de comparar objetos cujo equals não é confiável, e é bem melhor do que escrever uma dúzia de asserções campo a campo que vão ficar desatualizadas. Quando um tipo do domínio aparece repetidamente nas asserções, uma classe de asserção customizada que estende AbstractAssert dá à suíte um vocabulário próprio: assertThat(invoice).isPaid().hasTotalOf("1470.00").
Eliminando repetição: testes parametrizados e aninhados
Quando o mesmo comportamento precisa ser verificado com várias entradas, um teste parametrizado deixa isso explícito em vez de esconder os casos em um loop:
@ParameterizedTest(name = "IBAN {0} is {1}")
@CsvSource({
"FR7630006000011234567890189, true",
"DE89370400440532013000, true",
"FR7630006000011234567890188, false", // wrong check digits
"XX00, false"
})
void validatesIbanCheckDigits(String iban, boolean expected) {
assertThat(Iban.isValid(iban)).isEqualTo(expected);
}
@ParameterizedTest
@EnumSource(value = Status.class, names = {"SHIPPED", "DELIVERED", "REFUNDED"})
void terminalStatusesCannotBeCancelled(Status status) {
assertThatThrownBy(() -> new Order(status).cancel())
.isInstanceOf(IllegalStateException.class);
}
@ParameterizedTest
@MethodSource("discountScenarios")
void appliesTieredDiscount(Money subtotal, Tier tier, Money expected) {
assertThat(pricing.discount(subtotal, tier)).isEqualTo(expected);
}
static Stream<Arguments> discountScenarios() {
return Stream.of(
arguments(Money.of("99.99", "EUR"), Tier.STANDARD, Money.zero("EUR")),
arguments(Money.of("100.00", "EUR"), Tier.STANDARD, Money.of("5.00", "EUR")),
arguments(Money.of("100.00", "EUR"), Tier.PREMIUM, Money.of("10.00", "EUR")));
}
Cada conjunto de argumentos é reportado como um teste separado, então uma falha aponta a entrada que quebrou. O atributo name controla como ele aparece. Outras fontes incluem @ValueSource para um único parâmetro primitivo, @NullAndEmptySource para as duas entradas que todo mundo esquece e @CsvFileSource quando a tabela cresce além do que cabe em uma anotação. Os valores-limite das técnicas de projeto de testes se encaixam naturalmente nesse formato: uma linha por limite, com um comentário dizendo qual limite é.
Classes internas @Nested agrupam testes que compartilham a mesma preparação e dão ao relatório uma hierarquia legível:
class ShoppingCartTest {
@Nested
class WhenEmpty {
private final Cart cart = new Cart();
@Test void totalIsZero() { assertThat(cart.total()).isZero(); }
@Test void checkoutIsRejected() { assertThatThrownBy(cart::checkout).isInstanceOf(EmptyCartException.class); }
}
@Nested
class WithOneItem {
private final Cart cart = new Cart().add(Item.of("SKU-1", Money.of("20.00", "EUR")));
@Test void totalIsTheItemPrice() { assertThat(cart.total()).isEqualTo(Money.of("20.00", "EUR")); }
@Test void removingLeavesEmpty() { assertThat(cart.remove("SKU-1").isEmpty()).isTrue(); }
}
}
Cinco tipos de test double
No dia a dia, chamamos todos de "mock", mas as distinções descrevem trade-offs reais.
| Double | O que faz | Uso típico |
|---|---|---|
| Dummy | Passado para satisfazer uma assinatura, nunca usado | Preencher um parâmetro de construtor não utilizado |
| Stub | Devolve respostas prontas | Fornecer ao código sob teste os dados de que um cenário precisa |
| Spy | Objeto real que registra como foi chamado | Código legado que você não consegue substituir com facilidade |
| Mock | Programado com expectativas e verificado depois | Checar que um efeito colateral aconteceu |
| Fake | Implementação funcional, porém simplificada | Repositório em memória, um hash map no lugar de um servidor de cache |
O que o pessoal subutiliza é o fake. Uma implementação em memória de uma interface de repositório, escrita uma vez, faz com que dezenas de testes se leiam como código comum, sem stub nenhum, e pega bugs de sequência que um stub devolvendo um valor fixo não consegue pegar. Se a interface de um colaborador é pequena e você vive fazendo stub dela do mesmo jeito, escreva o fake.
Mockito na prática
@ExtendWith(MockitoExtension.class)
class SubscriptionServiceTest {
@Mock PaymentGateway gateway;
@Mock SubscriberRepository subscribers;
@Mock Mailer mailer;
@InjectMocks SubscriptionService service;
@Test
void chargesAndActivatesOnSuccess() {
Subscriber ada = new Subscriber(7L, "[email protected]", Plan.ANNUAL);
when(subscribers.findById(7L)).thenReturn(Optional.of(ada));
when(gateway.charge(any(ChargeRequest.class)))
.thenReturn(ChargeResult.approved("txn-88"));
service.renew(7L);
assertThat(ada.status()).isEqualTo(Status.ACTIVE);
verify(subscribers).save(ada);
verify(mailer).sendRenewalReceipt("[email protected]", "txn-88");
}
@Test
void doesNotActivateWhenTheCardIsDeclined() {
when(subscribers.findById(7L)).thenReturn(Optional.of(new Subscriber(7L, "[email protected]", Plan.ANNUAL)));
when(gateway.charge(any())).thenReturn(ChargeResult.declined("insufficient_funds"));
assertThatThrownBy(() -> service.renew(7L)).isInstanceOf(PaymentDeclinedException.class);
verify(subscribers, never()).save(any());
verifyNoInteractions(mailer);
}
}
O MockitoExtension cria os mocks, faz a injeção e, o que é importante, impõe strict stubs: um stub que nunca é usado faz o teste falhar. Essa regra pega preparação copiada e colada e testes que silenciosamente deixaram de corresponder ao código. Quando um stub é de fato condicional, envolva-o com lenient() em vez de afrouxar a classe inteira.
Argument matchers (any(), eq(), argThat()) precisam ser usados em todos os argumentos de uma chamada ou em nenhum: misturar um valor literal com um matcher lança uma exceção. Quando você precisar inspecionar o que foi passado, capture o argumento:
@Captor ArgumentCaptor<ChargeRequest> charge;
@Test
void chargesTheAnnualPriceInTheSubscriberCurrency() {
// ... arrange and act ...
verify(gateway).charge(charge.capture());
assertThat(charge.getValue().amount()).isEqualTo(Money.of("199.00", "EUR"));
assertThat(charge.getValue().idempotencyKey()).isNotBlank();
}
Outras coisas que vale saber: thenAnswer calcula um resultado a partir dos argumentos (when(repo.save(any())).thenAnswer(i -> i.getArgument(0)) é a receita padrão de "o save devolve o que recebeu"); doThrow(...).when(mock).method() faz stub de métodos void; verify(mock, times(2)), atLeastOnce() e InOrder restringem quantas vezes e em que ordem as chamadas acontecem; e mockStatic / mockConstruction existem para código legado, mas devem ser encarados como um sinal de que o design precisa de um ponto de desacoplamento, não como ferramenta de rotina.
Dependências incômodas
Tempo. Injete um java.time.Clock. A produção fornece Clock.systemUTC(), o teste fornece Clock.fixed(Instant.parse("2026-03-01T09:00:00Z"), ZoneOffset.UTC), e as asserções sobre expiração, retenção ou dias úteis ficam exatas em vez de aproximadas.
Aleatoriedade e identificadores. Receba como dependência um Supplier<UUID> ou um RandomGenerator com seed. Um teste que verifica que uma chave de idempotência "não está em branco" é bem mais fraco do que um que verifica que ela é igual a um valor conhecido.
Arquivos. O parâmetro ou campo @TempDir do Jupiter dá a cada teste um diretório novo que é apagado depois, o que é mais simples e mais seguro do que um caminho fixo em /tmp.
Assincronia. Nunca use Thread.sleep. Ou faça o componente aceitar um executor, para que o teste possa rodar as tarefas de forma síncrona, ou faça polling com o Awaitility: await().atMost(TWO_SECONDS).untilAsserted(() -> assertThat(store.count()).isEqualTo(3)).
O que não mockar
Não faça mock de tipos que não são seus: um driver JDBC, uma biblioteca cliente HTTP, o cliente de um message broker. Os contratos deles são complexos, o seu stub codifica o que você acredita sobre eles, e o teste passa sejam essas crenças verdadeiras ou não. Coloque uma interface fina sua na frente da biblioteca e faça mock dela, ou teste contra o componente real em um teste de integração.
Não faça mock de value objects, DTOs, coleções nem enums. Construir um de verdade é mais barato que criar um stub e infinitamente mais legível.
Não faça mock da classe sob teste, nem mesmo com um spy parcial que faz stub de metade dos próprios métodos. Se um método é difícil de testar porque outro método da mesma classe faz algo caro, esse outro método está pedindo para virar um colaborador separado.
Por fim, não verifique todas as chamadas. Cada verify é uma asserção sobre como o código funciona, e uma suíte cheia deles quebra em refatorações que não mudaram nada observável. Verifique os efeitos colaterais que são a razão de ser do método (o pagamento cobrado, a mensagem enviada) e deixe os valores de retorno cuidarem do resto. Nomes também ajudam: renewalReceiptIsSentAfterASuccessfulCharge descreve um comportamento, enquanto testRenew2 não descreve nada. O guia de padrões de código cobre as convenções que mantêm o código de teste tão legível quanto o de produção.
Principais conclusões
- Um teste unitário merece o nome por ser rápido e isolado; todo o resto da classificação é discussão de nomenclatura.
- O Jupiter cria uma nova instância de teste por método, não precisa de runner nem de modificadores public e oferece
assertAll,assertThrowse@TempDirde fábrica. - As asserções fluentes do AssertJ geram mensagens de falha melhores e lidam com coleções, optionals e comparação de objetos inteiros sem código auxiliar.
@ParameterizedTestcom@CsvSourceou@MethodSourcetransforma uma tabela de casos-limite em testes reportados individualmente.- Conheça os cinco test doubles e recorra a um fake quando o mesmo stub se repetir; os strict stubs vão avisar quando um mock tiver se afastado da realidade.
- Injete relógios, fontes de aleatoriedade e executors em vez de mockar métodos estáticos, e nunca faça mock de tipos que não são seus nem da classe sob teste.
Fontes e leituras complementares
Perguntas frequentes
- Devo usar as asserções nativas do JUnit ou o AssertJ?
- Os dois funcionam, mas o AssertJ escala melhor. A API fluente permite descobrir a asserção certa pelo autocomplete, gera mensagens de falha que descrevem a diferença em vez de despejar dois objetos e lida com coleções, optionals, exceções e comparação recursiva sem métodos auxiliares. As asserções do JUnit bastam para igualdade simples e estão sempre disponíveis; a maioria dos times padroniza no AssertJ e usa assertThrows ou assertAll do Jupiter junto com ele.
- Quando usar @Mock em vez de construir o objeto real?
- Faça mock de um colaborador quando ele for lento, não determinístico, tiver efeitos colaterais indesejados ou quando a interação com ele for justamente o objetivo do teste. Use o objeto real quando ele for um valor, uma estrutura de dados simples ou uma função pura: mockar esses casos enfraquece o teste e o acopla a um detalhe de implementação. Se mockar algo exige mais de três chamadas com stub, geralmente é sinal de que o design tem colaboradores demais.
- Por que o Mockito reclama de stubs desnecessários?
- O MockitoExtension usa strict stubs por padrão, que fazem um teste falhar quando ele cria um stub para uma chamada que o código sob teste nunca faz. É uma verificação realmente útil: um stub não utilizado costuma indicar que o teste não corresponde mais ao código de produção ou que o stub foi copiado de outro teste. Corrija o teste em vez de mudar para o modo lenient; se um stub for legitimamente condicional, marque só ele com Mockito.lenient().
- Como fazer teste unitário de código que chama LocalDateTime.now()?
- Injete um java.time.Clock e leia o horário por meio dele. O código de produção recebe Clock.systemDefaultZone() pela configuração, os testes recebem Clock.fixed(instant, zone) e se tornam determinísticos. O mesmo princípio vale para números aleatórios, geração de UUID e sistema de arquivos: receba a fonte de não determinismo como parâmetro do construtor em vez de chamar uma factory estática, e o teste não vai precisar de truques de bytecode.