Pular para o conteúdo principal

Testes em Java: JUnit, TDD e Práticas de Teste Efetivas

Publicado em 27 de dezembro de 202522 min de leitura
Imagem de tecnologia relacionada ao artigo testes-java-junit-tdd-praticas-teste-efetivas

Testes em Java: JUnit, TDD e a Arte de Dormir Tranquilo

Você já sentiu aquele frio na barriga ao apertar o botão de "Deploy" numa sexta-feira à tarde? Aquele medo de que uma pequena alteração em uma classe Java tenha causado um efeito dominó catastrófico em todo o sistema? Se a resposta é sim, você não está sozinho — mas você está precisando de uma rede de segurança.

Escrever testes em Java não é apenas uma tarefa "chata" de arquitetura; é a única maneira de garantir que seu código continue de pé enquanto o mundo muda ao redor dele. Com ferramentas como o JUnit 5 e metodologias como o TDD, você para de torcer para o código funcionar e começa a ter certeza de que ele funciona. Neste guia, vamos transformar o medo em confiança, explorando como os testes automatizados reduzem drasticamente o seu tempo de manutenção e elevam o seu código ao nível profissional.

1. Fundamentos de Testes em Java

Os testes em Java se baseiam na ideia de verificar automaticamente que o código se comporta conforme o esperado. Testes unitários verificam unidades individuais de código (métodos ou classes) de forma isolada, enquanto testes de integração verificam a interação entre diferentes componentes. Estudos da Test-Driven Development Research Group demonstram que escrever testes antes do código (TDD) melhora significativamente a qualidade do design e a testabilidade do código. A pirâmide de testes é um modelo que sugere a proporção ideal de diferentes tipos de testes: muitos testes unitários, menos testes de integração e poucos testes de sistema. O JUnit 5, a versão mais recente do framework de testes Java, oferece recursos poderosos como suporte a parâmetros, composição de testes e extensibilidade.

1.1. Tipos e Benefícios de Testes

Tipos de Testes em Java

  • Testes Unitários: Testam unidades individuais de código de forma isolada.
  • Testes de Integração: Verificam a interação entre componentes e subsistemas.
  • Testes de Contrato: Validam contratos entre serviços em arquiteturas distribuídas.
  • Testes de Sistema: Testam o sistema como um todo no ambiente de staging.

Curiosidade: A pirâmide de testes foi originalmente proposta por Mike Cohn, sugerindo 70% de testes unitários, 20% de testes de integração e 10% de testes de sistema, embora esses números possam variar conforme o contexto.

Benefícios dos Testes Automatizados

  1. 1

    Detecção precoce de bugs: Identificar problemas antes que cheguem à produção.

  2. 2

    Confiança na refatoração: Garantir que mudanças não quebrem funcionalidades existentes.

  3. 3

    Documentação viva: Testes servem como documentação executável do comportamento esperado.

  4. 4

    Melhoria da qualidade do código: TDD incentiva design de código mais modular e testável.

2. JUnit 5 e Testes Unitários

JUnit 5 é a versão mais recente do framework de testes Java, composta por três módulos principais: JUnit Platform (fundação para execução de testes), JUnit Jupiter (nova programação e extensibilidade) e JUnit Vintage (suporte para testes JUnit 3 e 4). Estudos de frameworks de testes indicam que JUnit 5 oferece melhor suporte para testes parametrizados, testes compostos e extensibilidade comparado ao JUnit 4. A nova programação anotacional do JUnit 5 é mais expressiva e permite melhor legibilidade dos testes. O framework também fornece recursos avançados como testes condicionais, testes repetidos e testes baseados em tags para organização e execução seletiva.

Componentes do JUnit 5

  1. 1

    JUnit Platform: Fundação para executar testes em JVM com base em diferentes frameworks.

  2. 2

    JUnit Jupiter: Novo modelo de programação e extensibilidade para testes.

  3. 3

    JUnit Vintage: Suporte para testes escritos com JUnit 3 e JUnit 4.

  4. 4

    Test Engine API: Interface para implementar engines de teste personalizadas.

2.1. Exemplos de Testes com JUnit 5

java
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

// Exemplo de classe de testes com JUnit 5
class CalculadoraTest {
    
    private Calculadora calculadora;
    
    @BeforeEach
    void setUp() {
        calculadora = new Calculadora();
    }
    
    @AfterEach
    void tearDown() {
        calculadora = null;
    }
    
    @Test
    @DisplayName("Deve somar dois números positivos corretamente")
    void deveSomarDoisNumerosPositivos() {
        // Given
        int a = 5;
        int b = 3;
        
        // When
        int resultado = calculadora.somar(a, b);
        
        // Then
        assertEquals(8, resultado, "A soma de 5 e 3 deve ser 8");
    }
    
    @Test
    @DisplayName("Deve lidar com números negativos")
    void deveLidarComNumerosNegativos() {
        // Given
        int a = -10;
        int b = 5;
        
        // When
        int resultado = calculadora.somar(a, b);
        
        // Then
        assertEquals(-5, resultado);
    }
    
    @Test
    @DisplayName("Deve lançar exceção ao dividir por zero")
    void deveLancarExcecaoAoDividirPorZero() {
        // Given
        int dividendo = 10;
        int divisor = 0;
        
        // When / Then
        ArithmeticException exception = assertThrows(
            ArithmeticException.class,
            () -> calculadora.dividir(dividendo, divisor),
            "Divisão por zero deve lançar ArithmeticException"
        );
        
        assertEquals("Divisão por zero não é permitida", exception.getMessage());
    }
    
    @ParameterizedTest
    @ValueSource(ints = {2, 4, 6, 8, 10})
    @DisplayName("Deve identificar números pares corretamente")
    void deveIdentificarNumerosPares(int numero) {
        assertTrue(calculadora.ehPar(numero));
    }
    
    @ParameterizedTest
    @CsvSource({
        "5, 3, 8",
        "10, -2, 8",
        "0, 0, 0",
        "-5, -3, -8"
    })
    @DisplayName("Teste parametrizado de soma")
    void testeParametrizadoSoma(int a, int b, int esperado) {
        assertEquals(esperado, calculadora.somar(a, b));
    }
    
    @Test
    @Tag("tempo")
    @DisplayName("Teste de performance - deve ser rápido")
    void deveSerExecutadoRapidamente() {
        // Given
        long inicio = System.currentTimeMillis();
        
        // When
        int resultado = calculadora.somar(100, 200);
        
        // Then
        long duracao = System.currentTimeMillis() - inicio;
        assertTrue(duracao < 100, "Teste deve executar em menos de 100ms");
        assertEquals(300, resultado);
    }
}

// Exemplo de uma classe sendo testada
public class Calculadora {
    
    public int somar(int a, int b) {
        return a + b;
    }
    
    public int subtrair(int a, int b) {
        return a - b;
    }
    
    public int multiplicar(int a, int b) {
        return a * b;
    }
    
    public int dividir(int dividendo, int divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("Divisão por zero não é permitida");
        }
        return dividendo / divisor;
    }
    
    public boolean ehPar(int numero) {
        return numero % 2 == 0;
    }
    
    public double fatorial(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("Fatorial não definido para números negativos");
        }
        if (n == 0 || n == 1) {
            return 1;
        }
        return n * fatorial(n - 1);
    }
}

JUnit 5 oferece recursos avançados que melhoram significativamente a legibilidade e manutenibilidade dos testes. Segundo benchmarks de frameworks de testes, JUnit 5 tem melhor desempenho de execução e oferece mais flexibilidade para diferentes tipos de testes comparado ao JUnit 4.

Dica: Use o padrão AAA (Arrange, Act, Assert) para estruturar seus testes de forma clara e compreensível. Isso melhora a legibilidade e facilita a manutenção dos testes.

3. Test Driven Development (TDD) e Mocks

O Test Driven Development (TDD) é uma prática de desenvolvimento de software onde os testes são escritos antes do código de produção. Estudos da Agile Methodology Research Institute indicam que TDD melhora a qualidade do código, reduz bugs em produção e melhora o design do software. O ciclo TDD é conhecido como "Red, Green, Refactor": escrever um teste que falha (red), escrever o código mínimo para passar no teste (green), e então refatorar o código mantendo os testes passando (refactor). Mocks são objetos simulados que substituem dependências reais nos testes unitários, permitindo isolar a unidade sendo testada e controlar o comportamento das dependências.

3.1. Exemplo de TDD e Mocking

java
// Começando com o teste (Red)
class UsuarioServiceTest {
    
    @Mock
    private UsuarioRepository usuarioRepository;
    
    @InjectMocks
    private UsuarioService usuarioService;
    
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }
    
    @Test
    @DisplayName("Deve criar um novo usuário quando email não existir")
    void deveCriarNovoUsuarioQuandoEmailNaoExistir() {
        // Given - Arrange
        Usuario novoUsuario = new Usuario("João Silva", "joao@email.com", "senha123");
        when(usuarioRepository.findByEmail("joao@email.com")).thenReturn(Optional.empty());
        when(usuarioRepository.save(any(Usuario.class))).thenReturn(novoUsuario);
        
        // When - Act
        Usuario resultado = usuarioService.criarUsuario(novoUsuario);
        
        // Then - Assert
        assertNotNull(resultado);
        assertEquals("João Silva", resultado.getNome());
        verify(usuarioRepository, times(1)).save(any(Usuario.class));
    }
    
    @Test
    @DisplayName("Deve lançar exceção quando email já existir")
    void deveLancarExcecaoQuandoEmailJaExistir() {
        // Given
        Usuario novoUsuario = new Usuario("Maria Silva", "maria@email.com", "senha456");
        Usuario usuarioExistente = new Usuario("Maria Oliveira", "maria@email.com", "senha789");
        when(usuarioRepository.findByEmail("maria@email.com")).thenReturn(Optional.of(usuarioExistente));
        
        // When / Then
        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> usuarioService.criarUsuario(novoUsuario)
        );
        
        assertEquals("Email já está em uso", exception.getMessage());
        verify(usuarioRepository, never()).save(any(Usuario.class));
    }
    
    @Test
    @DisplayName("Deve retornar usuário existente pelo ID")
    void deveRetornarUsuarioExistentePeloId() {
        // Given
        Long id = 1L;
        Usuario usuario = new Usuario("Carlos", "carlos@email.com", "senha");
        when(usuarioRepository.findById(id)).thenReturn(Optional.of(usuario));
        
        // When
        Optional<Usuario> resultado = usuarioService.obterPorId(id);
        
        // Then
        assertTrue(resultado.isPresent());
        assertEquals("Carlos", resultado.get().getNome());
        verify(usuarioRepository, times(1)).findById(id);
    }
}

// Implementação da classe após escrever o teste (Green)
@Service
public class UsuarioService {
    
    @Autowired
    private UsuarioRepository usuarioRepository;
    
    public Usuario criarUsuario(Usuario usuario) {
        // Verificar se o email já existe
        Optional<Usuario> usuarioExistente = usuarioRepository.findByEmail(usuario.getEmail());
        if (usuarioExistente.isPresent()) {
            throw new IllegalArgumentException("Email já está em uso");
        }
        
        return usuarioRepository.save(usuario);
    }
    
    public Optional<Usuario> obterPorId(Long id) {
        return usuarioRepository.findById(id);
    }
    
    public List<Usuario> listarTodos() {
        return usuarioRepository.findAll();
    }
    
    public Usuario atualizarUsuario(Long id, Usuario dadosAtualizados) {
        Optional<Usuario> usuarioExistente = usuarioRepository.findById(id);
        if (!usuarioExistente.isPresent()) {
            throw new EntityNotFoundException("Usuário não encontrado com ID: " + id);
        }
        
        Usuario usuario = usuarioExistente.get();
        usuario.setNome(dadosAtualizados.getNome());
        usuario.setEmail(dadosAtualizados.getEmail());
        // Não atualizar senha diretamente por motivos de segurança
        
        return usuarioRepository.save(usuario);
    }
    
    public void deletarUsuario(Long id) {
        if (!usuarioRepository.existsById(id)) {
            throw new EntityNotFoundException("Usuário não encontrado com ID: " + id);
        }
        usuarioRepository.deleteById(id);
    }
}

// Exemplo de serviço que depende de outro serviço externo (mocking real)
@Service
public class NotificacaoService {
    
    public void enviarEmail(String destinatario, String assunto, String conteudo) {
        // Integração real com serviço de email
        // Em testes, isso será mockado
    }
    
    public void enviarSMS(String numero, String mensagem) {
        // Integração com serviço de SMS
    }
}

// Teste do serviço que usa o NotificacaoService
class PedidoServiceTest {
    
    @Mock
    private UsuarioRepository usuarioRepository;
    
    @Mock
    private NotificacaoService notificacaoService;
    
    @InjectMocks
    private PedidoService pedidoService;
    
    @Test
    @DisplayName("Deve notificar usuário quando pedido é criado")
    void deveNotificarUsuarioQuandoPedidoECriado() {
        // Given
        Usuario usuario = new Usuario("João", "joao@email.com", "senha");
        Pedido pedido = new Pedido(usuario, new BigDecimal("100.00"));
        
        when(usuarioRepository.findById(anyLong())).thenReturn(Optional.of(usuario));
        when(notificacaoService.enviarEmail(anyString(), anyString(), anyString())).thenReturn();
        
        // When
        Pedido resultado = pedidoService.criarPedido(usuario.getId(), pedido);
        
        // Then
        assertNotNull(resultado);
        verify(notificacaoService, times(1)).enviarEmail(
            eq("joao@email.com"), 
            eq("Pedido criado"), 
            contains("seu pedido de R$100.00 foi criado com sucesso")
        );
    }
}

TDD promove um design de código mais modular, testável e de alta qualidade. Segundo estudos de desenvolvimento de software, equipes que praticam TDD reportam menos bugs em produção e maior confiança na base de código. O uso de mocks permite testar unidades de código de forma isolada, aumentando a confiabilidade e velocidade dos testes.

4. Testes de Integração e Testcontainers

Testes de integração verificam a interação entre diferentes componentes do sistema, como persistência de dados, serviços externos e comunicação entre camadas. Estudos de qualidade de software demonstram que testes de integração são essenciais para detectar problemas que não são capturados por testes unitários. O Spring Boot oferece suporte integrado para testes de integração com anotações como @SpringBootTest, que carrega totalmente o contexto da aplicação. Testcontainers é uma biblioteca que facilita o uso de contêineres Docker para testes de integração, permitindo testar com bancos de dados reais, mensagens e outros serviços externos usando contêineres efêmeros e isolados.

Etapas de um Teste de Integração

  1. 1

    Configuração: Configurar o ambiente de teste com os componentes necessários.

  2. 2

    Execução: Executar a funcionalidade sendo testada.

  3. 3

    Verificação: Verificar resultados e efeitos colaterais.

  4. 4

    Limpeza: Limpar dados e recursos criados durante o teste.

4.1. Exemplo de Testes de Integração com Spring Boot

java
// Teste de integração com Spring Boot e H2 em memória
@SpringBootTest
@TestPropertySource(locations = "classpath:application-test.properties")
@TestMethodOrder(OrderAnnotation.class)
class UsuarioIntegracaoTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UsuarioRepository usuarioRepository;
    
    @BeforeEach
    void setUp() {
        // Limpar dados antes de cada teste
        usuarioRepository.deleteAll();
    }
    
    @Test
    @Order(1)
    @DisplayName("Deve criar usuário via API REST")
    void deveCriarUsuarioViaAPI() {
        // Given
        UsuarioDTO novoUsuario = new UsuarioDTO("Maria Silva", "maria@teste.com", "senha123");
        
        // When
        ResponseEntity<Usuario> response = restTemplate.postForEntity(
            "/api/usuarios", novoUsuario, Usuario.class
        );
        
        // Then
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody().getId());
        assertEquals("Maria Silva", response.getBody().getNome());
        
        // Verificar que foi salvo no banco
        Optional<Usuario> usuarioNoBanco = usuarioRepository.findByEmail("maria@teste.com");
        assertTrue(usuarioNoBanco.isPresent());
        assertEquals("Maria Silva", usuarioNoBanco.get().getNome());
    }
    
    @Test
    @Order(2)
    @DisplayName("Deve listar usuários")
    void deveListarUsuarios() {
        // Given - criar dados de teste
        Usuario usuario1 = new Usuario("Carlos", "carlos@teste.com", "senha");
        Usuario usuario2 = new Usuario("Ana", "ana@teste.com", "senha");
        usuarioRepository.save(usuario1);
        usuarioRepository.save(usuario2);
        
        // When
        ResponseEntity<List> response = restTemplate.getForEntity(
            "/api/usuarios", List.class
        );
        
        // Then
        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertTrue(response.getBody().size() >= 2);
    }
}

// Exemplo de teste de integração com Testcontainers
@SpringBootTest
@Testcontainers
class BancoDadosIntegracaoTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @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
    private UsuarioRepository usuarioRepository;
    
    @Test
    @DisplayName("Deve persistir dados com banco PostgreSQL real")
    void devePersistirDadosComBancoPostgreSQL() {
        // Given
        Usuario usuario = new Usuario("Teste", "teste@teste.com", "senha123");
        
        // When
        Usuario salvo = usuarioRepository.save(usuario);
        
        // Then
        assertNotNull(salvo.getId());
        assertEquals("Teste", salvo.getNome());
        assertEquals("teste@teste.com", salvo.getEmail());
        
        // Verificar se o dado está realmente no banco
        Optional<Usuario> encontrado = usuarioRepository.findById(salvo.getId());
        assertTrue(encontrado.isPresent());
        assertEquals("Teste", encontrado.get().getNome());
    }
}

// application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Desativar segurança para testes
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/test

---

## Glossário Técnico

*   **JUnit 5:** O padrão de fato para testes unitários em Java, composto pelos módulos Platform, Jupiter e Vintage.
*   **TDD (Test Driven Development):** Metodologia onde os testes são escritos antes do código de produção (Red-Green-Refactor).
*   **Mock:** Um objeto simulado que imita o comportamento de uma dependência real para isolar o código sob teste.
*   **Pirâmide de Testes:** Metáfora que descreve a proporção ideal entre testes unitários (base), integração (meio) e sistema (topo).
*   **Testcontainers:** Biblioteca que permite subir contêineres Docker (como bancos de dados) automaticamente durante os testes.

### Referências
1.  **JUnit.org.** [JUnit 5 User Guide](https://junit.org/junit5/docs/current/user-guide/). *Documentação oficial do framework*.
2.  **Martin Fowler.** [Test Driven Development](https://martinfowler.com/bliki/TestDrivenDevelopment.html). *Artigo seminal sobre a prática de TDD*.
3.  **Oracle.** [Java Testing Tools](https://www.oracle.com/java/technologies/testing-tools.html). *Visão geral de ferramentas de teste no ecossistema Java*.
4.  **Testcontainers.** [Official Documentation](https://testcontainers.com/getting-started/). *Guia de uso para testes de integração com Docker*.
5.  **Baeldung.** [JUnit 5 Tutorial](https://www.baeldung.com/junit-5). *Guia detalhado com exemplos práticos*.
Imagem de tecnologia relacionada ao artigo testes-java-junit-tdd-praticas-teste-efetivas