Testando por exemplo

Publicados: 2022-02-15

Continuamos com nossa série de blogs sobre tudo relacionado a testes. Neste blog, estamos focando em exemplos reais.

Embora os exemplos neste post sejam escritos usando JUnit 5 e AssertJ, as lições são aplicáveis ​​a qualquer outra estrutura de teste de unidade.

JUnit é o framework de teste mais popular para Java. AssertJ é uma biblioteca Java que ajuda os desenvolvedores a escrever testes mais expressivos.

Estrutura básica de teste

O primeiro exemplo de teste que veremos é uma calculadora simples para somar 2 números.

 class CalculadoraDeve {

     @Teste // 1
     void soma() {
         Calculadora calculadora = new Calculadora(); // 2
         int resultado = calculadora.sum(1, 2); // 3
         assertThat(resultado).isEqualTo(3); // 4
     }
}

Prefiro usar a convenção de nomenclatura ClassShould ao escrever testes para evitar repetir should ou test em cada nome de método. Você pode ler mais sobre isso aqui.

O que o teste acima faz?

Vamos quebrar o teste linha por linha:

  1. A anotação @Test permite que o framework JUnit saiba quais métodos devem ser executados como testes. É perfeitamente normal ter métodos private na classe de teste que não sejam testes.
  2. Esta é a fase de organização do nosso teste, onde preparamos o ambiente de teste. Tudo o que precisamos para este teste é ter uma instância Calculator .
  3. Esta é a fase do ato em que acionamos o comportamento que queremos testar.
  4. Esta é a fase de assert , em que inspecionamos o que aconteceu e se tudo foi resolvido conforme o esperado. O método assertThat(result) faz parte da biblioteca AssertJ e tem várias sobrecargas.

Cada sobrecarga retorna um objeto Assert especializado. O objeto retornado possui métodos que fazem sentido para o objeto que passamos para o método assertThat . No nosso caso, esse objeto é AbstractIntegerAssert com métodos para testar Integers. isEqualTo(3) verificará se result == 3 . Se for, o teste será aprovado e, caso contrário, falhará.

Não vamos nos concentrar em nenhuma implementação nesta postagem do blog.

Outra maneira de pensar sobre Arranjar , Agir , Afirmar é Dado , Quando , Então .

Depois de escrevermos nossa implementação de sum , podemos nos fazer algumas perguntas:

  • Como posso melhorar neste teste?
  • Existem mais casos de teste que devo cobrir?
  • O que acontece se eu somar um número positivo e um negativo? Dois números negativos? Um positivo e um negativo?
  • E se eu estourar o valor inteiro?

Vamos adicionar esses casos e melhorar um pouco o nome do teste existente.

Não permitiremos overflows em nossa implementação. Se a sum estourar, lançaremos uma ArithmeticException em vez disso.

 class CalculadoraDeve {

     calculadora calculadora privada = new Calculadora();

     @Teste
     void sumPositiveNumbers() {
         int soma = calculadora.sum(1, 2);
         assertThat(soma).isEqualTo(3);
     }

     @Teste
     void sumNegativeNumbers() {
         int soma = calculadora.sum(-1, -1);
         assertThat(soma).isEqualTo(-2);
     }

     @Teste
     void somaPositiveAndNegativeNumbers() {
         int soma = calculadora.sum(1, -2);
         assertThat(soma).isEqualTo(-1);
     }

     @Teste
     void failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() -> calculadora.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit criará uma nova instância de CalculatorShould antes de executar cada método @Test . Isso significa que cada CalculatorShould terá uma calculator diferente para que não tenhamos que instanciar em todos os testes.

O teste shouldFailWithArithmeticExceptionWhenOverflown usa um tipo diferente de assert . Ele verifica se um pedaço de código falhou. assertThatThrownBy executará o lambda que fornecemos e garantirá que ele falhou. Como já sabemos, todos os métodos assertThat retornam um Assert especializado que nos permite verificar qual tipo de exceção ocorreu.

Este é um exemplo de como podemos testar se nosso código falha quando esperamos. Se em algum momento refatorarmos a Calculator e ela não lançar ArithmeticException em um estouro, nosso teste falhará.

Padrão de design ObjectMother

O próximo exemplo é uma classe validadora para garantir que uma instância Person seja válida.

 class PersonValidatorDeve {

    private Validator PersonValidator = new PersonValidator();

    @Teste
    void falhaQuandoNomeIsNull() {
        Pessoa pessoa = new Pessoa(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(pessoa))
            .isInstanceOf(InvalidPersonException.class);
    }

    @Teste
    void falhaQuandoAgeIsNegative() {
        Pessoa pessoa = new Pessoa("João", -5, new Endereço(...), ...);

        assertThatThrownBy(() -> validator.validate(pessoa))
            .isInstanceOf(InvalidPersonException.class);

    }
}

O padrão de projeto ObjectMother é frequentemente usado em testes que criam objetos complexos para ocultar os detalhes de instanciação do teste. Vários testes podem até criar o mesmo objeto, mas testar coisas diferentes nele.

O teste #1 é muito semelhante ao teste #2. Podemos refatorar PersonValidatorShould extraindo a validação como um método privado e, em seguida, passar instâncias de Person ilegais para ele, esperando que todas falhem da mesma maneira.

 class PersonValidatorDeve {

     private Validator PersonValidator = new PersonValidator();

     @Teste
     void falhaQuandoNomeIsNull() {
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @Teste
     void falhaQuandoAgeIsNegative() {
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     private void shouldFailValidation(Pessoa inválidaPessoa) {
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

Testando aleatoriedade

Como devemos testar a aleatoriedade em nosso código?

Vamos supor que temos um PersonGenerator que tem generateRandom para gerar instâncias aleatórias de Person .

Começamos escrevendo o seguinte:

 class PersonGeneratorShould {

     gerador de PersonGenerator privado = new PersonGenerator();

     @Teste
     void gerarValidPerson() {
         Pessoa pessoa = generator.generateRandom();
         assertThat(pessoa).
    }
}

E então devemos nos perguntar:

  • O que estou tentando provar aqui? O que essa funcionalidade precisa fazer?
  • Devo apenas verificar se a pessoa gerada é uma instância não nula?
  • Preciso provar que é aleatório?
  • A instância gerada precisa seguir algumas regras de negócio?

Podemos simplificar nosso teste usando injeção de dependência.

 public interface RandomGenerator {
     String gerarRandomString();
     int gerarRandomInteger();
}

O PersonGenerator agora tem outro construtor que também aceita uma instância dessa interface. Por padrão, ele usa a implementação JavaRandomGenerator que gera valores aleatórios usando java.Random .

No entanto, no teste, podemos escrever outra implementação mais previsível.

 @Teste
 void gerarValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     Gerador PersonGenerator = new PersonGenerator(randomGenerator);
     Pessoa pessoa = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

Este teste prova que o PersonGenerator gera instâncias aleatórias conforme especificado pelo RandomGenerator sem entrar em nenhum detalhe do RandomGenerator .

Testar o JavaRandomGenerator realmente não adiciona nenhum valor, pois é um wrapper simples em torno java.Random . Ao testá-lo, você estaria essencialmente testando java.Random da biblioteca padrão Java. Escrever testes óbvios só levará a manutenção adicional com pouco ou nenhum benefício.

Para evitar escrever implementações para fins de teste, como PredictableGenerator , você deve usar uma biblioteca simulada como Mockito.

Quando escrevemos PredictableGenerator , na verdade, rascunhamos a classe RandomGenerator manualmente. Você também poderia tê-lo stub usando Mockito:

 @Teste
 void gerarValidPerson() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("João Silva");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     Gerador PersonGenerator = new PersonGenerator(randomGenerator);
     Pessoa pessoa = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

Essa forma de escrever testes é mais expressiva e leva a menos implementações para testes específicos.

Mockito é uma biblioteca Java para escrever mocks e stubs. É muito útil ao testar código que depende de bibliotecas externas que você não pode instanciar facilmente. Ele permite que você escreva o comportamento dessas classes sem implementá-las diretamente.

O Mockito também permite outra sintaxe para criar e injetar mocks para reduzir o clichê quando temos mais de um teste semelhante ao que estamos acostumados:

 @ExtendWith(MockitoExtension.class) // 1
 class PersonGeneratorShould {

     @Mock // 2
     RandomGenerator randomGenerator;

     @InjectMocks // 3
     gerador de PersonGenerator privado;

     @Teste
     void gerarValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("João Silva");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Pessoa pessoa = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5 pode usar “extensões” para estender seus recursos. Essa anotação permite reconhecer mocks por meio de anotações e injetá-los corretamente.

2. A anotação @Mock cria uma instância simulada do campo. Isso é o mesmo que escrever mock(RandomGenerator.class) no corpo do nosso método de teste.

3. A anotação @InjectMocks criará uma nova instância de PersonGenerator e injetará mocks na instância do generator .

Para mais detalhes sobre as extensões JUnit 5, veja aqui.

Para mais detalhes sobre injeção de Mockito veja aqui.

Há uma armadilha em usar @InjectMocks . Isso pode remover a necessidade de declarar uma instância do objeto manualmente, mas perdemos a segurança em tempo de compilação do construtor. Se em algum momento alguém adicionar outra dependência ao construtor, não obteríamos o erro de tempo de compilação aqui. Isso pode levar a testes com falha que não são fáceis de detectar. Eu prefiro usar @BeforeEach para configurar a instância manualmente:

 @ExtendWith(MockitoExtension.class)
class PersonGeneratorShould {

     @Zombar
     RandomGenerator randomGenerator;

     gerador de PersonGenerator privado;

     @BeforeEach
     void configuração() {
         gerador = new PersonGenerator(randomGenerator);
     }

     @Teste
     void gerarValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("João Silva");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         Pessoa pessoa = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

Como testar processos sensíveis ao tempo

Um pedaço de código geralmente depende de timestamps e tendemos a usar métodos como System.currentTimeMillis() para obter o timestamp da época atual.

Embora isso pareça bom, é difícil testar e provar se nosso código funciona corretamente quando a classe toma decisões por nós internamente. Um exemplo de tal decisão seria determinar qual é o dia atual.

 class IndexadorDeve {
     indexador indexador privado = new Indexador();
     @Teste
     void generateIndexNameForAmanhã() {
         String indexName = indexer.tomorrow("meu-índice");
         // este teste funcionaria hoje, mas e amanhã?
        assertThat(indexName)
           .isEqualTo("meu-index.2022-02-02");
     }
}

Devemos usar Injeção de Dependência novamente para poder 'controlar' qual é o dia ao gerar o nome do índice.

Java tem uma classe Clock para lidar com casos de uso como este. Podemos passar uma instância de Clock para nosso Indexer para controlar a hora. O construtor padrão pode usar Clock.systemUTC() para compatibilidade com versões anteriores. Agora podemos substituir as chamadas System.currentTimeMillis() por clock.millis() .

Ao injetar um Clock podemos impor um tempo previsível em nossas classes e escrever testes melhores.

Testando métodos de produção de arquivos

  • Como devemos testar classes que gravam sua saída em arquivos?
  • Onde devemos armazenar esses arquivos para que funcionem em qualquer sistema operacional?
  • Como podemos ter certeza de que o arquivo ainda não existe?

Ao lidar com arquivos, pode ser difícil escrever testes se tentarmos resolver esses problemas nós mesmos, como veremos no exemplo a seguir. O teste que se segue é um teste antigo de qualidade duvidosa. Ele deve testar se um DogToCsvWriter serializa e grava cães em um arquivo CSV:

 class DogToCsvWriterShould {

     private DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @Teste
     void convertToCsv() {
         escritor.appendAsCsv(new Dog(Raça.CORGI, Cor.MARROM, "Monty"));
         escritor.appendAsCsv(new Dog(Raça.MALTESE, Cor.BRANCO, "Zoe"));

         String csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,marrom\nZoe,maltês,branco");
     }
}

O processo de serialização deve ser desacoplado do processo de escrita, mas vamos nos concentrar na correção do teste.

O primeiro problema com o teste acima é que ele não funcionará no Windows, pois os usuários do Windows não poderão resolver o caminho /tmp/dogs.csv . Outro problema é que ele não funcionará se o arquivo já existir, pois ele não será excluído quando o teste acima for executado. Pode funcionar bem em um pipeline de CI/CD, mas não localmente se executado várias vezes.

JUnit 5 tem uma anotação que você pode usar para obter uma referência a um diretório temporário que é criado e excluído pela estrutura para você. Embora o mecanismo de criação e exclusão de arquivos temporários varie de estrutura para estrutura, as ideias permanecem as mesmas.

 class DogToCsvWriterShould {

     @Teste
     void convertToCsv(@TempDir Path tempDir) {
         Caminho dogsCsv = tempDir.resolve("dogs.csv");
         Escritor DogToCsvWriter = new DogToCsvWriter(dogsCsv);
         escritor.appendAsCsv(new Dog(Raça.CORGI, Cor.MARROM, "Monty"));
         escritor.appendAsCsv(new Dog(Raça.MALTESE, Cor.BRANCO, "Zoe"));

         String csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,marrom\nZoe,maltês,branco");
     }
}

Com essa pequena mudança, agora temos certeza de que o teste acima funcionará no Windows, macOS e Linux sem precisar se preocupar com caminhos absolutos. Ele também excluirá os arquivos criados após o teste para que agora possamos executá-lo várias vezes e obter resultados previsíveis a cada vez.

Teste de comando vs consulta

Qual é a diferença entre um comando e uma consulta?

  • Command : instruímos um objeto a realizar uma ação que produz um efeito sem retornar um valor (métodos void)
  • Consulta : pedimos a um objeto que execute uma ação e retorne um resultado ou uma exceção

Até agora, testamos principalmente consultas em que chamamos um método que retornou um valor ou lançou uma exceção na fase de ato. Como podemos testar métodos void e ver se eles interagem corretamente com outras classes? Os frameworks fornecem um conjunto diferente de métodos para escrever esses tipos de testes.

As asserções que escrevemos até agora para consultas estavam começando com assertThat . Ao escrever testes de comando, usamos um conjunto diferente de métodos porque não estamos mais inspecionando os resultados diretos dos métodos como fazíamos com as consultas. Queremos 'verificar' as interações que nosso método teve com outras partes do nosso sistema.

 @ExtendWith(MockitoExtension.class)
 class FeedMentionServiceShould {

     @Zombar
     repositório privado FeedRepository;

     @Zombar
     emissor privado FeedMentionEventEmitter;

     serviço privado FeedMentionService;

     @BeforeEach
     void configuração() {
         serviço = new FeedMentionService(repositório, emissor);
     }

     @Teste
     void insertMentionToFeed() {
         longo feedId = 1L;
         Menção menção = ...;

         when(repository.upsertMention(feedId, menção))
             .thenReturn(UpsertResult.success(feedId, menção));

         Evento FeedInsertionEvent = new FeedInsertionEvent(feedId, menção);
         mencionarService.insertMentionToFeed(evento);

         verifique(emissor).mençãoInsertedToFeed(feedId, menção);
         verifiqueNoMoreInteractions(emissor);
     }
}

Neste teste, primeiro zombamos de nosso repositório para responder com um UpsertResult.success quando solicitados a fazer uma menção upsert em nosso feed. Não estamos preocupados em testar o repositório aqui. Os métodos de repositório devem ser testados no FeedRepositoryShould . Ao zombar desse comportamento, na verdade não chamamos o método de repositório. Nós simplesmente dissemos a ele como responder na próxima vez que for chamado.

Em seguida, dissemos ao nosso mentionService para inserir essa menção em nosso feed. Sabemos que ele deve emitir o resultado somente se inserir com sucesso a menção no feed. Ao usar o método de verify , podemos ter certeza de que o método mentionInsertedToFeed foi chamado com nossa menção e feed e não foi chamado novamente usando verifyNoMoreInteractions .

Pensamentos finais

Escrever testes de qualidade vem da experiência, e a melhor maneira de aprender é fazendo. As dicas escritas neste blog vêm da prática. É difícil ver algumas das armadilhas se você nunca as encontrou e esperamos que essas sugestões tornem seu design de código mais robusto. Ter testes confiáveis ​​aumentará sua confiança para mudar as coisas sem suar a cada vez que você precisar implantar seu código.

Interessado em se juntar à equipe da Mediatoolkit?
Confira nossa vaga aberta para Desenvolvedor Frontend Sênior !