Testes

JUnit 5

Guia completo de testes em Java com JUnit 5, Mockito, AssertJ, Spring Boot Test e Testcontainers

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ísticaJUnit 4JUnit 5
Módulo únicojunit-4.x.jar3 módulos: Platform, Jupiter, Vintage
Pacote principalorg.junitorg.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)
Assertionsorg.junit.Assertorg.junit.jupiter.api.Assertions
Java mínimoJava 5Java 8
@Nestednão existesuportado nativamente
Testes parametrizadosvia @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ções

AssertJ

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-tests

Estrutura 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.sql

Convençõ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)