Pular para conteúdo

Unitário

2. Testes unitários

2.1. O que deve ser testado?

A qualidade dos testes unitários é mais valiosa do que a quantidade pura. É preferível ter um conjunto menor de testes bem elaborados e significativos do que uma grande quantidade de testes que não agregam valor substancial. A qualidade nos testes contribui para a confiança no código, facilita a manutenção e garante que os testes desempenhem um papel significativo na garantia da qualidade do software.

2.1.1. Métodos importantes/relevantes

O primeiro desafio é identificar os métodos importantes ou relevantes para teste, afim de garantir uma cobertura eficaz.

Vamos considerar um exemplo simples de um serviço em um aplicativo Spring Boot que realiza operações básicas em uma lista de números. Neste exemplo, teremos um conjunto de métodos relacionados: adicionarNumero, removerNumero e calcularSoma.

import java.util.List;

public class NumerosService {

    private List<Integer> numeros;

    public NumerosService(List<Integer> numeros) {
        this.numeros = numeros;
    }

    // Método que adiciona um número à lista
    public void adicionarNumero(int numero) {
        numeros.add(numero);
    }

    // Método que remove um número da lista
    public void removerNumero(int numero) {
        numeros.remove(Integer.valueOf(numero));
    }

    // Método que calcula a soma de todos os números na lista
    public int calcularSoma() {
        int soma = 0;
        for (int numero : numeros) {
            soma += numero;
        }
        return soma;
    }
}

Agora, considerando a simplicidade dos métodos adicionarNumero e removerNumero, eles podem ser vistos como operações diretas de manipulação da lista, e podem não ter muita lógica envolvida para justificar testes unitários independentes. Por outro lado, o método calcularSoma envolve uma lógica mais complexa (soma de todos os elementos da lista), e seria mais apropriado para testes unitários.

Vamos criar um teste unitário utilizando JUnit para o método calcularSoma:

import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class NumerosServiceTest {

    @Test
    public void testCalcularSoma() {
        List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5);
        NumerosService numerosService = new NumerosService(numeros);

        int resultado = numerosService.calcularSoma();

        assertEquals(15, resultado);
    }
}

Neste teste, estamos verificando se o método calcularSoma funciona conforme o esperado para uma lista específica. Para os métodos adicionarNumero e removerNumero, você pode argumentar que a validação da funcionalidade desses métodos pode ser coberta indiretamente pelos testes que envolvem a manipulação da lista como um todo, ou seja, testes que envolvem calcularSoma. Portanto, esses métodos podem não necessitar de testes unitários independentes.

Esta é uma abordagem comum, omitir os métodos simples, que são essencialmente operações diretas sobre dados, podem não necessitar de testes unitários isolados, enquanto métodos mais complexos, que envolvem lógica significativa, são candidatos mais adequados para testes unitários independentes.

Desta forma a experiência de desenvolvimento de grandes sistemas em java demonstra que algumas características técnicas e negociais dos métodos evidenciam sua importancia e os tornam candidatos. Podemos agrupa-los em:

2.1.1.1. Métodos complexos (Complexidade ciclomática)

Métodos que contenham lógica complexa, como condicionais aninhadas, loops complicados ou algoritmos intricados, são bons candidatos para testes unitários. Eles têm mais chances de conter bugs, e os testes ajudam a garantir que esses casos são tratados corretamente.

Vamos usar como exemplo simples um método em Spring Boot com uma complexidade ciclomática maior, envolvendo algumas condicionais e loops. O método será responsável por verificar se uma lista de números contém algum número primo.

import java.util.List;

public class MathService {

    public boolean containsPrimeNumber(List<Integer> numbers) {
        if (numbers == null || numbers.isEmpty()) {
            throw new IllegalArgumentException("A lista de números não pode ser nula ou vazia.");
        }

        for (int num : numbers) {
            if (isPrime(num)) {
                return true; // Encontrou um número primo, retorna verdadeiro
            }
        }

        return false; // Nenhum número primo encontrado na lista
    }

    private boolean isPrime(int number) {
        if (number <= 1) {
            return false; // Números menores ou iguais a 1 não são primos
        }

        for (int i = 2; i <= Math.sqrt(number); i++) {
            if (number % i == 0) {
                return false; // Encontrou um divisor, não é primo
            }
        }

        return true; // É primo
    }
}

Neste exemplo, o método containsPrimeNumber verifica se uma lista de números contém pelo menos um número primo. O método isPrime é responsável por determinar se um número é primo.

Agora, vamos criar testes unitários utilizando JUnit para verificar o comportamento deste método em cenários felizes e infelizes.

import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class MathServiceTest {

    @Test
    public void testContainsPrimeNumberHappyPath() {
        // Cenário feliz: lista contém um número primo (ex: 5)
        List<Integer> numbers = Arrays.asList(4, 6, 8, 5, 10);
        MathService mathService = new MathService();

        assertTrue(mathService.containsPrimeNumber(numbers));
    }

    @Test
    public void testContainsPrimeNumberUnhappyPath() {
        // Cenário infeliz: lista não contém número primo
        List<Integer> numbers = Arrays.asList(4, 6, 8, 10);
        MathService mathService = new MathService();

        assertFalse(mathService.containsPrimeNumber(numbers));
    }

    @Test
    public void testContainsPrimeNumberNullList() {
        // Cenário infeliz: lista nula, deve lançar IllegalArgumentException
        List<Integer> numbers = null;
        MathService mathService = new MathService();

        assertThrows(IllegalArgumentException.class, () -> {
            mathService.containsPrimeNumber(numbers);
        });
    }

    @Test
    public void testContainsPrimeNumberEmptyList() {
        // Cenário infeliz: lista vazia, deve lançar IllegalArgumentException
        List<Integer> numbers = Arrays.asList();
        MathService mathService = new MathService();

        assertThrows(IllegalArgumentException.class, () -> {
            mathService.containsPrimeNumber(numbers);
        });
    }
}

Estes testes cobrem cenários felizes e infelizes, incluindo a verificação de uma lista que contém um número primo, uma lista sem números primos, uma lista nula e uma lista vazia. Isso ajuda a validar a robustez e o comportamento esperado do método containsPrimeNumber.

2.1.1.2. Métodos que manipulação de estado

Métodos que manipulam o estado interno do objeto ou da aplicação são importantes de serem testados. Isso inclui métodos que alteram variáveis de instância ou têm efeitos colaterais.

Vamos criar um exemplo simples de teste unitário em Spring Boot para um serviço que possui um método com efeito colateral, ou seja, um método que altera o estado interno de um objeto. Neste exemplo, consideraremos um serviço de manipulação de usuários com um método atualizarNome que atualiza o nome de um usuário.

Vamos começar com a classe UsuarioService:

public class UsuarioService {

    private Usuario usuario;

    public UsuarioService(Usuario usuario) {
        this.usuario = usuario;
    }

    public void atualizarNome(String novoNome) {
        if (novoNome != null && !novoNome.isEmpty()) {
            usuario.setNome(novoNome);
        } else {
            throw new IllegalArgumentException("O novo nome não pode ser nulo ou vazio.");
        }
    }

    public String obterNomeUsuario() {
        return usuario.getNome();
    }
}

A classe Usuario simplesmente possui um atributo nome e seus métodos getter e setter.

Agora, criaremos um teste unitário para o método atualizarNome usando JUnit e o framework de testes do Spring.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class UsuarioServiceTest {

    private UsuarioService usuarioService;

    @BeforeEach
    public void setup() {
        Usuario usuario = new Usuario("John Doe");
        usuarioService = new UsuarioService(usuario);
    }

    @Test
    public void testAtualizarNome() {
        String novoNome = "Jane Doe";

        usuarioService.atualizarNome(novoNome);

        assertEquals(novoNome, usuarioService.obterNomeUsuario());
    }

    @Test
    public void testAtualizarNomeComNomeNulo() {
        assertThrows(IllegalArgumentException.class, () -> {
            usuarioService.atualizarNome(null);
        });
    }

    @Test
    public void testAtualizarNomeComNomeVazio() {
        assertThrows(IllegalArgumentException.class, () -> {
            usuarioService.atualizarNome("");
        });
    }
}

Neste exemplo, o método testAtualizarNome valida se o método atualizarNome está alterando corretamente o nome do usuário. Os métodos testAtualizarNomeComNomeNulo e testAtualizarNomeComNomeVazio testam os cenários em que o nome fornecido para atualização é nulo ou vazio, respectivamente, e garantem que o método lance a exceção apropriada nestes casos.

2.1.1.3. Validação de entrada

Métodos que realizam validação de entrada (por exemplo, verificação de parâmetros) são cruciais para a integridade do sistema. Testar esses métodos ajuda a garantir que inputs inválidos são tratados corretamente.

Vamos criar um exemplo de teste unitário em Spring Boot que valida parâmetros de entrada para garantir a integridade do sistema. Neste exemplo, consideraremos um serviço de manipulação de produtos, com um método adicionarProduto que requer que o nome e o preço do produto sejam fornecidos.

Primeiro, a classe Produto:

public class Produto {

    private String nome;
    private double preco;

    // Construtor, getters e setters
    // ...

    public Produto(String nome, double preco) {
        this.nome = nome;
        this.preco = preco;
    }

    // Outros métodos relacionados ao Produto
    // ...
}

Agora, a classe ProdutoService com o método adicionarProduto:

import java.util.ArrayList;
import java.util.List;

public class ProdutoService {

    private List<Produto> produtos = new ArrayList<>();

    public void adicionarProduto(String nome, double preco) {
        validarParametros(nome, preco);
        Produto novoProduto = new Produto(nome, preco);
        produtos.add(novoProduto);
    }

    private void validarParametros(String nome, double preco) {
        if (nome == null || nome.isEmpty()) {
            throw new IllegalArgumentException("O nome do produto não pode ser nulo ou vazio.");
        }

        if (preco <= 0) {
            throw new IllegalArgumentException("O preço do produto deve ser maior que zero.");
        }
    }

    public List<Produto> listarProdutos() {
        return produtos;
    }
}

Agora, o teste unitário para a classe ProdutoService usando JUnit:

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

public class ProdutoServiceTest {

    @Test
    public void testAdicionarProduto() {
        ProdutoService produtoService = new ProdutoService();
        produtoService.adicionarProduto("Produto A", 50.0);

        List<Produto> produtos = produtoService.listarProdutos();

        assertEquals(1, produtos.size());
        assertEquals("Produto A", produtos.get(0).getNome());
        assertEquals(50.0, produtos.get(0).getPreco(), 0.001);
    }

    @Test
    public void testAdicionarProdutoComNomeNulo() {
        ProdutoService produtoService = new ProdutoService();

        assertThrows(IllegalArgumentException.class, () -> {
            produtoService.adicionarProduto(null, 50.0);
        });
    }

    @Test
    public void testAdicionarProdutoComNomeVazio() {
        ProdutoService produtoService = new ProdutoService();

        assertThrows(IllegalArgumentException.class, () -> {
            produtoService.adicionarProduto("", 50.0);
        });
    }

    @Test
    public void testAdicionarProdutoComPrecoZero() {
        ProdutoService produtoService = new ProdutoService();

        assertThrows(IllegalArgumentException.class, () -> {
            produtoService.adicionarProduto("Produto B", 0);
        });
    }

    @Test
    public void testAdicionarProdutoComPrecoNegativo() {
        ProdutoService produtoService = new ProdutoService();

        assertThrows(IllegalArgumentException.class, () -> {
            produtoService.adicionarProduto("Produto C", -10.0);
        });
    }
}

Neste exemplo, os métodos testAdicionarProdutoComNomeNulo, testAdicionarProdutoComNomeVazio, testAdicionarProdutoComPrecoZero e testAdicionarProdutoComPrecoNegativo validam se o serviço lança corretamente exceções para situações em que os parâmetros de entrada não atendem às expectativas definidas.

Esses testes são projetados para garantir que o método adicionarProduto funcione de maneira coerente e proteja a integridade do sistema em relação aos parâmetros de entrada.

2.1.1.4. Caminhos críticos

Identifique os caminhos críticos em seu código, aqueles que são essenciais para o funcionamento correto do sistema. Testar esses caminhos assegura que as funcionalidades principais estão protegidas contra falhas.

Vamos criar um exemplo de teste unitário em Spring Boot que valida de forma coerente um caminho crítico no sistema. Neste exemplo, consideraremos um serviço de autenticação com um método autenticar que realiza a autenticação de usuários.

Primeiro, a classe Usuario:

public class Usuario {

    private String username;
    private String senha;

    // Construtor, getters e setters
    // ...

    public Usuario(String username, String senha) {
        this.username = username;
        this.senha = senha;
    }

    // Outros métodos relacionados ao Usuario
    // ...
}

Agora, a classe AutenticacaoService com o método autenticar:

import java.util.HashMap;
import java.util.Map;

public class AutenticacaoService {

    private Map<String, Usuario> usuariosCadastrados = new HashMap<>();

    public void cadastrarUsuario(String username, String senha) {
        Usuario novoUsuario = new Usuario(username, senha);
        usuariosCadastrados.put(username, novoUsuario);
    }

    public boolean autenticar(String username, String senha) {
        if (usuariosCadastrados.containsKey(username)) {
            Usuario usuario = usuariosCadastrados.get(username);
            return usuario.getSenha().equals(senha);
        }
        return false;
    }
}

Agora, o teste unitário para a classe AutenticacaoService usando JUnit:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class AutenticacaoServiceTest {

    private AutenticacaoService autenticacaoService;

    @BeforeEach
    public void setup() {
        autenticacaoService = new AutenticacaoService();
        autenticacaoService.cadastrarUsuario("usuarioA", "senha123");
    }

    @Test
    public void testAutenticarComCredenciaisCorretas() {
        boolean resultado = autenticacaoService.autenticar("usuarioA", "senha123");
        assertTrue(resultado);
    }

    @Test
    public void testAutenticarComUsernameInexistente() {
        boolean resultado = autenticacaoService.autenticar("usuarioB", "senha123");
        assertFalse(resultado);
    }

    @Test
    public void testAutenticarComSenhaIncorreta() {
        boolean resultado = autenticacaoService.autenticar("usuarioA", "senha456");
        assertFalse(resultado);
    }
}

Neste exemplo, o método testAutenticarComCredenciaisCorretas valida se o serviço retorna true quando as credenciais estão corretas. Os métodos testAutenticarComUsernameInexistente e testAutenticarComSenhaIncorreta validam se o serviço retorna false quando o username não existe ou quando a senha está incorreta, respectivamente.

Esses testes são projetados para cobrir o caminho crítico do método autenticar, que é a verificação de autenticação. Esses casos são fundamentais para garantir a segurança e a funcionalidade do sistema em situações de autenticação.

2.1.1.5. Métodos de requisitos específicos

Métodos que implementam requisitos de negócios críticos ou funcionalidades-chave devem ser priorizados para testes unitários. Certifique-se de que esses requisitos estão atendidos em diferentes cenários.

Vamos criar um exemplo de teste unitário em Spring Boot que valida métodos que implementam requisitos de negócios críticos. Neste exemplo, consideraremos um serviço de carrinho de compras que possui um método adicionarItem e um método calcularTotal como funcionalidades-chave.

Primeiro, a classe Item que representa um item no carrinho de compras:

public class Item {

    private String nome;
    private double preco;
    private int quantidade;

    // Construtor, getters e setters
    // ...

    public Item(String nome, double preco, int quantidade) {
        this.nome = nome;
        this.preco = preco;
        this.quantidade = quantidade;
    }

    // Outros métodos relacionados ao Item
    // ...
}

Agora, a classe CarrinhoComprasService com os métodos adicionarItem e calcularTotal:

import java.util.ArrayList;
import java.util.List;

public class CarrinhoComprasService {

    private List<Item> itens = new ArrayList<>();

    public void adicionarItem(String nome, double preco, int quantidade) {
        validarParametros(nome, preco, quantidade);
        Item novoItem = new Item(nome, preco, quantidade);
        itens.add(novoItem);
    }

    public double calcularTotal() {
        double total = 0;
        for (Item item : itens) {
            total += item.getPreco() * item.getQuantidade();
        }
        return total;
    }

    private void validarParametros(String nome, double preco, int quantidade) {
        if (nome == null || nome.isEmpty()) {
            throw new IllegalArgumentException("O nome do item não pode ser nulo ou vazio.");
        }

        if (preco <= 0) {
            throw new IllegalArgumentException("O preço do item deve ser maior que zero.");
        }

        if (quantidade <= 0) {
            throw new IllegalArgumentException("A quantidade do item deve ser maior que zero.");
        }
    }
}

Agora, o teste unitário para a classe CarrinhoComprasService usando JUnit:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class CarrinhoComprasServiceTest {

    @Test
    public void testAdicionarItemEValidarTotal() {
        CarrinhoComprasService carrinhoService = new CarrinhoComprasService();
        carrinhoService.adicionarItem("Produto A", 50.0, 2);

        double total = carrinhoService.calcularTotal();

        assertEquals(100.0, total, 0.001);
    }

    @Test
    public void testAdicionarItemComNomeNulo() {
        CarrinhoComprasService carrinhoService = new CarrinhoComprasService();

        assertThrows(IllegalArgumentException.class, () -> {
            carrinhoService.adicionarItem(null, 50.0, 2);
        });
    }

    @Test
    public void testAdicionarItemComPrecoZero() {
        CarrinhoComprasService carrinhoService = new CarrinhoComprasService();

        assertThrows(IllegalArgumentException.class, () -> {
            carrinhoService.adicionarItem("Produto B", 0, 2);
        });
    }

    @Test
    public void testAdicionarItemComQuantidadeNegativa() {
        CarrinhoComprasService carrinhoService = new CarrinhoComprasService();

        assertThrows(IllegalArgumentException.class, () -> {
            carrinhoService.adicionarItem("Produto C", 50.0, -2);
        });
    }
}

Neste exemplo, o método testAdicionarItemEValidarTotal valida se o serviço está calculando o total corretamente após adicionar um item ao carrinho. Os métodos testAdicionarItemComNomeNulo, testAdicionarItemComPrecoZero e testAdicionarItemComQuantidadeNegativa validam se o serviço lança corretamente exceções para situações em que os parâmetros de entrada não atendem às expectativas.

Esses testes são projetados para garantir que os métodos críticos atendam aos requisitos de negócios em diferentes cenários.

2.1.1.6. Métodos suscetíveis a mudanças

Métodos que são propensos a mudanças frequentes ou que são modificados com o tempo devem ser testados. Isso ajuda a garantir que as alterações não quebram a funcionalidade existente.

Considere um serviço de manipulação de strings que possui um método concatenarStrings. Este método pode ser propenso a mudanças, como a adição de mais lógica de formatação ou a mudança da estratégia de concatenação. Vamos criar um teste unitário que valida o comportamento básico do método e é flexível para mudanças.

public class StringManipulationService {

    public String concatenarStrings(String str1, String str2) {
        if (str1 == null) {
            str1 = "";
        }
        if (str2 == null) {
            str2 = "";
        }
        return str1 + str2;
    }
}

Agora, o teste unitário para a classe StringManipulationService usando JUnit:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class StringManipulationServiceTest {

    @Test
    public void testConcatenarStrings() {
        StringManipulationService stringService = new StringManipulationService();

        String resultado = stringService.concatenarStrings("Hello", "World");

        assertEquals("HelloWorld", resultado);
    }

    @Test
    public void testConcatenarStringComNull() {
        StringManipulationService stringService = new StringManipulationService();

        String resultado = stringService.concatenarStrings("Hello", null);

        assertEquals("Hello", resultado);
    }

    @Test
    public void testConcatenarStringComAmbasNull() {
        StringManipulationService stringService = new StringManipulationService();

        String resultado = stringService.concatenarStrings(null, null);

        assertEquals("", resultado);
    }

    // Teste flexível para futuras mudanças
    @Test
    public void testConcatenarStringComMudancaFutura() {
        StringManipulationService stringService = new StringManipulationService();

        String resultado = stringService.concatenarStrings("Hello", "World");

        // Este teste não se preocupa com o formato exato, apenas valida que a string resultante não é nula
        assertNotNull(resultado);
    }
}

Neste exemplo, o método testConcatenarStringComMudancaFutura é projetado para ser flexível para futuras mudanças. Ele não valida o formato exato da string resultante, mas apenas verifica se não é nula. Isso permite que o método concatenarStrings seja alterado no futuro para incluir formatação adicional ou uma estratégia diferente de concatenação sem quebrar o teste.

Essa abordagem ajuda a criar testes unitários que são mais resilientes a mudanças, permitindo que o código seja adaptado ao longo do tempo sem a necessidade de ajustes significativos nos testes.

2.1.1.7. Métodos de integração com outros componentes

Métodos que interagem com outros componentes, serviços ou bibliotecas externas são importantes para os testes. Certifique-se de que essas interações ocorram conforme o esperado.

Para testar métodos que interagem com outros componentes, serviços ou bibliotecas externas em um ambiente Spring Boot, você pode utilizar ferramentas como o Mockito para simular essas interações externas. Vou fornecer um exemplo simples de um serviço que consome um serviço externo, e um teste unitário utilizando Mockito.

Considere um serviço ExternalService que interage com um serviço externo:

public interface ExternalService {
    String obterDadosExternos();
}

Agora, o serviço MyService que utiliza o ExternalService:

public class MyService {

    private ExternalService externalService;

    public MyService(ExternalService externalService) {
        this.externalService = externalService;
    }

    public String processarDados() {
        String dadosExternos = externalService.obterDadosExternos();
        // Lógica de processamento local
        return "Dados processados: " + dadosExternos;
    }
}

Agora, o teste unitário para o MyService utilizando Mockito:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class MyServiceTest {

    @Test
    public void testProcessarDados() {
        // Criar um mock do ExternalService
        ExternalService externalServiceMock = Mockito.mock(ExternalService.class);

        // Configurar o comportamento do mock
        Mockito.when(externalServiceMock.obterDadosExternos()).thenReturn("Dados Externos Simulados");

        // Criar uma instância de MyService com o mock
        MyService myService = new MyService(externalServiceMock);

        // Chamar o método a ser testado
        String resultado = myService.processarDados();

        // Verificar se o método processarDados() interagiu corretamente com ExternalService
        Mockito.verify(externalServiceMock, Mockito.times(1)).obterDadosExternos();

        // Verificar o resultado do processamento local
        assertEquals("Dados processados: Dados Externos Simulados", resultado);
    }
}

Neste exemplo, utilizamos o Mockito para criar um mock do ExternalService. Configuramos o comportamento do mock para que, quando o método obterDadosExternos() for chamado, ele retorne uma string simulada. Em seguida, criamos uma instância do MyService passando o mock como dependência. No teste, chamamos o método processarDados() e verificamos se o método interagiu corretamente com o serviço externo, além de validar o resultado do processamento local.

Esse tipo de teste é crucial para garantir que as interações com componentes externos sejam tratadas corretamente, e a utilização de mocks ajuda a isolar o comportamento do serviço em teste, permitindo que você concentre-se especificamente na lógica interna do serviço sem depender do comportamento real dos serviços externos.

2.1.1.8. Métodos com condições especiais

Identifique métodos que têm diferentes comportamentos em situações especiais (por exemplo, edge cases). Testar esses cenários extremos ajuda a garantir a robustez do código.

Considere um serviço MathService que possui um método dividir:

public class MathService {

    public double dividir(int numerador, int denominador) {
        if (denominador == 0) {
            throw new IllegalArgumentException("Denominador não pode ser zero.");
        }
        return numerador / denominador;
    }
}

Agora, o teste unitário para o MathService:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class MathServiceTest {

    private final MathService mathService = new MathService();

    @Test
    public void testDividirNumerosPositivos() {
        double resultado = mathService.dividir(10, 2);
        assertEquals(5.0, resultado, 0.001);
    }

    @Test
    public void testDividirPorZero() {
        assertThrows(IllegalArgumentException.class, () -> {
            mathService.dividir(10, 0);
        });
    }

    @Test
    public void testDividirNumerosNegativos() {
        double resultado = mathService.dividir(-10, 2);
        assertEquals(-5.0, resultado, 0.001);
    }

    @Test
    public void testDividirZeroPorNumero() {
        double resultado = mathService.dividir(0, 5);
        assertEquals(0.0, resultado, 0.001);
    }

    @Test
    public void testDividirNumeradorNegativoPorDenominadorNegativo() {
        double resultado = mathService.dividir(-10, -2);
        assertEquals(5.0, resultado, 0.001);
    }
}

Neste exemplo, temos vários cenários cobertos pelos testes:

  1. testDividirNumerosPositivos: Testa a divisão de dois números positivos.
  2. testDividirPorZero: Testa se a exceção IllegalArgumentException é lançada ao tentar dividir por zero.
  3. testDividirNumerosNegativos: Testa a divisão de um número negativo por um número positivo.
  4. testDividirZeroPorNumero: Testa a divisão de zero por um número positivo.
  5. testDividirNumeradorNegativoPorDenominadorNegativo: Testa a divisão de um número negativo por outro número negativo.

Cada teste é projetado para validar o comportamento específico do método dividir em diferentes situações. Essa abordagem ajuda a garantir que o código lida corretamente com edge cases e situações especiais.


Ao aplicar esses critérios, você pode identificar os métodos mais críticos e propensos a erros, priorizando a criação de testes unitários para essas partes do código. Vale ressaltar que a seleção dos métodos para teste pode variar de acordo com o contexto do projeto e os requisitos específicos.


2.1.2. Deve ser testada a regra e não o código

É possível que os desenvolvedores, por vezes, se concentrem demasiadamente no código em si e percam de vista o objetivo maior: garantir que as regras de negócio sejam satisfeitas e que o usuário final tenha uma experiência positiva.

A principal razão pela qual os testes são enfatizados no desenvolvimento é a confiança que eles proporcionam ao desenvolvedor e à equipe. Testes unitários bem escritos oferecem a segurança de que cada parte do código está correta e continua funcionando conforme as expectativas mesmo quando são feitas alterações no código. Eles permitem a detecção precoce de problemas, facilitam a manutenção do código e contribuem para a criação de um software mais robusto.

No entanto, quando o desenvolvedor foca exclusivamente nos aspectos técnicos dos testes, pode ocorrer uma desconexão com o propósito maior do desenvolvimento de software: atender às necessidades do usuário. É fundamental lembrar que a finalidade das regras de negócio e funcionalidades implementadas no código é proporcionar valor ao usuário final.

Suponhamos um serviço de carrinho de compras que possui um método adicionarItem e um método calcularTotal. No teste abaixo, o desenvolvedor está mais focado na implementação técnica do que nas regras de negócio ou na satisfação do usuário:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CarrinhoComprasServiceTest {

    @Test
    public void testAdicionarItemEValidarTotal() {
        CarrinhoComprasService carrinhoService = new CarrinhoComprasService();
        carrinhoService.adicionarItem("Produto A", 50.0, 2);

        // Mocking para simular a obtenção do total a partir do banco de dados
        Mockito.when(carrinhoService.calcularTotal()).thenReturn(100.0);

        double total = carrinhoService.calcularTotal();

        assertEquals(100.0, total, 0.001);
    }
}

Neste exemplo, o desenvolvedor está usando o Mockito para simular o cálculo do total a partir do banco de dados no método calcularTotal. O problema aqui é que o desenvolvedor está mais preocupado em simular a interação do método com o banco de dados do que em garantir que as regras de negócio relacionadas ao cálculo do total sejam validadas.

Essa abordagem pode indicar uma falta de ênfase na validação do comportamento real do serviço em relação às regras de negócio, como descontos, promoções ou cálculos mais complexos que poderiam ocorrer no método calcularTotal. O foco excessivo na implementação técnica pode levar a uma cobertura de teste inadequada das funcionalidades reais do software.

Para melhorar este exemplo, o teste deveria ser adaptado para garantir que as regras de negócio do cálculo de total sejam devidamente validadas, sem depender excessivamente de simulações ou mocks desnecessários. Isso ajudaria a assegurar que o serviço funcione corretamente de acordo com as expectativas do usuário final.

Alguns pontos devem ser considerados para manter o equilíbrio entre testes unitários e o foco nas regras de negócio e satisfação do usuário, dentre eles:

2.1.2.1. Cobertura Realista

Voltamos a este ponto afim de enfatizar o seu potencial em diminuir a eficácia dos testes. Nem sempre é possível ou prático ter 100% de cobertura de testes unitários, especialmente quando a complexidade das regras de negócio é alta.

Priorizar os testes para as partes críticas e de alto impacto do sistema ajuda a diminuir o tempo gasto com trechos de código desnecessários.

A falta de preparo para identificar os trechos de código realmente relevantes, por vezes, leva o desenvolvedor a ir no caminho contrário as expectativas do usuário.

Vamos considerar um exemplo de teste unitário em Spring Boot que inclui uma necessidade do usuário a ser testada (uma regra de negócio relevante) e outra que não representa uma necessidade direta do usuário (um trecho de código menos relevante para o usuário).

Suponha que temos um serviço CalculadoraService com um método calcularDesconto que calcula o desconto com base em algumas regras específicas, e um método formatarNumero que formata um número para exibição. Vamos criar testes para ambos os métodos, demonstrando uma diferença na relevância para o usuário:

public class CalculadoraService {

    public double calcularDesconto(double valorTotal, boolean clienteEspecial) {
        // Lógica de cálculo de desconto
        if (clienteEspecial) {
            return valorTotal * 0.2; // Desconto de 20% para clientes especiais
        } else {
            return valorTotal * 0.1; // Desconto padrão de 10%
        }
    }

    public String formatarNumero(double numero) {
        // Lógica de formatação de número
        return String.format("%.2f", numero);
    }
}

Agora, os testes unitários correspondentes:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class CalculadoraServiceTest {

    @Test
    public void testCalcularDescontoParaClienteEspecial() {
        CalculadoraService calculadoraService = new CalculadoraService();
        double desconto = calculadoraService.calcularDesconto(100.0, true);

        assertEquals(20.0, desconto, 0.001);
    }

    @Test
    public void testCalcularDescontoSemClienteEspecial() {
        CalculadoraService calculadoraService = new CalculadoraService();
        double desconto = calculadoraService.calcularDesconto(100.0, false);

        assertEquals(10.0, desconto, 0.001);
    }

    @Test
    public void testFormatarNumero() {
        CalculadoraService calculadoraService = new CalculadoraService();
        String numeroFormatado = calculadoraService.formatarNumero(1234.5678);

        assertEquals("1234.57", numeroFormatado);
    }
}

No primeiro e segundo testes, estamos validando regras de negócio diretamente relacionadas à necessidade do usuário, ou seja, o cálculo de desconto para clientes especiais e não especiais. Esses são cenários que afetam diretamente a experiência do usuário em termos de preços e descontos.

No entanto, o terceiro teste está relacionado à formatação de um número, um detalhe mais técnico que pode ser relevante para a implementação interna, mas é menos significativo para a necessidade direta do usuário. Portanto, enquanto este teste pode ser necessário para garantir a integridade do código, não representa uma regra de negócio vital para o usuário.

Essa abordagem permite que os desenvolvedores priorizem testes relacionados diretamente às necessidades do usuário, garantindo que as funcionalidades mais importantes sejam validadas adequadamente.

2.1.2.2. Testes de Integração e End-to-End

Além dos testes unitários, os testes de integração e end-to-end são essenciais para validar o sistema como um todo, garantindo que as diferentes partes interajam corretamente e que a experiência do usuário seja satisfatória.

É necessário sempre avaliar se o teste unitário é realmente a melhor opção para o cenário de testes. Por vezes um teste de integração ou um teste end-to-end será mais adequado, dessa forma evitamos escrever testes unitários focados no código e permanecemos com foco no usuário.

Um exemplo típico de teste unitário que talvez não faça muito sentido ser escrito, devido à sua natureza, seria um teste que envolve a lógica de persistência e recuperação de dados em um banco de dados. Esse tipo de teste pode ser mais eficientemente abordado como um teste de integração, pois envolve a interação com componentes externos, como um banco de dados.

Considere uma classe UsuarioService que realiza operações relacionadas a usuários e tem um método salvarUsuario que persiste um usuário no banco de dados:

public class UsuarioService {

    private UsuarioRepository usuarioRepository;

    public UsuarioService(UsuarioRepository usuarioRepository) {
        this.usuarioRepository = usuarioRepository;
    }

    public void salvarUsuario(Usuario usuario) {
        // Lógica de validação ou manipulação antes de salvar no banco de dados
        usuarioRepository.save(usuario);
    }
}

Agora, um teste unitário que não faz muito sentido em alguns contextos:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.*;

public class UsuarioServiceTest {

    @Test
    public void testSalvarUsuario() {
        UsuarioRepository mockRepository = Mockito.mock(UsuarioRepository.class);
        UsuarioService usuarioService = new UsuarioService(mockRepository);

        Usuario usuario = new Usuario("john.doe@example.com", "John Doe");

        usuarioService.salvarUsuario(usuario);

        Mockito.verify(mockRepository, Mockito.times(1)).save(usuario);
    }
}

Neste exemplo, o teste está usando o Mockito para criar um mock do UsuarioRepository e verifica se o método save foi chamado corretamente. No entanto, essa abordagem está isolando a lógica do serviço de qualquer interação real com o banco de dados, o que pode ser uma desvantagem em termos de cobertura de testes significativos.

Em situações como essa, onde a lógica de persistência é uma parte crítica do serviço, pode ser mais eficiente escrever um teste de integração que envolva um banco de dados real ou um teste end-to-end que simule toda a aplicação em execução.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
public class UsuarioRepositoryIntegrationTest {

    @Autowired
    private UsuarioRepository usuarioRepository;

    @Test
    public void testSalvarUsuario() {
        Usuario usuario = new Usuario("john.doe@example.com", "John Doe");
        usuarioRepository.save(usuario);

        assertNotNull(usuario.getId()); // Verifica se o ID foi gerado após a persistência no banco de dados
    }
}

Este teste de integração usa a anotação @DataJpaTest do Spring Boot para configurar um ambiente de teste de integração para testar a interação real do UsuarioRepository com o banco de dados. Isso oferece uma validação mais completa da lógica de persistência e recuperação de dados.

Falaremos exclusivamente de testes de integração em outra seção.

2.1.2.3. Casos de Uso Realistas

Considere cenários de teste que reflitam casos de uso do mundo real. Os testes devem abordar situações que os usuários finais possam encontrar, garantindo que as funcionalidades cumpram as expectativas.

Vamos considerar um exemplo de teste unitário em Spring Boot para um serviço de gerenciamento de pedidos. Em seguida, compararemos com um exemplo que não reflete cenários do mundo real.

Exemplo com Cenários de Teste do Mundo Real

Suponha que temos um serviço PedidoService com um método calcularTotalPedido que recebe uma lista de itens de pedido e calcula o total. O objetivo é garantir que o serviço lide corretamente com diferentes situações que os usuários finais podem encontrar:

import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class PedidoServiceTest {

    @Test
    public void testCalcularTotalPedidoSemDesconto() {
        PedidoService pedidoService = new PedidoService();
        List<ItemPedido> itens = Arrays.asList(
                new ItemPedido("Produto A", 30.0, 2),
                new ItemPedido("Produto B", 20.0, 3)
        );

        double total = pedidoService.calcularTotalPedido(itens);

        assertEquals(150.0, total, 0.001);
    }

    @Test
    public void testCalcularTotalPedidoComDesconto() {
        PedidoService pedidoService = new PedidoService();
        List<ItemPedido> itens = Arrays.asList(
                new ItemPedido("Produto A", 30.0, 5),
                new ItemPedido("Produto B", 20.0, 2)
        );

        double total = pedidoService.calcularTotalPedido(itens);

        // Simulando um desconto de 10% para pedidos acima de 4 unidades do Produto A
        assertEquals(171.0, total, 0.001);
    }

    // Outros testes relacionados a descontos, validações, etc.
}

Este exemplo de teste abrange cenários que podem ocorrer no mundo real, como calcular o total do pedido com ou sem descontos, garantindo que as funcionalidades cumpram as expectativas do usuário final.

Exemplo sem Cenários de Teste do Mundo Real

Agora, vamos considerar um exemplo onde o teste não reflete adequadamente cenários do mundo real, tornando-o menos útil para validar as expectativas dos usuários finais:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ExemploNaoRealistaTest {

    @Test
    public void testOperacaoMatematicaSimples() {
        CalculadoraService calculadoraService = new CalculadoraService();

        int resultado = calculadoraService.somar(2, 3);

        assertEquals(5, resultado);
    }

    @Test
    public void testManipulacaoDeString() {
        StringUtil stringUtil = new StringUtil();

        String resultado = stringUtil.inverter("abc");

        assertEquals("cba", resultado);
    }
}

Neste exemplo, os testes se concentram em operações matemáticas simples e manipulação de strings, que, embora possam ser úteis para validar funções internas, não refletem adequadamente casos de uso do mundo real. Esses testes podem não fornecer informações valiosas sobre como as funcionalidades interagem no contexto de uma aplicação mais complexa.

Ao escrever testes unitários, é essencial garantir que os cenários de teste se alinhem com as situações reais que os usuários enfrentarão, para que a cobertura de testes seja mais significativa e relevante para a qualidade geral da aplicação.

2.1.2.4. Compreensão da Lógica de Negócios

Entenda profundamente as regras de negócio e as expectativas do usuário. Isso não apenas facilita a escrita de testes mais significativos, mas também ajuda a evitar que a implementação técnica se desvie do propósito principal.

Traduzir as regras de negócio em código é afinal o objetivo do desenvolvedor, entretanto, pela natureza do processo de abstração, vai-se além ou fica-se a quém da lógica final esperada.

Esta tarefa pode ser auxiliada pelo teste unitário, principalmente se auxiliado por técnicas como TDD. Abordaremos o TDD em breve.

2.1.2.5. Iteração com Stakeholders

Mantenha uma comunicação regular com os stakeholders, incluindo clientes ou usuários finais, para garantir que suas necessidades sejam compreendidas e atendidas. Isso pode influenciar diretamente os critérios de teste e os casos de uso.


Ao integrar esses princípios, os desenvolvedores podem criar testes unitários que não apenas validam a integridade técnica do código, mas também asseguram que as regras de negócio e, consequentemente, a satisfação do usuário sejam adequadamente consideradas. O equilíbrio entre a preocupação técnica e a visão orientada ao usuário é essencial para o sucesso de qualquer projeto de software.


2.1.3. Eficácia do teste

Citamos bons exemplos de como buscar a eficácia dos testes, entretanto, como podemos avalia-la?

Enquanto o software estiver em evolução o teste também estará. O teste deve passar por processos de análise de melhoria junto com o software final, entretanto, os testes sempre devem ser mais estáveis do que o próprio software.

Ora, o teste unitário é análogo as regras de negócio do que o próprio software, pois o primeiro declara e valida o que realmente é esperado pelo usuário final enquanto o software de fato tenta atender a essas expectativas. Desta forma o teste unitário só deveria mudar quando as regras de negócio mudam.

Entendendo que os testes são documentações vivas de que o software "funciona", sua eficácia pode ser obtida, observada e validada através de várias práticas e métricas como:

2.1.3.1. Cobertura de Código

A cobertura de código indica a porcentagem de código-fonte que é exercida pelos testes. Ferramentas de análise de cobertura, como JaCoCo para Java, podem ser usadas para medir essa métrica.

Deve-se executar relatórios de cobertura de código após a execução dos testes para avaliar quais partes do código estão sendo testadas e quais estão sendo negligenciadas.

Uma cobertura de código mais alta geralmente indica uma maior probabilidade de detecção de bugs. No entanto, a cobertura por si só não garante a qualidade dos testes. É necessário garantir que os testes cubram casos de uso significativos.

2.1.3.2. Taxa de Sucesso dos Testes

A taxa de sucesso dos testes indica a porcentagem de testes que são bem-sucedidos em uma execução.

Deve-se monitorar os resultados de execução dos testes e calcular a taxa de sucesso. Ferramentas de execução de testes, como JUnit, geralmente fornecem essas estatísticas.

Uma alta taxa de sucesso é um bom indicador, mas falhas ocasionais podem ocorrer. É importante investigar e corrigir testes falhos para manter a confiança nos resultados.

2.1.3.3. Tempo de Execução dos Testes

O tempo necessário para executar a suíte de testes é uma consideração importante, especialmente em ambientes de desenvolvimento ágil.

É necessário medir o tempo de execução dos testes e avalie se está dentro de limites aceitáveis.

Testes que são executados rapidamente incentivam uma execução frequente, mas é importante equilibrar a velocidade com uma cobertura significativa.

É possível inclusive organizar e agrupar os testes de tal forma que os testes pesados como test-suit longos ou testes de integração sejam executados em separado, afim de garantir a agilidade no feedback.

2.1.3.4. Detecção Antecipada de Defeitos

A capacidade dos testes de detectar defeitos antes de chegarem aos ambientes de produção é um indicador crítico de eficácia.

Deve-se avalie o histórico de defeitos detectados pelos testes durante o desenvolvimento.

Testes que detectam problemas cedo reduzem os custos de correção e melhoram a qualidade do software.

2.1.3.5. Refatoração Confiável

Os testes devem permitir a refatoração do código sem introduzir defeitos e refatoração no código não deve impor mudanças ao código de testes.

A execução do testes deve garantir que continuem a passar após as mudanças.

Testes robustos e concisos garantem que as melhorias no código não comprometam a funcionalidade existente.

2.1.3.6. Relevância para o Negócio

A relevância dos testes para os requisitos de negócio é fundamental.

Deve-se certificar de que os testes abordem os casos de uso críticos e representativos do ponto de vista do usuário final.

Testes que refletem cenários reais de uso garantem que o software atenda às expectativas do usuário.

2.1.3.7. Feedback Rápido

Os testes devem fornecer feedback rápido aos desenvolvedores durante o ciclo de desenvolvimento.

Recomenda-se avaliar o tempo entre a execução dos testes e o recebimento do feedback.

Feedback rápido permite ajustes imediatos e mantém o desenvolvimento ágil.


Ao adotar essas práticas e métricas, as equipes podem obter uma visão clara da eficácia dos testes unitários, garantindo que eles desempenhem um papel significativo na melhoria da qualidade do software.


2.1.4. Mocks

Citamos anteriormente os mocks, agora falaremos deles com mais detalhes pois sua motivação traz uma importante noção sobre o que realmente está sendo testado.

Dentro do framework de testes os Mocks são componentes representativos simulam componentes reais substituindo-os. Têm a capacidade representar um objeto java, por exemplo, e por meio de configuração comportar-se como ele quando necessario.

Assim, servem para facilitar a criação dos cenários de testes, onde sua melhor utilização é para aquelas dependencias que são difíceis ou impossíveis de utilizar em um ambiente de testes.

O objetivo de um Mock é auxiliar no isolamento dos componentes oferecendo-se como substituto daquela parte do código que não se deseja testar.

É possível observar que os mocks são utilizados de forma equivocada, talvez pela sua facilidade de implementação. É notável que a dificuldade está em avaliar corretamente o que deve ser mockado.

A utilização de mocks é uma prática comum, mas existem situações em que pode ser mais apropriado evita-los. Vamos explorar quando é apropriado usar mocks e quando pode ser melhor evitar:

2.1.4.1. Quando Utilizar Mocks

  • Quando você deseja isolar a unidade de teste de dependências externas, como serviços externos, APIs, ou componentes que podem ser lentos ou não controláveis.

    @Mock
    private ExternalService externalService;
    
    @Test
    void meuMetodoTeste() {
        when(externalService.chamarMetodoExterno()).thenReturn("resultadoMock");
        // Seu código de teste aqui
    }
    
  • Ao testar uma unidade isolada de código e você quer se concentrar apenas no componente em questão, sem se preocupar com as dependências.

    @Mock
    private Dependencia dependencia;
    
    @Test
    void meuMetodoTeste() {
        when(dependencia.metodoDependente()).thenReturn("resultadoMock");
        // Seu código de teste aqui
    }
    
  • Quando o uso de recursos reais (como um banco de dados) em cada teste seria ineficiente ou lento.

    @Mock
    private Repositorio repositorio;
    
    @Test
    void meuMetodoTeste() {
        when(repositorio.recuperarAlgo()).thenReturn(dadosMock);
        // Seu código de teste aqui
    }
    

2.1.4.2. Quando Evitar Mocks:

  • Em testes de integração ou de componentes completos, é muitas vezes preferível utilizar as implementações reais das dependências para garantir que a integração esteja correta.

    @SpringBootTest
    class MinhaClasseTesteIntegracao {
        // ...
    }
    
  • Quando as dependências são simples, bem conhecidas e não introduzem complexidade desnecessária.

    @Test
    void meuMetodoTeste() {
        // Não há necessidade de mock para uma dependência simples
        // Seu código de teste aqui
    }
    
  • Evite mocks quando os testes se tornam frágeis e quebram facilmente com pequenas alterações no código. Isso pode acontecer quando mocks são usados em excesso.

    @Mock
    private ServicoA servicoA;
    
    @Mock
    private ServicoB servicoB;
    
    @Test
    void meuMetodoTeste() {
        when(servicoA.metodo()).thenReturn("resultadoMockA");
        when(servicoB.metodo()).thenReturn("resultadoMockB");
        // Seu código de teste aqui
    }
    

Mocks são verdadeiros parceiros dos testes de software se utilizado de forma coerente. Sua utilização deve sempre ser no intuinto de isolar o sujeito de teste de suas dependencias e não pular etapas para forçar o funcionamento.


2.2. Como deve ser testado?

É fato que os testes unitários são uma prática essencial no desenvolvimento de software, entretanto se mal implementado, podem acabar por onerar o desenvolvimento, despendendo o dobro o esforço necessário ao objetivo pela mesma ou menor eficácia caso o teste não existisse.

Assim, é de suma importancia que além de entendermos o que deve ser testado, entendamos como deve ser testado, destacando o processo de tradução dos requisitos do sistema em testes que validem essas necessidades.

2.2.1. Antes, durante ou depois?

Independente do formato do insumo para desenvolvimento é elementar que ele vem primeiro. Assim, dado que já se sabe qual é o resultado esperado, basta construir o software.

Entretanto, essa empreitada pode levar de algumas horas até dias até a sua completude. Não é incomum que entre idas e vindas na fonte do insumo dos requisitos (stake holder, use case, user story), o objetivo pode se desviar e por vezes se perder.

Além disso, o desafio de transformar requisitos em ‘software’ funcional depende da interpretação correta da necessidade e do conteúdo dos insumos. Assim, falhar é uma opção interessante em direção a eficácia, entretanto vamos abordar brevemente algumas possibilidades:

  1. Depois do Desenvolvimento:
    • Os testes são escritos após a implementação do código funcional. O desenvolvedor cria testes retrospectivamente para verificar se o código existente está correto.
    • Pode ser mais difícil garantir cobertura completa.
    • Testar pode revelar problemas que exigem alterações significativas no código.
  2. Durante o Desenvolvimento:
    • Os testes unitários são escritos à medida que o código funcional está sendo desenvolvido. O foco está na criação de testes para as funcionalidades conforme são implementadas.
    • Permite uma abordagem mais flexível.
    • Testes são ajustados conforme o código evolui.
    • Ainda oferece benefícios de detecção precoce de defeitos.
  3. Antes do Desenvolvimento:
    • Os testes unitários são escritos antes da implementação do código funcional. O desenvolvedor inicia criando testes que definem o comportamento desejado e, em seguida, implementa o código para satisfazer esses testes.
    • Direciona o design do código.
    • Garante que o código seja testável desde o início.
    • Contribui para uma cobertura mais completa e confiável.
    • Oferece benefícios de detecção precoce de defeitos.

A primeira opção não possui benefícios que sugiram a sua implementação em nenhuma situação, já que, uma vez o codigo escrito e composto, será difícil fugir de vícios de teste, cenários irreais e o isolamento será complexo.

Já a segunda abordagem, sendo aplicada de forma gradual, traz todas as desvantagens da primeira, apenas diminuindo de forma abstrata o escopo do teste.

A terceira opção, em um esforço de aproximar o teste e o desenvolvimento ao máximo possível da necessidade do usuário final, traz vantagens consistentes o suficiente para ser considerada a melhor opção quando o assunto é eficácia de testes

Esta opção eficaz é conhecida como TDD, o Desenvolvimento Orientado a Testes. É uma abordagem inovadora que redefine a prática tradicional de escrever testes apenas após ou durante a criação do software e será a diretriz da seções seguintes.

2.2.2. O TDD

Como vimos na seção sobre o que deve ser testado, aprender a identificar os métodos importantes e mais relevantes é crucial.

O TDD auxilia essa tarefa nos dizendo o como testar, entretanto simplesmente focar em escrever o teste primeiro não é a resposta, mas sim o processo heurístico envolvido.

Kent Beck destaca este processo, o ciclo fundamental do TDD, conhecido como Red-Green-Refactor: - Red: Escrever um teste automatizado que falha inicialmente, representando a funcionalidade desejada. - Green: Implementar o código mínimo necessário para passar no teste. - Refactor: Melhorar o código sem alterar seu comportamento, mantendo os testes passando.

Para exemplificar vamos criar uma funcionalidade simples para gerenciar informações de livros, incluindo entrada de dados, processamento e persistência utilizando TDD.

  1. Escrever o Primeiro Teste:
    • Crie um teste que valida a capacidade de criar um novo livro.
@RunWith(SpringRunner.class)
@SpringBootTest
public class BookServiceTest {

    @Autowired
    private BookService bookService;

    @Test
    public void testCreateBook() {
        // Arrange
        BookDTO bookDTO = new BookDTO("Spring in Action", "Craig Walls", 39.99);

        // Act
        Book createdBook = bookService.createBook(bookDTO);

        // Assert
        assertNotNull(createdBook);
        assertEquals("Spring in Action", createdBook.getTitle());
        assertEquals("Craig Walls", createdBook.getAuthor());
        assertEquals(39.99, createdBook.getPrice(), 0.001);
    }
}
  1. Executar o Teste (que falhará inicialmente):

    • Execute o teste. Como o método createBook ainda não está implementado, o teste falhará.
  2. Implementar o Código Funcional:

    • Implemente o método createBook na classe BookService para atender aos requisitos do teste.
@Service
public class BookService {

    @Autowired
    private BookRepository bookRepository;

    public Book createBook(BookDTO bookDTO) {
        Book book = new Book(bookDTO.getTitle(), bookDTO.getAuthor(), bookDTO.getPrice());
        return bookRepository.save(book);
    }
}
  1. Escrever o Segundo Teste:
    • Crie um teste que valida a capacidade de recuperar todos os livros cadastrados.
@Test
public void testGetAllBooks() {
    // Arrange
    // Crie alguns livros de teste e salve-os no repositório

    // Act
    List<Book> books = bookService.getAllBooks();

    // Assert
    assertNotNull(books);
    assertEquals(3, books.size()); // Supondo que três livros foram salvos
}
  1. Executar o Teste (que falhará inicialmente):

    • Execute o teste. Como o método getAllBooks ainda não está implementado, o teste falhará.
  2. Implementar o Código Funcional:

    • Implemente o método getAllBooks na classe BookService para atender aos requisitos do teste.
public List<Book> getAllBooks() {
    return bookRepository.findAll();
}
  1. Escrever o Terceiro Teste:
    • Crie um teste que valida a capacidade de atualizar as informações de um livro.
@Test
public void testUpdateBook() {
    // Arrange
    // Crie um livro de teste e salve-o no repositório

    // Atualize as informações do livro

    // Act
    Book updatedBook = bookService.updateBook(bookId, updatedBookDTO);

    // Assert
    assertNotNull(updatedBook);
    assertEquals(updatedTitle, updatedBook.getTitle());
    // Adicione mais asserções conforme necessário
}
  1. Executar o Teste (que falhará inicialmente):

    • Execute o teste. Como o método updateBook ainda não está implementado, o teste falhará.
  2. Implementar o Código Funcional:

    • Implemente o método updateBook na classe BookService para atender aos requisitos do teste.
public Book updateBook(Long bookId, BookDTO updatedBookDTO) {
    // Lógica para recuperar o livro pelo ID, atualizar as informações e salvar
}
  • Repita o processo para outras funcionalidades, como exclusão de livros, validações, etc.
  • Sempre comece escrevendo um teste que falhe, implemente o código funcional para passar no teste e, em seguida, refatore conforme necessário.

Esse processo garante, além da identificação daqueles métodos de suma importancia para o teste, a eficácia, visto que estes cenários serão, por natureza desta abordagem, analisados e elucidados primeiro.

2.2.2.1. A heurística

Em vez de adiar a criação de testes, o TDD propõe que cada nova funcionalidade ou melhoria comece com a definição de testes que representam o comportamento desejado.

Porém, para que tudo funcione de forma organica, é necessário conhecer os limites do TDD, a fim de não o imputar responsabilidades que não são suas. A saber:

  • O TDD não exime o código da necessária boa abstração, baixo acoplamento e alta coesão são fundamentais em qualquer cenário;
  • A qualidade do teste depende da habilidade do desenvolvedor em traduzir os cenários em código e os criterios de aceite ou regras de negócio em cenários de teste;
  • TDD auxilia na construção do ‘design’ do ‘software’ e, apesar de ser um grande orientador, não é sua responsabilidade definir a arquitetura;
  • É natural que o TDD pareça mais demorado, a princípio. A sua responsabilidade não é acelerar o desenvolvimento, mas garantir que caminhe com confiança, independente da velocidade;

Para auxiliar na evolução da prática com o TDD, um exemplo do processo mental de desenvolvimento de uma funcionalidade com TDD:

  1. Análise dos requisitos/insumos;
  2. Validação do entendimento com os insumos disponíveis;
  3. Catálogo das regras de negócio e/ou critérios de aceite;
  4. Separação categórica e específica das necessidades do usuário;
  5. Transformação das RN/CA em cenários de teste;
  6. Para cada uma das regras de negócio e/ou critérios de aceite devem ser avaliados os resultados esperados em diferentes condições de execução;
  7. Transformação dos cenários de teste em teste unitários;
  8. Para cada cenário espeficicado deve ser criádo um método que o represente e o valide;
  9. Podem ser apenas métodos com um nome que identifique o seu propósito;
  10. Criação dos componentes de ‘software’ necessários a abstração mais básica do problema a ser resolvido;
  11. Em orientação a objetos, por boa prática, o desenvolvimento acontece em camadas que representam responsabilidades distintas;
  12. Estas camadas são excelentes candidatas a construção mais abstrata do objetivo da funcionalidade;
  13. MVC é um excelente exemplo de separação dessas responsabilidades onde, por exemplo, podemos testar apenas a camada de serviço;
  14. Em Java uma forma inteligente de construir essas camadas é utilizando classes abstratas, ou preferivelmente, interfaces;
  15. Concretizar o abstrato, implementando a lógica que atende a necessidade;
  16. A implementação fica escondida atrás das camadas ou abstrações que o teste conhece;
  17. Esta etapa é onde afinal o desenvolvedor deve passar a maioria do tempo;
  18. Executar os testes e verificar se o resultado esperado foi atendido pela implementação;
  19. Caso tenha não tenham sido atendidos, é sinal de que o TDD serviu o seu propósito e impediu que esta versão não seja aceita;
  20. Refatorar caso necessário;
  21. Quanto mais coerente estiver a tradução dos RA's e CA's em cenários de teste, menor será necessária a alteração do código de teste unitário;
  22. É sinal de eficácia que seja mínima a alteração no código do teste enquanto acontece a refatoração do código real;

O desenvolvedor é guiado pela evolução contínua dos testes unitários, proporcionando confiança na qualidade do software.


2.2.3. Abstração dos métodos

É de suma importancia que as melhores práticas de orientação a objetos sejam aplicadas ao escrever testes. Uma vez que, mesmo não utilizando técnicas como o TDD, o desenvolvedor tende a focar demais em detalhes da implementação.

Para demonstrar o poder de uma boa abstração mesmo na camada de testes, vamos considerar um exemplo em que a utilização de interfaces ajuda a manter a estabilidade dos testes durante mudanças na implementação específica. Neste caso, criaremos um serviço simples de autenticação em uma aplicação Spring Boot.

1. Definindo a Interface de Serviço:

Vamos começar criando uma interface AuthService que define as operações relacionadas à autenticação.

public interface AuthService {
    boolean authenticate(String username, String password);
}

2. Implementando a Lógica de Autenticação:

Agora, criamos uma implementação inicial da interface AuthService. Neste exemplo, a autenticação sempre retorna true para qualquer combinação de usuário e senha (para simplificar).

@Service
public class SimpleAuthService implements AuthService {

    @Override
    public boolean authenticate(String username, String password) {
        // Lógica de autenticação simplificada
        return true;
    }
}

3. Escrevendo um Teste para SimpleAuthService:

Vamos escrever um teste usando JUnit e Mockito para a classe SimpleAuthService.

@RunWith(MockitoJUnitRunner.class)
public class SimpleAuthServiceTest {

    @InjectMocks
    private SimpleAuthService authService;

    @Test
    public void testAuthenticationSuccess() {
        boolean result = authService.authenticate("user123", "password123");
        assertTrue(result);
    }

    // Possíveis outros casos de teste...
}

4. Adicionando uma Nova Implementação sem Alterar o Teste:

Agora, imagine que queremos melhorar a segurança e introduzir uma nova implementação mais complexa da autenticação. Vamos criar uma nova classe SecureAuthService implementando a mesma interface.

@Service
public class SecureAuthService implements AuthService {

    @Override
    public boolean authenticate(String username, String password) {
        // Nova lógica de autenticação mais segura
        return username.equals("user123") && password.equals("securePassword123");
    }
}

5. Atualizando a Configuração da Aplicação:

Vamos configurar a aplicação Spring Boot para usar a nova implementação.

@Configuration
public class AppConfig {

    @Bean
    public AuthService authService() {
        return new SecureAuthService();
    }
}

6. Testando a Nova Implementação sem Alterar os Testes:

Os testes para SimpleAuthService ainda permanecem os mesmos, e podemos adicionar testes específicos para SecureAuthService sem modificar os testes existentes.

@RunWith(MockitoJUnitRunner.class)
public class SecureAuthServiceTest {

    @InjectMocks
    private SecureAuthService secureAuthService;

    @Test
    public void testSecureAuthenticationSuccess() {
        boolean result = secureAuthService.authenticate("user123", "securePassword123");
        assertTrue(result);
    }

    @Test
    public void testSecureAuthenticationFailure() {
        boolean result = secureAuthService.authenticate("user123", "wrongPassword");
        assertFalse(result);
    }

    // Possíveis outros casos de teste...
}

Benefícios da Utilização de Interfaces no TDD:

  1. Estabilidade dos Testes: Como os testes são baseados na interface, podemos introduzir novas implementações sem afetar os testes existentes, desde que a interface permaneça consistente.

  2. Substituição de Implementações: A flexibilidade proporcionada pela interface permite a substituição fácil de implementações, promovendo a manutenibilidade do código.

  3. Facilidade de Testabilidade: A abstração através da interface facilita a criação de mocks para testes unitários, isolando o código específico da implementação.

A utilização de interfaces contribui para a criação de código mais modular e testável.

2.2.4. Nomeando os testes

Nomear corretamente os testes unitários é crucial para tornar o código mais legível e para facilitar a identificação das regras de negócio ou critérios de aceite que estão sendo validados, algumas práticas fundamentais são:

1. Utilize Nomes Descritivos:

Dê nomes descritivos que expressam claramente o comportamento que está sendo testado. Evite nomes genéricos.

@Test
public void shouldCreateTaskWhenValidDescriptionProvided() {
    // ...
}

2. Siga a Convenção "should"/"shouldNot":

Usar a convenção "should" ou "shouldNot" ajuda a criar testes que leem como afirmações claras sobre o comportamento esperado.

@Test
public void shouldNotAllowEmptyDescriptionForTaskCreation() {
    // ...
}

3. Reflita as Condições de Execução:

Inclua informações sobre as condições de execução no nome do teste para indicar o contexto específico.

@Test
public void shouldNotAllowTaskCompletionForIncompleteTasks() {
    // ...
}

4. Utilize Nomes de Métodos Concisos:

Mantenha os nomes de métodos curtos e concisos, concentrando-se na ação principal sendo testada.

@Test
public void shouldRetrieveAllCompletedTasks() {
    // ...
}

5. Nomeie Casos de Borda e Casos Típicos:

Se houver casos de borda ou casos típicos relevantes para a regra de negócio, reflita isso nos nomes dos testes.

@Test
public void shouldHandleEdgeCaseWhenTaskListIsEmpty() {
    // ...
}

6. Use Notação CamelCase:

Siga a notação CamelCase para garantir consistência e clareza nos nomes dos testes.

@Test
public void shouldNotAllowDuplicateTaskDescriptions() {
    // ...
}

7. Reflita o Comportamento Positivo e Negativo:

Quando apropriado, nomeie testes para refletir tanto o comportamento positivo quanto o negativo.

@Test
public void shouldAllowTaskDeletionWhenExists() {
    // ...
}

@Test
public void shouldNotAllowTaskDeletionWhenDoesNotExist() {
    // ...
}

8. Documente as Intenções do Teste:

Adicione breves comentários para documentar as intenções do teste, fornecendo contexto adicional quando necessário.

@Test
// Verifica se a tarefa é marcada como concluída ao chamar o método completeTask()
public void shouldMarkTaskAsCompletedOnCompletion() {
    // ...
}

Ao seguir essas práticas, os testes unitários se tornam uma forma de documentação executável, fornecendo informações claras sobre o comportamento esperado do código em relação às regras de negócio ou critérios de aceite. Isso facilita a manutenção, a compreensão do código e a identificação rápida de falhas quando necessário.

O teste bem nomeado e descritivo serve como documentação viva do software. Aproximar as nomenclaturas ou códigos de referência para regras de negócio, ou critérios de aceite é uma excelente prática.

2.2.5. Formato dos testes

Todo teste, por ser focado em cenários, possui uma sequência básica: entreda de dados, processamento e resposta. Uma forma otimizada de escrever o teste é utilizando um pequeno roteiro visando essa sequencia.

O roteiro "Given, When, Then" é uma abordagem de escrita de testes que ajuda a estruturar os casos de teste de maneira clara e compreensível. Essa abordagem segue uma sequência lógica, destacando a configuração inicial (Given), a ação ou evento que está sendo testado (When) e as expectativas ou resultados esperados (Then).

Sobre a motivação de utilizar esta estrutura: - Proporciona uma leitura natural e compreensível, tornando os testes mais acessíveis para desenvolvedores e membros da equipe. - Além disso, a divisão em seções distintas ajuda a organizar o código do teste de maneira lógica, facilitando a identificação da configuração inicial, da ação principal e das verificações de resultado. - Cada seção tem um propósito claro, permitindo que o leitor do teste se concentre na lógica específica de configuração, execução e verificação, separando as preocupações. - Também facilita a manutenção do teste. Se for necessário atualizar o código, fica claro em qual seção a alteração deve ser feita.

Vamos ilustrar como isso pode ser utilizado:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class TaskServiceTest {

    @Autowired
    private TaskService taskService;

    @Test
    public void givenValidDescription_whenCreateTask_thenTaskIsCreatedSuccessfully() {
        // Given
        String validDescription = "Sample Task";

        // When
        Task createdTask = taskService.createTask(validDescription);

        // Then
        assertEquals(validDescription, createdTask.getDescription());
        // Verifica outras expectativas, se necessário
    }

    @Test
    public void givenEmptyDescription_whenCreateTask_thenTaskCreationFails() {
        // Given
        String emptyDescription = "";

        // When
        Task createdTask = taskService.createTask(emptyDescription);

        // Then
        assertEquals(null, createdTask);
        // Verifica outras expectativas, se necessário
    }

    // Outros casos de teste podem seguir a mesma estrutura "Given, When, Then"
}

No exemplo:

  • Given: Configuramos o estado inicial (uma descrição válida ou inválida).
  • When: Executamos a ação que está sendo testada (chamamos o método createTask).
  • Then: Verificamos os resultados esperados com as asserções.

Essa estrutura torna o teste mais claro, modular e fácil de entender. Cada parte tem uma responsabilidade específica, contribuindo para a qualidade e manutenção dos testes.

2.3. Como manter o teste atualizado?

Agora que definimos o que deve ser testado e como pode ser testado, vamos estruturar como manter o teste relevante. Ora, se o software evolui o teste deve também evoluir, entretanto, de uma forma menos instável.

Podemos destacar uma maxima no desenvolvimento do teste defendidada por Kent Beck que é:

O teste robusto é aquele que apesar da mudança do código, se mantém inalterado e coerente.

Assim, considerando que o teste deve validar o objetivo da funcionalidade, ele só deveria mudar caso o objetivo mude.

Vamos explorar isso com um exemplo:

Cenário Inicial:

Suponha que você tenha uma classe Calculator com um método add:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

E um teste unitário associado:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }
}

Evolução do Código Sem Afetar o Teste:

Agora, imagine que você decide otimizar a implementação do método add:

public class Calculator {
    public int add(int a, int b) {
        // Versão otimizada, mantendo a mesma funcionalidade
        return Math.addExact(a, b);
    }
}

A otimização não altera a lógica subjacente do método; ele ainda realiza a adição, mas de uma forma mais robusta. O teste unitário original permanece inalterado e continua a verificar a mesma regra de negócio, apesar da evolução do código.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }
}

Pode haver exceções a esse princípio. Se a mudança no código resultar numa alteração significativa na lógica de negócios ou na funcionalidade, então os testes devem ser adaptados para refletir essa mudança. O objetivo é manter um equilíbrio entre a estabilidade dos testes e a adaptação a mudanças reais nos requisitos ou na lógica do sistema.