Testes unitários com JUnit

Contratos, casos limite, JUnit e Mockito, cobertura e testes de mutação.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

Numa arena de largura 5, as células válidas vão de 0 a 4. Um teste de contem(5) deve esperar false, mesmo que a implementação aceite esse valor por engano. O esperado vem do contrato da arena; copiar a condição do programa para o teste repetiria o erro.

Escolher o que verificar

Um teste unitário verifica uma unidade pequena através do seu comportamento observável. Pode abranger vários objetos simples que colaboram. Um teste de integração verifica a colaboração com uma fronteira real, como leitura de ficheiros. Um teste do sistema percorre a aplicação completa.

Deriva os casos de partições do contrato antes de escreveres código de teste. Para contem com largura 5, há três partições: dentro (0 a 4), abaixo (< 0) e acima (>= 5). Escolhe um representante de cada, mais as fronteiras exatas 0, 4, -1 e 5. Isto é seleção black-box: usa o contrato sem depender da estrutura interna. Testar largura zero também é black-box quando o contrato do construtor a proíbe. Seleção white-box usa a implementação para procurar percursos ainda não exercitados; não muda a origem do resultado esperado.

Uma largura zero ou negativa não representa uma arena válida e deve ser rejeitada, e esta exceção pertence ao contrato de construção; uma célula exterior, por sua vez, não exige uma exceção, pois contem responde false a entradas fora do domínio normal da operação.

Prepara o cenário, executa a ação e verifica o resultado, Arrange, Act, Assert. Um comportamento pode exigir várias asserções relacionadas. Por exemplo, mover deve produzir a posição certa e preservar a origem quando a operação promete imutabilidade.

O primeiro teste com JUnit

Os exemplos desta página correm no projeto Gradle de controlo de versões, com JUnit 5.13.4 e Mockito 4.11.0. São ficheiros locais completos. O editor Java do site não carrega essas bibliotecas.

Guarda src/main/java/Limite.java:

public final class Limite {
    private final int largura;

    public Limite(int largura) {
        if (largura <= 0) throw new IllegalArgumentException("largura invalida");
        this.largura = largura;
    }

    public boolean contem(int x) {
        return x >= 0 && x < largura;
    }
}

Guarda src/test/java/LimiteTest.java:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

class LimiteTest {
    @ParameterizedTest
    @ValueSource(ints = {0, 2, 4})
    void aceitaCelulasInteriores(int x) {
        Limite limite = new Limite(5);
        boolean resultado = limite.contem(x);
        assertTrue(resultado);
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, 5, 6})
    void rejeitaCelulasExteriores(int x) {
        assertFalse(new Limite(5).contem(x));
    }

    @Test
    void rejeitaLarguraZero() {
        assertThrows(IllegalArgumentException.class, () -> new Limite(0));
    }

    @Test
    void rejeitaLarguraNegativa() {
        assertThrows(IllegalArgumentException.class, () -> new Limite(-1));
    }
}

Corre ./gradlew test. Os testes parametrizados fazem uma execução por argumento. A falha identifica o valor que não cumpriu o contrato. assertThrows executa a ação e confirma a exceção esperada, incluindo subclasses compatíveis.

Quando o contrato envolve um cálculo aproximado de vírgula flutuante, usa uma tolerância explícita adequada ao erro numérico esperado, em vez de exigir igualdade bit a bit. Para objetos, assertEquals usa igualdade lógica; assertSame exige identidade.

Este vídeo mostra testes com JUnit 5, distinguindo em cada caso a preparação, a ação e a asserção:

Abrir no YouTube

Independência dos testes

Um teste não deve depender da ordem, do estado deixado por outro teste ou de uma pausa arbitrária. JUnit Jupiter cria normalmente uma instância da classe por método de teste. @BeforeEach prepara estado antes de cada execução e @AfterEach liberta recursos depois.

@BeforeAll e @AfterAll tratam preparação e limpeza da classe inteira. Sob o ciclo de vida predefinido, estes métodos são estáticos. Evita partilhar estado mutável entre testes por conveniência.

Se o resultado depende de tempo, aleatoriedade ou I/O, injeta um colaborador que o teste controla. Um teste que só confirma que o método não lançou exceção pode ser útil para esse contrato, mas não verifica automaticamente o resultado calculado.

Mocks e stubs com Mockito

Um test double substitui um colaborador. Um stub fornece respostas controladas. Um fake é uma implementação simplificada funcional, como armazenamento em memória. Um mock permite verificar chamadas esperadas. O mesmo objeto criado com Mockito pode servir de stub numa interação e de mock noutra; a distinção descreve o uso no teste.

Vamos testar um controlador que lê uma ação e envia a posição resultante à vista. A string "direita" move uma célula; outras ações mantêm a posição. Guarda src/main/java/Controlador.java:

interface Entrada {
    String ler();
}

interface Vista {
    void mostrar(int x);
}

public final class Controlador {
    private final Entrada entrada;
    private final Vista vista;
    private int x;

    public Controlador(Entrada entrada, Vista vista, int x) {
        this.entrada = entrada;
        this.vista = vista;
        this.x = x;
    }

    public void passo() {
        if ("direita".equals(entrada.ler())) x++;
        vista.mostrar(x);
    }
}

Guarda src/test/java/ControladorTest.java:

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

class ControladorTest {
    @Test
    void mostraPosicaoDepoisDeMover() {
        Entrada entrada = mock(Entrada.class);
        when(entrada.ler()).thenReturn("direita");
        Vista vista = mock(Vista.class);
        Controlador controlador = new Controlador(entrada, vista, 2);

        controlador.passo();

        verify(vista).mostrar(3);
    }

    @Test
    void acaoDesconhecidaMantemPosicao() {
        Entrada entrada = mock(Entrada.class);
        when(entrada.ler()).thenReturn("pausa");
        Vista vista = mock(Vista.class);
        Controlador controlador = new Controlador(entrada, vista, 2);

        controlador.passo();

        verify(vista).mostrar(2);
    }
}

A entrada é um stub, porque fornece uma resposta fixa. A vista é um mock, porque verificamos o resultado enviado. verify(vista).mostrar(3) exige uma chamada correspondente; times(2) exigiria duas, e never() nenhuma. Por si só, essa verificação não proíbe todas as outras chamadas possíveis.

Verifica interações quando são parte do contrato, como enviar o resultado à vista. Não congeles cada chamada interna de um algoritmo: uma refatoração válida pode mudar essas chamadas e preservar o resultado. Para testar regras puras, prefere verificar o valor ou estado final. Também evita verificar uma chamada que já configuraste como stub só para confirmar que aconteceu: se o resultado depende dessa resposta, o teste falha sem ela. Reserva verify para interações que as asserções não observam, como o destino de um efeito, a sua quantidade ou a sua ordem.

Um spy envolve um objeto real e, por defeito, executa os seus métodos. when(spy.metodo()) também pode executar o método durante a configuração. doReturn(valor).when(spy).metodo() evita essa execução quando precisas de substituir a resposta. Um spy não elimina a necessidade de perceber os efeitos do objeto real.

Este vídeo mostra substitutos com Mockito, identificando a dependência substituída e a interação que faz parte do comportamento pretendido:

Abrir no YouTube

Cobertura e mutation testing

Cobertura mede código executado pelos testes. Cobertura de linhas pergunta se uma linha foi executada; cobertura de branches distingue alternativas de uma condição. Nenhuma das duas prova que as asserções reconhecem um erro.

Imagina um teste que chama contem(2) e não verifica nada. Executa a linha da condição, mas aceita tanto true como false. A cobertura pode subir sem melhorar a proteção.

O curto-circuito mostra outro limite da cobertura de linhas. Com largura 5, segue a avaliação de x >= 0 && x < largura. A primeira condição é x >= 0; a segunda é x < 5:

xPrimeiraSegundaResultado
-1falseNão avaliadafalse
2truetruetrue
5truefalsefalse

Só testar 2 executa a linha, mas não percorre as saídas falsas. Com -1, a segunda condição nem é avaliada; com 5, é ela que rejeita a célula. A tabela distingue cobertura da linha, resultados de cada condição avaliada e resultado global. Confere o critério exato do relatório antes de interpretar a percentagem.

Mutation testing altera o programa em pequenas operações, por exemplo trocar < por <=. Um mutante é morto quando os testes falham com essa mudança. Se todos passam, o mutante sobrevive. Um mutante sem cobertura nem sequer teve esse comportamento exercitado.

No nosso Limite, o mutante troca x < largura por x <= largura. Com largura 5 e a condição inferior x >= 0 mantida:

xContrato e originalMutanteDistingue?
2truetrueNão
5falsetrueSim
6falsefalseNão

O teste exterior com 5 mata esse mutante porque espera false. Testar só 2 e 6 não distingue as condições, mesmo com asserções.

Um mutante sobrevivente pode indicar uma falta nos testes, mas também pode ser equivalente, sem mudança observável dentro do domínio válido. Analisa o efeito antes de acrescentar uma asserção sem relação com o contrato.

O Gradle da primeira lição já tem JaCoCo. ./gradlew jacocoTestReport gera cobertura. Para executar mutação neste projeto de treino, acrescenta id 'info.solidsoft.pitest' version '1.19.0' ao bloco plugins do mesmo build.gradle. No fim do ficheiro acrescenta:

pitest {
    pitestVersion = '1.19.4'
    junit5PluginVersion = '1.2.3'
    targetClasses = ['Limite']
    targetTests = ['LimiteTest']
    threads = 1
    outputFormats = ['HTML', 'XML']
    timestampedReports = false
}

Corre ./gradlew pitest e abre build/reports/pitest/index.html. As classes alvo são nomes completos: neste exemplo não há pacotes; num projeto com pacotes usa, por exemplo, pt.equipa.Limite. A integração JUnit é uma dependência da ferramenta de mutação, distinta das dependências dos testes. Estas versões formam um exemplo reproduzível; no projeto da cadeira preserva a configuração fornecida.

No relatório, escolhe a condição de contem e lê a mudança de cada mutante. Relaciona o mutante da fronteira superior com o teste de x == 5, em vez de concluir apenas que uma percentagem alta torna o programa correto.

Trabalhar com um teste que falha

Num ciclo de desenvolvimento guiado por testes, começa com uma exigência pequena e observa a falha pelo motivo esperado. Implementa a mudança mínima que cumpre o contrato, observa o teste passar e só depois melhora a estrutura.

Um erro de importação não prova que o teste deteta o comportamento. E ver verde antes de existir uma implementação pode revelar que o teste não verifica o requisito.

Property-based testing gera entradas e verifica uma propriedade em vez de enumerar só exemplos fixos. Para uma posição imutável, mover à direita deve aumentar x em um e conservar y para cada entrada gerada, dentro do domínio aritmético escolhido. As apresentações usam a biblioteca jqwik na JVM: @ForAll declara entradas geradas, hipóteses restringem o domínio e o shrinking reduz um contraexemplo a um caso pequeno. Um caso encontrado pode revelar uma fronteira esquecida; conserva-o como exemplo reproduzível. Este complemento não é enumerado separadamente na ficha atual.

Num defeito já existente, reproduz o comportamento pela interface pública mais próxima do utilizador e conserva esse caso como regressão. O teste tem de falhar pela diferença observada, não por um erro de preparação ou importação.

Exercícios

Matar um mutante na fronteira

O contrato aceita 0 ≤ x < 8. Um mutante troca x < 8 por x ≤ 8. Qual destes valores distingue as duas versões?

A tua resposta

Pista
Procura o valor em que < e <= dão respostas diferentes.
Mais uma pista
Esse valor é o próprio limite superior.
Solução

Testa x igual a 8 e espera falso. Raciocina por partições: 7 está dentro nas duas versões, 9 está fora nas duas, só 8 muda de falso para verdadeiro no mutante. O mutante produz verdadeiro, por isso a asserção falha e mata-o. Os valores 7 e 9 não distinguem as duas condições.

  • 7. As duas versões aceitam 7.
  • 8. A versão correta rejeita 8 e o mutante aceita-o.
  • 9. As duas versões rejeitam 9.

Erros frequentes

Cobrir a linha com um valor interior não prova que a fronteira está correta.

Voltar à explicação
Derivar fronteiras do contrato

O construtor de Limite exige largura positiva e lança IllegalArgumentException caso contrário. Para largura 5, contem(x) responde se x pertence às células 0 a 4. Escolhe casos para as fronteiras de ambas as operações e dá os resultados esperados. Testar largura zero é necessariamente white-box? Justifica sem consultar a implementação.

Pista

Uma entrada exterior de contem não tem a mesma resposta que uma largura inválida.

Solução

Para largura 5, contem(0), contem(2) e contem(4) dão true; contem(-1) e contem(5) dão false. -1/0 e 4/5 exercitam as fronteiras do intervalo. No construtor, 1 é a menor largura válida; 0 e -1 lançam a exceção indicada. Testar zero é black-box quando o caso é escolhido a partir desta regra. White-box significa que a estrutura interna orientou a seleção, não que o teste conhece o nome do construtor.

Confere a tua resposta:

  • Escolhi casos interiores e exteriores, incluindo -1, 0, 4 e 5.
  • Indiquei false para uma célula exterior e exceção para largura inválida.
  • Expliquei por que a largura zero pode ser selecionada sem ler a implementação.
Voltar à explicação
O que prova uma verificação?

Um teste contém apenas verify(vista).mostrar(3) depois da ação. Explica o que verifica e se proíbe uma chamada adicional a mostrar(2).

Pista
A verificação procura uma assinatura e argumentos concretos.
Mais uma pista
Ela não é uma afirmação sobre todas as chamadas possíveis.
Solução

Por defeito exige uma chamada a mostrar com argumento 3. Não proíbe uma chamada diferente a mostrar(2). Se o contrato exigir uma única apresentação, acrescenta a verificação adequada para essa exigência, sem congelar chamadas internas irrelevantes.

Confere a tua resposta:

  • Exige uma chamada correspondente a mostrar(3).
  • Reconhece que a chamada a mostrar(2) não é excluída por essa verificação.
  • Só acrescenta restrições de outras interações quando o contrato as exige.

Erros frequentes

Uma verificação de uma interação não significa automaticamente que não houve outras.

Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.