
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
Detecção precoce de bugs: Identificar problemas antes que cheguem à produção.
- 2
Confiança na refatoração: Garantir que mudanças não quebrem funcionalidades existentes.
- 3
Documentação viva: Testes servem como documentação executável do comportamento esperado.
- 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
JUnit Platform: Fundação para executar testes em JVM com base em diferentes frameworks.
- 2
JUnit Jupiter: Novo modelo de programação e extensibilidade para testes.
- 3
JUnit Vintage: Suporte para testes escritos com JUnit 3 e JUnit 4.
- 4
Test Engine API: Interface para implementar engines de teste personalizadas.
2.1. Exemplos de Testes com JUnit 5
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
// 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
Configuração: Configurar o ambiente de teste com os componentes necessários.
- 2
Execução: Executar a funcionalidade sendo testada.
- 3
Verificação: Verificar resultados e efeitos colaterais.
- 4
Limpeza: Limpar dados e recursos criados durante o teste.
4.1. Exemplo de Testes de Integração com Spring Boot
// 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*.