JUnit 5 (JUnit Jupiter) é o framework de testes padrão para Java. Modular, extensível e com suporte nativo a lambdas e streams, representa uma reescrita completa em relação ao JUnit 4.
JUnit 5 vs JUnit 4
| Característica | JUnit 4 | JUnit 5 |
|---|---|---|
| Módulo único | junit-4.x.jar | 3 módulos: Platform, Jupiter, Vintage |
| Pacote principal | org.junit | org.junit.jupiter.api |
@Before / @After | @Before / @After | @BeforeEach / @AfterEach |
@BeforeClass / @AfterClass | @BeforeClass / @AfterClass | @BeforeAll / @AfterAll |
@Ignore | @Ignore | @Disabled |
@RunWith | @RunWith(Xxx.class) | @ExtendWith(Xxx.class) |
| Assertions | org.junit.Assert | org.junit.jupiter.api.Assertions |
| Java mínimo | Java 5 | Java 8 |
@Nested | não existe | suportado nativamente |
| Testes parametrizados | via @RunWith(Parameterized.class) | @ParameterizedTest nativo |
<!-- pom.xml — JUnit 5 + Mockito + AssertJ -->
<dependencies>
<!-- JUnit Jupiter (engine de testes JUnit 5) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Necessário para o Maven executar testes JUnit 5 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>Anotações Principais
import org.junit.jupiter.api.*;
class ExemploAnotacoesTest {
// @BeforeAll — executa uma vez antes de todos os testes da classe
// Deve ser static (ou a classe deve ter @TestInstance(Lifecycle.PER_CLASS))
@BeforeAll
static void setupGlobal() {
System.out.println("Inicializando recursos compartilhados...");
// ex: iniciar servidor embutido, banco em memória
}
// @AfterAll — executa uma vez após todos os testes
@AfterAll
static void teardownGlobal() {
System.out.println("Liberando recursos compartilhados...");
}
// @BeforeEach — executa antes de CADA teste
@BeforeEach
void setup() {
// reinicializa estado antes de cada teste
// garante isolamento entre testes
}
// @AfterEach — executa após CADA teste
@AfterEach
void teardown() {
// limpa recursos usados no teste
}
// @Test — marca o método como caso de teste
@Test
void deveRetornarSoma() {
int resultado = 2 + 2;
Assertions.assertEquals(4, resultado);
}
// @DisplayName — nome legível exibido nos relatórios
@Test
@DisplayName("Deve lançar exceção quando dividir por zero")
void deveLancarExcecaoDivisaoPorZero() {
Assertions.assertThrows(ArithmeticException.class, () -> {
int resultado = 10 / 0;
});
}
// @Disabled — desabilita um teste (com mensagem obrigatória)
@Test
@Disabled("Aguardando correção do bug #1234")
void testeDesabilitado() {
Assertions.fail("Este teste não deve executar");
}
// @Tag — categoriza testes para execução seletiva
@Test
@Tag("integracao")
@Tag("lento")
void testeIntegracao() {
// roda apenas quando: mvn test -Dgroups=integracao
}
}@Nested — Testes Aninhados
import org.junit.jupiter.api.*;
// @Nested organiza testes relacionados em classes internas
// Excelente para descrever comportamentos de uma classe complexa
@DisplayName("ContaBancaria")
class ContaBancariaTest {
ContaBancaria conta;
@BeforeEach
void criarConta() {
conta = new ContaBancaria("12345", BigDecimal.valueOf(1000));
}
@Nested
@DisplayName("ao realizar saque")
class AoRealizarSaque {
@Test
@DisplayName("deve reduzir o saldo quando valor é válido")
void deveReduzirSaldo() {
conta.sacar(BigDecimal.valueOf(300));
assertThat(conta.getSaldo()).isEqualByComparingTo("700.00");
}
@Test
@DisplayName("deve lançar exceção quando saldo insuficiente")
void deveLancarExcecaoSaldoInsuficiente() {
assertThrows(SaldoInsuficienteException.class,
() -> conta.sacar(BigDecimal.valueOf(1500)));
}
@Test
@DisplayName("deve lançar exceção quando valor negativo")
void deveLancarExcecaoValorNegativo() {
assertThrows(ValorInvalidoException.class,
() -> conta.sacar(BigDecimal.valueOf(-100)));
}
}
@Nested
@DisplayName("ao realizar depósito")
class AoRealizarDeposito {
@Test
@DisplayName("deve aumentar o saldo")
void deveAumentarSaldo() {
conta.depositar(BigDecimal.valueOf(500));
assertThat(conta.getSaldo()).isEqualByComparingTo("1500.00");
}
}
}Assertions
JUnit 5 Assertions nativas
import static org.junit.jupiter.api.Assertions.*;
class AssertionsTest {
@Test
void demonstrandoAssertions() {
// assertEquals — igualdade
assertEquals(4, 2 + 2);
assertEquals("esperado", "esperado", "mensagem exibida se falhar");
// assertNotEquals
assertNotEquals(5, 2 + 2);
// assertTrue / assertFalse
assertTrue("hello".startsWith("he"));
assertFalse("hello".isEmpty());
// assertNull / assertNotNull
String nulo = null;
assertNull(nulo);
assertNotNull("valor");
// assertSame / assertNotSame — referência de memória
String a = "text";
String b = a;
assertSame(a, b); // mesma referência
// assertArrayEquals
assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
// assertIterableEquals
assertIterableEquals(
List.of("a", "b", "c"),
List.of("a", "b", "c")
);
}
@Test
void demonstrandoAssertThrows() {
// assertThrows — verifica que uma exceção é lançada
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> new Produto(null, -1) // deve lançar a exceção
);
// Verifica a mensagem da exceção
assertEquals("Nome não pode ser nulo", ex.getMessage());
assertTrue(ex.getMessage().contains("nulo"));
}
@Test
void demonstrandoAssertAll() {
// assertAll — executa todas as assertions mesmo que uma falhe
// Útil para validar múltiplos campos de um objeto
Endereco endereco = new Endereco("Rua X", "123", "SP", "01310-100");
assertAll("endereço completo",
() -> assertEquals("Rua X", endereco.getLogradouro()),
() -> assertEquals("123", endereco.getNumero()),
() -> assertEquals("SP", endereco.getEstado()),
() -> assertEquals("01310-100", endereco.getCep())
);
}
@Test
void demonstrandoAssertTimeout() {
// assertTimeout — falha se demorar mais que o limite
String resultado = assertTimeout(
Duration.ofMillis(100),
() -> {
Thread.sleep(50);
return "resultado";
}
);
assertEquals("resultado", resultado);
// assertTimeoutPreemptively — interrompe a execução ao exceder o tempo
// (executa em thread separada)
assertTimeoutPreemptively(
Duration.ofMillis(100),
() -> metodoRapido()
);
}
}Testes Parametrizados
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParametrizadoTest {
// @ValueSource — lista simples de valores
@ParameterizedTest
@ValueSource(strings = {"racecar", "radar", "level", "anna"})
void deveIdentificarPalindromo(String palavra) {
assertTrue(Palindromo.verificar(palavra));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void deveTerValorPositivo(int numero) {
assertTrue(numero > 0);
}
// @NullSource / @EmptySource / @NullAndEmptySource
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void deveRejeitarEntradaInvalida(String entrada) {
assertThrows(IllegalArgumentException.class,
() -> new Nome(entrada));
}
// @CsvSource — múltiplos parâmetros por linha
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, -5, 5",
"0, 0, 0"
})
void deveSomarCorretamente(int a, int b, int esperado) {
assertEquals(esperado, Calculadora.somar(a, b));
}
// @CsvFileSource — lê de um arquivo CSV em src/test/resources
@ParameterizedTest
@CsvFileSource(resources = "/dados/calculos.csv", numLinesToSkip = 1)
void deveSomarCorretamenteDeArquivo(int a, int b, int esperado) {
assertEquals(esperado, Calculadora.somar(a, b));
}
// @EnumSource — itera sobre valores de um enum
@ParameterizedTest
@EnumSource(DiaSemana.class)
void todosDiasDaSemanaDevemTerNome(DiaSemana dia) {
assertNotNull(dia.getNomePt());
assertFalse(dia.getNomePt().isBlank());
}
// @EnumSource com filtro
@ParameterizedTest
@EnumSource(value = DiaSemana.class, names = {"SABADO", "DOMINGO"})
void finsDeSemanaDevemSerFeriados(DiaSemana dia) {
assertTrue(dia.isFimDeSemana());
}
// @MethodSource — usa método estático como fonte de dados
@ParameterizedTest
@MethodSource("fornecerCasosDeCalculoDesconto")
void deveCalcularDescontoCorretamente(BigDecimal preco, int qtd, BigDecimal esperado) {
assertEquals(esperado, Desconto.calcular(preco, qtd));
}
// Método provedor deve ser static e retornar Stream/Collection/Iterable
static Stream<Arguments> fornecerCasosDeCalculoDesconto() {
return Stream.of(
Arguments.of(new BigDecimal("100.00"), 1, new BigDecimal("100.00")),
Arguments.of(new BigDecimal("100.00"), 5, new BigDecimal("95.00")), // 5% desconto
Arguments.of(new BigDecimal("100.00"), 10, new BigDecimal("90.00")), // 10% desconto
Arguments.of(new BigDecimal("100.00"), 20, new BigDecimal("80.00")) // 20% desconto
);
}
}Assumptions
Assumptions pulam o teste quando uma condição não é atendida (ao contrário de assertions, que falham).
import static org.junit.jupiter.api.Assumptions.*;
class AssumptionsTest {
@Test
void deveRodarApenasEmCI() {
// Pula se a variável CI não estiver definida
assumeTrue("true".equals(System.getenv("CI")),
"Teste ignorado: não está rodando em CI");
// Código que só deve rodar em CI
}
@Test
void deveRodarApenasEmLinux() {
assumeTrue(System.getProperty("os.name").toLowerCase().contains("linux"),
"Teste ignorado: não é Linux");
}
@Test
void assumingQueServicoestaDisponivel() {
boolean servicoDisponivel = verificarServico("https://api.exemplo.com");
assumingThat(servicoDisponivel, () -> {
// Este bloco só executa se servicoDisponivel == true
// O teste não falha se a assumption não for atendida
var resposta = cliente.get("/health");
assertEquals(200, resposta.getStatus());
});
// Este código sempre executa
System.out.println("Teste concluído");
}
}JUnit 5 Extension API
// Criando uma extension customizada
public class TempDirectoryExtension
implements BeforeEachCallback, AfterEachCallback {
private Path tempDir;
@Override
public void beforeEach(ExtensionContext context) throws Exception {
tempDir = Files.createTempDirectory("teste-");
// Injeta o diretório no campo da classe de teste
context.getTestInstance()
.ifPresent(instance -> injectField(instance, tempDir));
}
@Override
public void afterEach(ExtensionContext context) throws Exception {
// Limpa o diretório temporário após cada teste
Files.walk(tempDir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
}
// Usando a extension
@ExtendWith(TempDirectoryExtension.class)
class FileProcessorTest {
@TempDir // Extension nativa do JUnit 5 para diretório temporário
Path tempDir;
@Test
void deveProcessarArquivo() throws IOException {
Path arquivo = tempDir.resolve("dados.txt");
Files.writeString(arquivo, "conteúdo do teste");
var resultado = FileProcessor.processar(arquivo);
assertEquals("CONTEÚDO DO TESTE", resultado);
}
}Mockito
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
// MockitoExtension inicializa @Mock, @InjectMocks, @Spy, @Captor automaticamente
@ExtendWith(MockitoExtension.class)
class PedidoServiceTest {
@Mock
EstoqueRepository estoqueRepository;
@Mock
PagamentoGateway pagamentoGateway;
@Mock
EmailService emailService;
// @InjectMocks cria a instância e injeta todos os @Mock declarados
@InjectMocks
PedidoService pedidoService;
// @Captor — captura argumentos passados para mocks
@Captor
ArgumentCaptor<Pedido> pedidoCaptor;
@Test
void deveCriarPedidoComSucesso() {
// ARRANGE — configura comportamento dos mocks
Produto produto = new Produto("P001", "Notebook", new BigDecimal("3000.00"));
when(estoqueRepository.findById("P001"))
.thenReturn(Optional.of(produto));
when(estoqueRepository.verificarDisponibilidade("P001", 2))
.thenReturn(true);
when(pagamentoGateway.processar(any(Pagamento.class)))
.thenReturn(new PagamentoResult("TXN-001", StatusPagamento.APROVADO));
// ACT
Pedido pedido = pedidoService.criar(new CriarPedidoRequest("P001", 2, "CARTAO"));
// ASSERT
assertNotNull(pedido);
assertEquals(StatusPedido.CONFIRMADO, pedido.getStatus());
assertEquals(new BigDecimal("6000.00"), pedido.getTotal());
// Verifica que o email foi enviado uma vez
verify(emailService, times(1)).enviarConfirmacao(any(Pedido.class));
// Verifica que estoque foi decrementado
verify(estoqueRepository).decrementarEstoque("P001", 2);
}
@Test
void deveLancarExcecaoQuandoEstoqueInsuficiente() {
when(estoqueRepository.verificarDisponibilidade("P001", 100))
.thenReturn(false);
assertThrows(EstoqueInsuficienteException.class,
() -> pedidoService.criar(new CriarPedidoRequest("P001", 100, "CARTAO")));
// Verifica que pagamento NUNCA foi chamado
verify(pagamentoGateway, never()).processar(any());
verify(emailService, never()).enviarConfirmacao(any());
}
@Test
void deveCaptularPedidoEnviadoPorEmail() {
// Setup...
when(estoqueRepository.findById(anyString()))
.thenReturn(Optional.of(new Produto("P001", "Mouse", new BigDecimal("150.00"))));
when(estoqueRepository.verificarDisponibilidade(anyString(), anyInt()))
.thenReturn(true);
when(pagamentoGateway.processar(any()))
.thenReturn(new PagamentoResult("TXN-002", StatusPagamento.APROVADO));
pedidoService.criar(new CriarPedidoRequest("P001", 1, "PIX"));
// Captura o argumento passado para o emailService
verify(emailService).enviarConfirmacao(pedidoCaptor.capture());
Pedido pedidoEnviado = pedidoCaptor.getValue();
assertEquals("P001", pedidoEnviado.getProdutoId());
assertEquals(1, pedidoEnviado.getQuantidade());
}
}@Spy — Partial Mock
@ExtendWith(MockitoExtension.class)
class NotificacaoServiceTest {
// @Spy — cria instância real mas permite interceptar métodos específicos
@Spy
NotificacaoService notificacaoService = new NotificacaoService();
@Test
void deveUsarImplementacaoRealMasInterceptarEnvio() {
// Intercepta apenas o método de envio HTTP
doReturn(true).when(notificacaoService).enviarViaHttp(anyString());
// O método formatarMensagem() executa de verdade
boolean resultado = notificacaoService.notificar("user@example.com", "Olá");
assertTrue(resultado);
verify(notificacaoService).enviarViaHttp(contains("Olá"));
}
}doThrow e comportamento condicional
@Test
void deveTratarFalhaNoGatewayDePagamento() {
when(estoqueRepository.findById("P001"))
.thenReturn(Optional.of(produto));
when(estoqueRepository.verificarDisponibilidade(anyString(), anyInt()))
.thenReturn(true);
// doThrow para métodos void ou quando 'when' não funciona
doThrow(new GatewayException("Timeout"))
.when(pagamentoGateway).processar(any());
assertThrows(PagamentoException.class,
() -> pedidoService.criar(request));
}
@Test
void deveRetornarValoresDiferentesACadaChamada() {
// thenReturn com múltiplos valores — cada chamada retorna o próximo
when(estoqueRepository.contarDisponivel("P001"))
.thenReturn(10) // 1ª chamada
.thenReturn(9) // 2ª chamada
.thenReturn(8); // 3ª e demais chamadas
assertEquals(10, estoqueRepository.contarDisponivel("P001"));
assertEquals(9, estoqueRepository.contarDisponivel("P001"));
assertEquals(8, estoqueRepository.contarDisponivel("P001"));
assertEquals(8, estoqueRepository.contarDisponivel("P001")); // repete o último
}ArgumentMatchers
// Matchers mais comuns
when(repo.findByNomeContaining(anyString())).thenReturn(List.of());
when(repo.findById(eq("id-especifico"))).thenReturn(Optional.empty());
when(service.calcular(any(BigDecimal.class), intThat(n -> n > 0))).thenReturn(result);
// Matchers em verify
verify(emailService).enviar(
argThat(email -> email.getDestinatario().contains("@empresa.com")),
contains("Confirmação")
);
// Verificações de número de chamadas
verify(repo, times(3)).save(any()); // exatamente 3 vezes
verify(repo, atLeast(1)).save(any()); // pelo menos 1 vez
verify(repo, atMost(5)).save(any()); // no máximo 5 vezes
verify(repo, never()).delete(any()); // nunca
verifyNoMoreInteractions(emailService); // sem outras interações
verifyNoInteractions(auditLog); // zero interaçõesAssertJ
AssertJ oferece assertions fluentes e mais legíveis que as nativas do JUnit.
import static org.assertj.core.api.Assertions.*;
class AssertJTest {
@Test
void demonstrandoAssertJBasico() {
// String assertions
assertThat("Hello, World!")
.isNotNull()
.isNotBlank()
.startsWith("Hello")
.endsWith("!")
.contains("World")
.hasSize(13)
.isEqualToIgnoringCase("hello, world!");
// Number assertions
assertThat(42)
.isPositive()
.isGreaterThan(10)
.isLessThanOrEqualTo(100)
.isBetween(1, 100);
// Collection assertions
List<String> frutas = List.of("maçã", "banana", "laranja");
assertThat(frutas)
.isNotEmpty()
.hasSize(3)
.contains("banana")
.containsExactly("maçã", "banana", "laranja") // ordem exata
.doesNotContain("uva")
.allMatch(f -> f.length() > 2); // condição para todos
// Map assertions
Map<String, Integer> scores = Map.of("Alice", 95, "Bob", 87);
assertThat(scores)
.isNotEmpty()
.containsKey("Alice")
.containsEntry("Bob", 87)
.doesNotContainKey("Charlie");
}
@Test
void demonstrandoAssertJObjetos() {
Produto produto = new Produto("P001", "Notebook", new BigDecimal("3000.00"));
// usingRecursiveComparison — compara campos recursivamente (ignora equals())
Produto esperado = new Produto("P001", "Notebook", new BigDecimal("3000.00"));
assertThat(produto)
.usingRecursiveComparison()
.isEqualTo(esperado);
// extracting — extrai campos para verificação
List<Produto> produtos = List.of(
new Produto("P001", "Notebook", new BigDecimal("3000.00")),
new Produto("P002", "Mouse", new BigDecimal("150.00"))
);
assertThat(produtos)
.extracting(Produto::getNome)
.containsExactlyInAnyOrder("Mouse", "Notebook");
// extracting com múltiplos campos (tuplas)
assertThat(produtos)
.extracting(Produto::getId, Produto::getNome)
.containsExactly(
tuple("P001", "Notebook"),
tuple("P002", "Mouse")
);
}
@Test
void demonstrandoAssertJExcecoes() {
// assertThatThrownBy — verificações fluentes na exceção
assertThatThrownBy(() -> new Produto(null, new BigDecimal("100")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Nome não pode ser nulo")
.hasNoCause();
// assertThatCode — verifica que não lança exceção
assertThatCode(() -> new Produto("Mouse", new BigDecimal("100")))
.doesNotThrowAnyException();
// assertThatExceptionOfType — sintaxe alternativa
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new Produto(null, BigDecimal.ONE))
.withMessage("Nome não pode ser nulo")
.withNoCause();
}
}Testes de Integração com Spring Boot
@SpringBootTest
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.beans.factory.annotation.Autowired;
// Sobe o contexto Spring completo
@SpringBootTest
@ActiveProfiles("test") // usa application-test.properties
class PedidoIntegrationTest {
@Autowired
PedidoService pedidoService;
@Autowired
PedidoRepository pedidoRepository;
@Test
@Transactional // rollback automático após o teste
void devePersistirPedidoNoBanco() {
var request = new CriarPedidoRequest("P001", 2, "PIX");
Pedido pedido = pedidoService.criar(request);
assertThat(pedido.getId()).isNotNull();
// Busca direto no banco para confirmar persistência
Optional<Pedido> salvo = pedidoRepository.findById(pedido.getId());
assertThat(salvo).isPresent();
assertThat(salvo.get().getStatus()).isEqualTo(StatusPedido.CONFIRMADO);
}
}@WebMvcTest — Teste de Controllers
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.boot.test.mock.mockito.MockBean;
// Sobe apenas a camada web (Controllers, Filters, etc.)
// Muito mais rápido que @SpringBootTest
@WebMvcTest(PedidoController.class)
class PedidoControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean // Mock Spring-managed — substituído no contexto de aplicação
PedidoService pedidoService;
@Autowired
ObjectMapper objectMapper;
@Test
void deveCriarPedidoComSucesso() throws Exception {
var request = new CriarPedidoRequest("P001", 2, "PIX");
var pedidoRetornado = new Pedido("PED-001", StatusPedido.CONFIRMADO);
when(pedidoService.criar(any(CriarPedidoRequest.class)))
.thenReturn(pedidoRetornado);
mockMvc.perform(
post("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/pedidos/PED-001")))
.andExpect(jsonPath("$.id").value("PED-001"))
.andExpect(jsonPath("$.status").value("CONFIRMADO"));
}
@Test
void deveRetornar400ParaRequestInvalido() throws Exception {
var requestInvalido = new CriarPedidoRequest(null, -1, "");
mockMvc.perform(
post("/api/v1/pedidos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestInvalido))
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors", hasSize(greaterThan(0))));
}
@Test
void deveRetornar404QuandoPedidoNaoEncontrado() throws Exception {
when(pedidoService.buscarPorId("ID-INVALIDO"))
.thenThrow(new PedidoNaoEncontradoException("ID-INVALIDO"));
mockMvc.perform(get("/api/v1/pedidos/ID-INVALIDO"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").exists());
}
}@DataJpaTest — Teste de Repositórios
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
// Configura banco H2 em memória, carrega apenas camada JPA
@DataJpaTest
class PedidoRepositoryTest {
@Autowired
TestEntityManager em; // Helper para persistir dados de teste
@Autowired
PedidoRepository pedidoRepository;
@Test
void deveBuscarPedidosPorStatus() {
// Arrange — persiste dados de teste
var p1 = em.persistAndFlush(new Pedido("CLIENTE-1", StatusPedido.CONFIRMADO));
var p2 = em.persistAndFlush(new Pedido("CLIENTE-2", StatusPedido.CANCELADO));
var p3 = em.persistAndFlush(new Pedido("CLIENTE-3", StatusPedido.CONFIRMADO));
// Act
List<Pedido> confirmados = pedidoRepository.findByStatus(StatusPedido.CONFIRMADO);
// Assert
assertThat(confirmados)
.hasSize(2)
.extracting(Pedido::getId)
.containsExactlyInAnyOrder(p1.getId(), p3.getId());
}
@Test
void deveBuscarPedidosDoClienteNoPeriodo() {
LocalDateTime inicio = LocalDateTime.now().minusDays(7);
LocalDateTime fim = LocalDateTime.now();
var pedidoRecente = em.persistAndFlush(
new Pedido("CLI-001", StatusPedido.CONFIRMADO, LocalDateTime.now().minusDays(3))
);
var pedidoAntigo = em.persistAndFlush(
new Pedido("CLI-001", StatusPedido.CONFIRMADO, LocalDateTime.now().minusDays(30))
);
List<Pedido> resultado = pedidoRepository
.findByClienteIdAndDataCriacaoBetween("CLI-001", inicio, fim);
assertThat(resultado).hasSize(1);
assertThat(resultado.get(0).getId()).isEqualTo(pedidoRecente.getId());
}
}TestRestTemplate — Testes de API HTTP
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PedidoApiTest {
@Autowired
TestRestTemplate restTemplate;
@LocalServerPort
int port;
@Test
void deveRetornarListaDePedidos() {
ResponseEntity<List<PedidoResponse>> resposta = restTemplate.exchange(
"/api/v1/pedidos",
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {}
);
assertThat(resposta.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resposta.getBody()).isNotNull();
}
@Test
void deveCriarERecuperarPedido() {
var request = new CriarPedidoRequest("P001", 1, "PIX");
ResponseEntity<PedidoResponse> criacao = restTemplate.postForEntity(
"/api/v1/pedidos",
request,
PedidoResponse.class
);
assertThat(criacao.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String id = criacao.getBody().getId();
ResponseEntity<PedidoResponse> busca = restTemplate.getForEntity(
"/api/v1/pedidos/" + id,
PedidoResponse.class
);
assertThat(busca.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(busca.getBody().getId()).isEqualTo(id);
}
}Testcontainers
Testcontainers sobe containers Docker reais durante os testes — banco real, Redis real, Kafka real.
<!-- pom.xml -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Testcontainers
class PedidoRepositoryIntegrationTest {
// Container compartilhado entre todos os testes da classe
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// Configura Spring para usar o container
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
PedidoRepository repository;
@Test
void devePersistirERecuperarPedido() {
var pedido = new Pedido("CLI-001", StatusPedido.PENDENTE);
var salvo = repository.save(pedido);
var recuperado = repository.findById(salvo.getId());
assertThat(recuperado).isPresent();
assertThat(recuperado.get().getClienteId()).isEqualTo("CLI-001");
}
}Reutilizando Containers (Singleton Pattern)
// Classe base compartilhada entre múltiplos testes de integração
// O container inicia apenas uma vez para toda a suíte de testes
public abstract class BaseIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final GenericContainer<?> REDIS;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // reutiliza container entre execuções (dev local)
REDIS = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
POSTGRES.start();
REDIS.start();
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.redis.host", REDIS::getHost);
registry.add("spring.redis.port", () -> REDIS.getMappedPort(6379));
}
}
// Herda a configuração base
@SpringBootTest
class PedidoServiceIntegrationTest extends BaseIntegrationTest {
@Autowired
PedidoService pedidoService;
@Test
@Sql("/sql/pedidos-test-data.sql") // carrega dados de teste via SQL
void deveBuscarPedidoComCache() {
// Primeira chamada — vai ao banco
var pedido1 = pedidoService.buscarPorId("PED-001");
// Segunda chamada — deve vir do cache Redis
var pedido2 = pedidoService.buscarPorId("PED-001");
assertThat(pedido1).usingRecursiveComparison().isEqualTo(pedido2);
}
}Coverage com JaCoCo
<!-- pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<!-- Prepara o agente JaCoCo antes dos testes -->
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<!-- Gera relatório após os testes -->
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- Falha o build se coverage mínimo não for atingido -->
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- 80% mínimo -->
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
<configuration>
<!-- Exclui classes sem lógica de negócio do coverage -->
<excludes>
<exclude>**/dto/**</exclude>
<exclude>**/config/**</exclude>
<exclude>**/*Application.class</exclude>
<exclude>**/exception/*Exception.class</exclude>
</excludes>
</configuration>
</plugin># Gera relatório HTML em target/site/jacoco/index.html
mvn test jacoco:report
# Falha se coverage abaixo do mínimo configurado
mvn verify
# Apenas testes unitários (rápidos)
mvn test
# Testes unitários + integração
mvn verify -P integration-testsEstrutura de Projeto e Boas Práticas
Estrutura de Diretórios
src/
├── main/
│ └── java/com/empresa/app/
│ ├── domain/
│ │ ├── Pedido.java
│ │ └── PedidoService.java
│ ├── infrastructure/
│ │ └── PedidoRepository.java
│ └── web/
│ └── PedidoController.java
└── test/
├── java/com/empresa/app/
│ ├── domain/
│ │ ├── PedidoServiceTest.java # teste unitário
│ │ └── PedidoTest.java # teste de entidade
│ ├── infrastructure/
│ │ └── PedidoRepositoryTest.java # @DataJpaTest
│ └── web/
│ ├── PedidoControllerTest.java # @WebMvcTest
│ └── PedidoApiTest.java # @SpringBootTest (e2e)
└── resources/
├── application-test.properties
└── sql/
└── pedidos-test-data.sqlConvenções e Padrões
// Nomenclatura: deve<Comportamento>Quando<Condicao>
// ou: <metodo>_<cenario>_<resultado>
void deveLancarExcecaoQuandoSaldoInsuficiente() { }
void criar_pedidoValido_retornaPedidoConfirmado() { }
// Padrão AAA — Arrange, Act, Assert
@Test
void deveAplicarDescontoParaClienteVip() {
// Arrange — prepara o estado
Cliente cliente = new Cliente("CLI-001", TipoCliente.VIP);
Pedido pedido = new Pedido(cliente, new BigDecimal("1000.00"));
// Act — executa a ação testada
BigDecimal total = pedido.calcularTotal();
// Assert — verifica o resultado
assertThat(total).isEqualByComparingTo("900.00"); // 10% desconto VIP
}
// Um assert por comportamento (não por linha)
// Testes devem ser independentes e deterministicos
// Evite lógica condicional em testes (if/for dentro do teste)
// Prefira dados de teste explícitos a fixtures globais mutáveis
// Use @Sql para dados de integração, Builders/Factories para unitários
// Test Data Builder — facilita criação de objetos de teste
class PedidoBuilder {
private String clienteId = "CLI-DEFAULT";
private StatusPedido status = StatusPedido.PENDENTE;
private BigDecimal valor = new BigDecimal("100.00");
public static PedidoBuilder umPedido() { return new PedidoBuilder(); }
public PedidoBuilder doCliente(String id) { this.clienteId = id; return this; }
public PedidoBuilder comStatus(StatusPedido s) { this.status = s; return this; }
public PedidoBuilder noValor(String v) { this.valor = new BigDecimal(v); return this; }
public Pedido build() { return new Pedido(clienteId, status, valor); }
}
// Uso
Pedido pedido = umPedido()
.doCliente("CLI-VIP")
.noValor("5000.00")
.build();Checklist de Boas Práticas
✓ Cada teste valida um único comportamento
✓ Nomes descritivos: deve<Comportamento>Quando<Cenario>
✓ Padrão AAA (Arrange-Act-Assert) em todos os testes
✓ Testes isolados — sem dependência de ordem ou estado compartilhado
✓ Mocks apenas para dependências externas (banco, APIs, email)
✓ @DataJpaTest para repositórios, @WebMvcTest para controllers
✓ Testcontainers para testes de integração com banco real
✓ Coverage mínimo de 80% nas linhas e 70% nos branches
✓ Use @DisplayName para descrever o comportamento esperado
✓ Use @Nested para agrupar cenários relacionados
✓ Test Data Builder para objetos complexos de teste
✓ @Transactional nos testes de integração para rollback automático
✓ Separe testes unitários (rápidos) de integração (lentos)