Alura > Cursos de Programação > Cursos de Clojure > Conteúdos de Clojure > Primeiras aulas do curso Clojure: geradores e testes de propriedades

Clojure: geradores e testes de propriedades

Geradores - Introdução

Vamos começar mais um curso de Clojure — aqui, falaremos sobre testes generativos e de propriedades, isto é, geraremos valores em um espaço de parâmetros, entendendo diversas maneiras de fazê-lo, com base em nossos esquemas, ou não. Anteriormente criávamos testes baseados em exemplos, passaremos a entender as vantagens e desvantagens destes, e veremos como podemos usar estas características dos testes generativos para explorar estes subespaços nos espaços de parâmetros, para encontrar erros e garantir propriedades do nosso sistema sob diversas condições.

Podemos testar situações em parâmetros válidos, inválidos, e em muitos exemplos distintos, em situações de unidade pequenas, ou maiores, integrando partes do código. Poderíamos lidar com integração entre sistemas distintos, também. Passaremos por tudo isso, inclusive por um exemplo bem complexo, em que simulamos entradas e transferências dentro do hospital de forma aleatória, em departamentos aleatórios, incluindo aqueles que não existem, de várias pessoas, e teremos que garantir que o funcionamento do hospital seja adequado e atenda os pacientes corretamente.

Alcançaremos um último teste, mais complexo, para garantir que o comportamento de um hospital que inclui todas as funções que geramos durante os cursos funcione de acordo com determinadas propriedades. Em um site de e-commerce, por exemplo, poderia ser importante garantir que o estoque nunca fique abaixo de zero, independentemente de como as vendas aconteçam, em qual ordem, como, quando e onde. Precisaríamos garantir que o produto não seja enviado antes do primeiro pagamento ser efetuado.

Então, não importa o que acontecer com nosso sistema, se os parâmetros estão válidos ou não, sua ordem, teremos que garantir que tudo aconteça conforme esperado. Ou seja, a propriedade precisa estar presente, independentemente da sequência, e de outros elementos. Trabalharemos com testes que alcançarão este nível de complexidade.

Espero que aproveite bastante este curso, que abre a nossa cabeça para outra maneira de enxergar como testar um sistema, e não só como escrever testes.

Geradores - Geradores

Vamos começar criando um projeto denominado "hospital", para o qual traremos o modelo e a lógica provenientes do curso anterior. Sendo assim, abriremos este arquivo em uma nova janela, e nele teremos model.clj, com fila-vazia, novo-hospital, com algumas filas específicas, novo-departamento e modelos de paciente, departamento e hospital. Duplicaremos este arquivo para o nosso projeto recém criado, a partir do qual continuaremos trabalhando.

Faremos o mesmo em relação a logic.clj, de que removeremos as variações de cabe-na-fila, que não utilizaremos, mantendo-se apenas uma das implementações. Teremos chega-em, atende e proxima, de cursos anteriores, removeremos os comentários, também manteremos mesmo-tamanho, para que se verifique os tamanhos de entrada e saída em pré condições de transfere.

Para fins de organização, poderemos incluir o comentário ; código do curso anterior antes destes trechos provenientes dos arquivos duplicados, tanto em model.clj quanto logic.clj. Para que possamos criar testes, deletaremos hospital.core-test, que não será utilizado, e criaremos logic_test.clj.

No projeto anterior, tínhamos um arquivo de mesmo nome, com alguns testes, dos quais copiaremos alguns para verificar se tudo está funcionando bem. Removeremos os comentários, e de chega-em-test em diante, também. E para conseguirmos rodar tudo isso, precisaremos das dependências. Vamos usar [prismatic/schema "1.1.12"]. Lembrando que quando salvamos project.clj, poderemos acessar Leiningen e pressionar a opção de "Refresh Leiningen Projects", sendo que às vezes o próprio IntelliJ nos pergunta se queremos fazer esta atualização.

Então, clicaremos com o lado direito do mouse sobre "project.clj" no painel de projetos e em "Run 'REPL for hospital'". Também é possível fazermos isso no Leiningen, clicando no ícone de seta verde após ativarmos a janela, que pode ser "escondida" posteriormente clicando-se na aba "REPL".

Vamos carregar e rodar os testes para garantir que tudo funciona como no curso anterior. Usaremos o atalho "Ctrl + Cmd + T", criado no curso anterior para que todos os testes sejam rodados. Poderemos ir a "Tools > REPL > Run tests in current NS in REPL". Teremos que há 6 assertions em um deftest, o que significa que tivemos o resultado desejado.

O que acontece é que os testes que criamos ao longo dos cursos são baseados em exemplos, e podemos incluisve incluir o comentário ; são testes ESCRITOS baseados em exemplos acima da linha (deftest cabe-na-fila?-test. Escrever testes é diferente de testar; há um artigo do Maurício Aniche, "Testing vs writing tests" que toca exatamente nesta questão.

Escrever testes ocorre à medida em que escrevemos o código, ou antes, ou depois, o que interfere no ganho de qualidade do código, design, e afins, enquanto testar é algo distinto, sendo que ambos precisam ser feitos. No artigo, são citados vários exemplos de quando uma aplicação é testada, algo sistemático. Há diversas maneiras de se criar testes, e de definir quais são aqueles que queremos realizar no nosso sistema, explorando o espaço de parâmetros de uma função, de ações de um usuário ou usuária com o sistema, e criar testes baseados nestas explorações.

Se houver um erro em produção, por exemplo, poderemos analisar os logs, baseados nos quais exploraremos e criaremos testes para o nosso sistema local. Alguns estudos atuais do Aniche seguem esta lógica, da IDE conseguir conectar com o sistema de monitoramento live para trazer mais informações, não necessariamente para testes, para quem estiver desenvolvendo.

Conforme já comentado anteriormente, aplicam-se técnicas nos testes baseados em exemplos, como o Boundary Testing, para explorar as bordas existentes nos nossos códigos, ou na complexidade do nosso sistema. Então, se temos um if que compara se o valor é maior ou igual a 500, por exemplo, testaremos os valores, 500, 501, 499, 0, e assim por diante.

Poderemos, também, separar em categorias de testes, Category-Partition, para testar quando o cliente é devedor ou não, quando ele é um professor ou não, ou seja, quando criamos categorias, neste caso binárias. E é possível fazer combinações destas categorias para explorar o espaço de parâmetros. Além disso, poderemos combinar o Boundary com o Category, e outras técnicas como Checklist, e afins, utilizar análises de complexidade ciclomática, uma ferramenta, ou de Convert de linha, cuja complexidade deve ser ainda mais interessante, tudo isso para a criação de testes.

Porém, nada disso resolveria o bug de comparar um valor para sabermos se ele é maior ou igual a 500, pois não testamos o valor negativo. Não estávamos preparados para isso, pois trata-se de um universo de opções. Testaremos o valor nulo? E o valor com ponto flutuante, como 499.5, ou 37.55, nosso código funcionará? Não testamos, não há como saber. Então, com base em exemplos, qual é o problema?

Há um texto, de 2019, chamado "In praise of property-based testing — Increment: Testing", de um autor famoso de uma biblioteca de Python, que fala sobre testes e problemas provenientes de testes com exemplos.

Testes com exemplos não devem ser desencorajados, pois são importantes para se manter a regressão, e por permitir a previsão de coisas que já conhecemos. Entretanto, não conseguiremos varrer todo o espaço de parâmetros de uma função desta forma, isto é, o domínio de uma função, para entendermos todos os resultados possíveis, por sua infinitude. Se for um booleano, será True ou False, talvez nulo, mas se recebemos um inteiro, já passamos a lidar com o infinito, principalmente no caso de Clojure, que transformará em BigInt.

Mesmo fazendo vários testes, não teremos como garantir nada.

Ainda, existem exemplos de lógica de erros que podem ocorrer, como no caso de um sistema de criptografia de senhas, que precisa garantir que ela seja capaz de ser descriptografada, ou, quando criptografada novamente, atinja o mesmo resultado anterior. De que forma testaremos isso para todos os valores de senha possíveis? E se a senha é muito grande, ou possui um caractere que não é ASCII, um caractere coreano, ou um emoji, por exemplo?

Temos que testar tudo isso, e se não lembrarmos disso, então, o Example-based, mesmo em uma situação de lógica, como neste exemplo de criptografia, de criação de hash de senha, pode não ser o ideal. Sendo assim, poderemos explorar todo o espaço de parâmetro, ou uma grande parte dela. Chegaremos à questão dos Property-based tests, mas para isso precisaremos gerar vários exemplos, para que possamos testar uma grande quantidade deles.

Exploraremos bibliotecas que geram valores para nós, não testes. Escreveremos tais testes, e o que validaremos com eles. Queremos uma ferramenta de testes de Clojure, que checa valores gerados automaticamente. A biblioteca test.check possui uma introdução (Introduction) sobre gerar valores para os testes, os Generators.

Claro, para que possamos utilizá-los, será necessário usar o test.check, portanto acessaremos project.clj e o adicionaremos junto às nossas dependências, na sua versão mais recente (existente na parte de "Leiningen"):

:dependencies [[org.clojure/clojure "1.10.0"]
              [prismatic/schema "1.1.12"]
              [org.clojure/test.check "0.10.0"]]

Lembrando que, por mais que esta versão já esteja estável e as pessoas estejam utilizando-a, de repente uma versão mais nova pode ter uma quebra de API, principalmente em se tratando de versão major, isto é bem comum, claro, dependendo da linguagem, ferramenta e o padrão utilizado para major e minor. Neste curso, utilizaremos esta versão, 0.10.0, e a sugestão é que você utilize-a também. Após o término do curso, você pode atualizar a versão e aprender suas vantagens e desvantagens, melhorias, isso em relação a qualquer biblioteca e curso da Alura.

Pausaremos o REPL e atualizaremos o Leiningen, e importaremos em h.logic-test aquilo que gerará valores para nós. Para isto, utilizaremos funções do tipo gen/sample, uma vez que "sample" remete à "amostragem", em português. Acrescentaremos isto em require:

(:require [clojure.test :refer :all]
          [hospital.logic :refer :all]
          [hospital.model :as h.model]
          [clojure.test.check.generators :as gen]
          [schema.core :as s]))

E, para explorarmos e entendermos o que este Generator faz, abriremos hospital.core, que deixaremos com o seguinte conteúdo:

(ns hospital.core
    (:require [clojure.test.check.generators :as gen]))
    
(gen/boolean)

Assim, geraremos um valor booleano, pois estes, pelo menos os simples são finitos, isto é, possuem apenas dois resultados possíveis. Claro, no momento em que o Clojure considera qualquer retorno exceto nulo e falso como true, teremos infinitos valores que poderão ser considerados verdadeiros. Vamos rodar o código acima e verificar o que acontece quando chamamos gen/boolean. Antes, precisaremos pressionar o ícone de play do REPL, no canto superior direito.

No momento em que o arquivo é carregado, teremos uma Exception, indicando que o Generator não pode ser ser "casteado" para uma função, portanto, esta não é uma função, e sim um objeto. Ou seja, uma instância de um Generator é um objeto, e não é assim que solicitamos um Boolean aleatório.

Para fazermos isso, utilizaremos a função gen/sample, que é a amostra, após o qual passaremos um gerador como parâmetro. Também solicitaremos sua impressão:

(println (gen/sample gen/boolean))

Vamos fazer o teste?

Ao recarregarmos o arquivo, teremos como retorno vários valores. Então, por padrão, o sample chama o que se quer como amostra diversas vezes. E se você quiser controlar essa quantidade de amostras, é possível passar este número como parâmetro:

(println (gen/sample gen/boolean, 3))

Cada vez que rodarmos o arquivo, serão trazidos três valores distintos. Gerar valores aleatórios é o primeiro passo para testes mais ricos, mais complexos. Então, da mesma maneira que temos um gerador, que é um objeto passado como parâmetro para a amostragem de booleano, temos para outros, como em (gen/sample gen/int), (gen/sample gen/string), ou ainda, (gen/sample gen/string-alphanumeric). Clicando com "Ctrl" pressionado, conseguiremos verificar as opções adequadas.

Assim, se clicarmos na função, teremos que sample retorna uma sequência de tamanho num-samples, por padrão, 10, transformada em valores baseados em generator. E ela começa com valores pequenos do gerador, passando a criar valores grandes depois. Por exemplo, ao usarmos gen/int, teremos valores como 2, 3, -1, -6. Se gerarmos 100 ints, os valores vão às extremidades, tanto para o lado positivo quanto negativo.

Isso é interessante pois, a medida em que formos fazer testes, eles acontecerão inicialmente com valores menores, passando aos maiores, gradativamente. Poderemos gerar resultados mais complexos, como um vetor, coleções ou mapas, a partir dos quais teremos os geradores. Poderemos criar nosso próprio gerador, customizar comportamentos, compor geradores, e utilizar tudo isso nos testes.

No nosso caso, queremos um sample de vetor, isto é, trabalharemos com Compound generators, portanto utilizaremos (println (gen/sample (gen/vector gen/int))), sendo que gen/vector é uma função que recebe um gerador de inteiros, gen/int, e devolve outro, que gera vetores de inteiros.

Ao rodarmos o arquivo, teremos a impressão de 10 vetores de inteiros. Se quiséssemos apenas 5, usaríamos (println (gen/sample (gen/vector gen/int), 5)). Se o tamanho do vetor for 15, então ele receberá opcionalmente este tamanho:

; usando vírgula somenta para deixar claro a QUANTIDADE DE SAMPLES
(println (gen/sample gen/boolean, 100))
(println (gen/sample gen/int, 100))
(println (gen/sample gen/string))
(println (gen/sample gen/string-alphanumeric, 100))

(println (gen/sample (gen/vector gen/int 15), 5))

Com isto, geraremos 5 vetores de tamanho 15, de tamanhos menores para maiores. O tamanho é fixo, mas poderíamos trabalhar com tamanhos dinâmicos, com (println (gen/sample (gen/vector gen/int), 100)), sendo possível também solicitar vetores que façam parte de um determinado intervalo: (println (gen/sample (gen/vector gen/int 1 5), 100)), de 1 a 5, por exemplo.

As vírgulas são utilizadas propositalmente, para indicar os parâmetros de vetor, apenas para fins de didática. Na prática, não usamos as vírgulas.

Assim, conseguimos gerar vários tipos de valores. Agora, precisamos pensar em como utilizar estes geradores em um contexto de teste. Poderemos chamar gen/sample, fazer um laço for e iterar. Veremos como fazer isso da melhor forma possível.

Geradores - Testes com geradores manuais

Agora que temos os geradores, e vimos que conseguimos acessar o código fonte deles, ou ainda a sua documentação, com a introdução, o Cheatsheet, além de exemplos e outros tópicos úteis. A sugestão é a de que, à medida em que você for aprendendo e fazendo seus próprios testes, você vá explorando a documentação, que é bastante extensa.

Vamos criar um novo teste em cabe-na-fila?, misturando o tipo de exemplo e de generativos. O nosso vetor terá o tamanho entre 0 e 4, o que gerará 10 vetores.

(testing "Que cabe pessoas em filas de tamanho até 4 inclusive"
    (println (gen/sample (gen/vector gen/string-alphanumeric 0 4)))

    )

Então, para cada uma das filas criaremos um let e um for. Existe uma forma de fazermos um laço, ou passar por cada um destes vetores. Já vimos variações disso em cursos iniciais de Clojure, sendo uma delas o doseq, que recebe o nome de uma variável, e uma sequência. Vamos utilizá-lo?

(testing "Que cabe pessoas em filas de tamanho até 4 inclusive"
    (doseq [filas (gen/sample (gen/vector gen/string-alphanumeric 0 4))]
        )
    )

O doseq funcionará como o let, então filas, mas para cada valor da sequência. Isto é, o código que estiver dentro dele será executado 10 vezes:

(testing "Que cabe pessoas em filas de tamanho até 4 inclusive"
    (doseq [filas (gen/sample (gen/vector gen/string-alphanumeric 0 4))]
        (is (cabe-na-fila? {:espera fila}, :espera))))

Ao testarmos o código, o número de assertions terá aumentado para 16, pois desta vez estaremos lidando com 10 asserts. Não teremos como saber quais valores foram testados, uma vez que o teste foi bem sucedido. Poderemos imprimir por curiosidade, e para cada impressão teremos vetores diferentes.

Podemos achar que isto causará "instabilidade" nos testes (por rodar valores distintos), mas isso não será um problema, pois se o teste não passar significa que há um bug a ser corrigido. Ou seja, rodar os testes não será mais determinístico, mas esta é a ideia do gerador. Não temos como saber de antemão quais são os valores a serem executados no gerador, e é assim mesmo.

Esta, então, é uma maneira de gerarmos valores e testá-los. Em vez de 10 testes, então, poderíamos testar 100, 1000 vezes. E se tivermos que suportar 5, mas suportarmos apenas 4? Neste caso, teremos um bug, que será apontado na tela para nós. Claro, pode-se configurar as ferramentas de Continuous integration, delivery, e assim por diante, para que sejam mostradas as mensagens de erro no log da ferramenta de integração utilizada, para saber qual é o exato caso da falha.

Como dito, a instabilidade de trabalharmos com valores aleatórios é proposital, para que peguemos o bug e possamos atacá-lo. Se não fosse assim, teríamos um sistema determinístico, e a vantagem do gerador aleatório descobrir à medida em que é executado não existiria. Em geral, a quantidade de testes executados será o suficiente para pegar o bug, mas poderia ser um caso específico que fugisse disso.

Sobre o curso Clojure: geradores e testes de propriedades

O curso Clojure: geradores e testes de propriedades possui 199 minutos de vídeos, em um total de 45 atividades. Gostou? Conheça nossos outros cursos de Clojure em Programação, ou leia nossos artigos de Programação.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda Clojure acessando integralmente esse e outros cursos, comece hoje!

Conheça os Planos para Empresas