Recentemente, mais e mais frequentemente existem referências a uma certa ferramenta mágica - testes baseados em propriedades (testes baseados em propriedades, se você precisar pesquisar na literatura em inglês). A maioria dos artigos sobre este tópico fala sobre como é uma abordagem legal e mostra em um exemplo elementar como escrever um teste usando uma estrutura específica, na melhor das hipóteses, sugerem várias propriedades comuns e ... isso é tudo. Além disso, o leitor espantado e entusiasmado tenta colocar tudo isso em prática e repousa no fato de que as propriedades de alguma forma não são inventadas. E, infelizmente, muitas vezes se rende a isso. Neste artigo, tentarei priorizar um pouco diferente. Ainda assim, começarei com um exemplo mais ou menos concreto para explicar que tipo de animal é. Mas um exemplo, espero, não é muito típico para artigos desse tipo. Depois, tentarei analisar alguns dos problemas associados a essa abordagem e como eles podem ser resolvidos. E a seguir - propriedades, propriedades e apenas propriedades, com exemplos onde elas podem ser pressionadas. Interessante?
Testando o armazenamento de valores-chave em três testes breves
Então, digamos que, por algum motivo, precisamos implementar algum tipo de armazenamento de valor-chave. Pode ser um dicionário com base em uma tabela de hash ou com base em alguma árvore, pode ser totalmente armazenado na memória ou capaz de trabalhar com um disco - não nos importamos. O principal é que ele deve ter uma interface que permita:
- escrever valor por chave
- verifique se existe uma entrada com a chave desejada
- valor lido por chave
- obtenha uma lista dos itens registrados
- obtenha uma cópia do repositório
Na abordagem clássica baseada em exemplos, um teste típico seria algo como isto:
storage = Storage() storage['a'] = 42 assert len(storage) == 1 assert 'a' in storage assert storage['a'] == 42
Ou então:
storage = Storage() storage['a'] = 42 storage['b'] = 73 assert len(storage) == 2 assert 'a' in storage assert 'b' in storage assert storage['a'] == 42 assert storage['b'] == 73
E, em geral, esses testes podem e precisam ser escritos um pouco mais do que dofiga. Além disso, quanto mais complicada a implementação interna, maior a chance de perder alguma coisa. Em suma, um trabalho longo, tedioso e muitas vezes ingrato. Como seria bom empurrá-lo em alguém! Por exemplo, faça o computador gerar casos de teste para nós. Primeiro, tente fazer algo assim:
storage = Storage() key = arbitrary_key() value = arbitrary_value() storage[key] = value assert len(storage) == 1 assert key in storage assert storage[key] == value
Este é o primeiro teste baseado em propriedade. Parece quase o mesmo que o tradicional, embora um pequeno bônus já seja impressionante - não há valores retirados do teto, em vez disso, usamos funções que retornam valores e chaves arbitrárias. Há outra vantagem muito mais séria - ela pode ser executada muitas e muitas vezes e em dados de entrada diferentes para verificar o contrato que, se você tentar adicionar algum elemento ao armazenamento vazio, ele realmente será adicionado lá. Tudo bem, tudo bem, mas até agora não é muito útil em comparação com a abordagem tradicional. Vamos tentar adicionar outro teste:
storage = arbitrary_storage() storage_copy = storage.copy() assert len(storage) == len(storage_copy) assert all(storage_copy[key] == storage[key] for key in storage) assert all(storage[key] == storage_copy[key] for key in storage_copy)
Aqui, em vez de pegar o armazenamento vazio, geramos arbitrariamente alguns dados e verificamos se sua cópia é idêntica à original. Sim, o gerador precisa ser gravado usando uma API pública potencialmente com erros, mas, como regra, essa não é uma tarefa tão difícil. Ao mesmo tempo, se houver algum erro sério na implementação, são grandes as chances de as quedas começarem durante o processo de geração, portanto, isso também pode ser considerado como uma espécie de teste de fumaça de bônus. Mas agora podemos ter certeza de que tudo o que o gerador foi capaz de fornecer pode ser copiado corretamente. E, graças ao primeiro teste, sabemos com certeza que o gerador pode criar armazenamento com pelo menos um elemento. Hora do próximo teste! Ao mesmo tempo, reutilizamos o gerador:
storage = arbitrary_storage() backup = storage.copy() key = arbitrary_key() value = arbitrary_value() if key in storage: return storage[key] = value assert len(storage) == len(backup) + 1 assert key in storage assert storage[key] == value assert all(storage[key] == backup[key] for key in backup)
Tomamos um armazenamento arbitrário e verificamos se podemos adicionar outro elemento lá. Portanto, o gerador pode criar um repositório com dois elementos. E você pode adicionar um elemento a ele também. E assim por diante (lembro-me imediatamente de indução matemática). Como resultado, os três testes escritos e o gerador permitem verificar com segurança que um número arbitrário de elementos diferentes pode ser adicionado ao repositório. Apenas três testes breves! Essa é basicamente a idéia dos testes baseados em propriedades:
- encontramos propriedades
- verificando propriedades em um monte de dados diferentes
- lucro!
A propósito, essa abordagem não contradiz os princípios do TDD - os testes podem ser escritos da mesma maneira antes do código (pelo menos pessoalmente, eu costumo fazer isso). Outra questão é que tornar esse teste verde pode ser muito mais difícil do que o tradicional, mas quando finalmente for bem-sucedido, teremos certeza de que o código realmente está em conformidade com uma determinada parte do contrato.
Tudo está bem, mas ...
Com todo o apelo de uma abordagem de teste baseada em propriedades, há muitos problemas. Nesta parte, tentarei entender os mais comuns. Além dos problemas com a complexidade real de encontrar propriedades úteis (às quais voltarei na próxima seção), na minha opinião, o maior problema para iniciantes é muitas vezes a crença falsa em uma boa cobertura. De fato, escrevemos vários testes que geram centenas de casos de teste - o que poderia dar errado? Se você olhar o exemplo da parte anterior, existem muitas coisas. Para começar, os testes escritos não garantem que o
storage.copy () faça uma cópia "profunda" e não apenas o ponteiro. Outro buraco - não há verificação normal de que a
chave no armazenamento retornará
False se a chave que você está procurando não estiver na loja. E a lista continua. Bem, um dos meus exemplos favoritos - digamos que escrevemos uma espécie e, por alguma razão, pensamos que um teste que verifica a ordem dos elementos é suficiente:
input = arbitrary_list() output = sort(input) assert all(a <= b for a, b in zip(output, output[1:]))
E essa implementação passará perfeitamente
def sort(input): return [1, 2, 3]
Espero que a moral aqui seja clara.
O próximo problema, que em certo sentido pode ser chamado de conseqüência dos dois anteriores, é que o uso de testes baseados em propriedades geralmente é muito difícil de obter uma cobertura realmente completa. Mas, na minha opinião, isso é resolvido com muita simplicidade - você não precisa escrever apenas testes baseados em propriedades, ninguém cancelou os testes tradicionais. Além disso, as pessoas estão tão dispostas que é muito mais fácil entender as coisas com exemplos concretos, o que também é favorável ao uso de ambas as abordagens. Em geral, desenvolvi para mim aproximadamente o seguinte algoritmo - para escrever alguns testes tradicionais muito simples, idealmente, para que eles possam servir como um exemplo de como a API deve ser usada. Assim que houver a sensação de que os testes "para documentação" são suficientes, mas ainda está longe de ser uma cobertura completa - comece a adicionar testes com base nas propriedades.
Agora, para a questão das estruturas, o que esperar delas e por que elas são necessárias - afinal, ninguém proíbe com suas mãos fazer um teste em um ciclo, causando aleatoriedade por dentro e aproveitando a vida. De fato, a alegria será até o primeiro teste cair, e é bom se for local, e não em algum IC. Primeiro, como os testes baseados em propriedades são randomizados, você definitivamente precisa de uma maneira confiável de reproduzir um caso eliminado, e qualquer estrutura que se preze permite que você faça isso. As abordagens mais populares são a saída de uma certa semente para o console, que você pode executar manualmente no executor de teste e reproduzir de forma confiável a caixa descartada (conveniente para depuração) ou criar um cache no disco com sids "ruins", que serão verificados automaticamente primeiro quando o teste for iniciado ( ajuda na repetibilidade no IC). Outro aspecto importante é a minificação de dados (diminuindo em fontes estrangeiras). Como os dados são gerados aleatoriamente, ou seja, uma chance totalmente não falsa de entrar em um caso de teste em queda com um contêiner de 1000 elementos, o que ainda é um "prazer" de depurar. Portanto, boas estruturas, depois de encontrar um caso feylyaschy, aplicam várias heurísticas para tentar encontrar um conjunto mais compacto de dados de entrada, que, no entanto, continuarão travando o teste. E finalmente - muitas vezes metade da funcionalidade de teste é um gerador de dados de entrada; portanto, a presença de geradores e primitivos embutidos que permitem criar rapidamente outros mais complexos a partir de geradores simples também ajuda bastante.
Também existem críticas ocasionais de que existem muitos testes lógicos baseados em propriedades. No entanto, isso geralmente é acompanhado por exemplos no estilo de
data = totally_arbitrary_data() perform_actions(sut, data) if is_category_a(data): assert property_a_holds(sut) else if is is_category_b(data): assert property_b_holds(sut)
De fato, é bastante comum (para iniciantes) antipadrão, não faça isso! É muito melhor dividir esse teste em dois diferentes e pular dados de entrada inadequados (em muitas estruturas, existem ferramentas especiais para isso) se a chance de chegar a eles for pequena, ou usar geradores mais especializados que produzam imediatamente apenas dados adequados. O resultado deve ser algo como
data = totally_arbitrary_data() assume(is_category_a(data)) perform_actions(sut, data) assert property_a_holds(sut)
e
data = data_from_category_b() perform_actions(sut, data) assert property_b_holds(sut)
Propriedades úteis e seus habitats
Tudo bem, o que é útil para testes baseados em propriedades, parece claro, as principais armadilhas foram resolvidas ... embora não, a principal coisa ainda não está clara - de onde essas propriedades vêm? Vamos tentar pesquisar.
Pelo menos não caia
A opção mais fácil é inserir dados arbitrários no sistema em teste e verificar se eles não travam. De fato, essa é uma direção totalmente distinta, com o nome da moda difuso, para o qual existem ferramentas especializadas (por exemplo, AFL, também conhecida como American Fuzzy Lop), mas com certa extensão, pode ser considerado um caso especial de teste baseado em propriedades e, se é que não há idéias em mente. Se não estiver subindo, você pode começar com isso. No entanto, como regra, esses testes explicitamente raramente fazem sentido, pois as quedas em potencial geralmente saem muito bem ao verificar outras propriedades. As principais razões pelas quais menciono essa "propriedade" é direcionar o leitor para os difusores e, em particular, para a AFL (há muitos artigos em inglês sobre esse tópico), bem, para completar o quadro.
Teste oracle
Uma das propriedades mais chatas, mas na verdade uma coisa muito poderosa que pode ser usada com muito mais frequência do que parece. A idéia é que, às vezes, existem dois pedaços de código que fazem a mesma coisa, mas de maneiras diferentes. E, em seguida, você pode não entender especialmente para gerar dados de entrada arbitrários, inseri-los nas duas opções e verificar se os resultados correspondem. O exemplo de aplicativo mais frequentemente mencionado é ao escrever uma versão otimizada de uma função para deixar uma opção lenta mas simples e executar testes nela.
input = arbitrary_list() assert quick_sort(input) == bubble_sort(input)
No entanto, a aplicabilidade dessa propriedade não se limita a isso. Por exemplo, muitas vezes acontece que a funcionalidade implementada pelo sistema que queremos testar é um superconjunto de algo já implementado, geralmente até na biblioteca de idiomas padrão. Em particular, geralmente a maior parte da funcionalidade de algum armazenamento de valor-chave (na memória ou no disco, com base em árvores, tabelas de hash ou em algumas estruturas de dados mais exóticas, como a árvore de merkle patricia) pode ser testada com um dicionário padrão padrão. Testando todos os tipos de CRUDs - lá também.
Outra aplicação interessante que eu pessoalmente usei - algumas vezes ao implementar um modelo numérico de um sistema, alguns casos particulares podem ser calculados analiticamente e comparados com eles os resultados da simulação. Nesse caso, como regra, se você tentar inserir dados completamente arbitrários na entrada, mesmo com a implementação correta, os testes ainda começarão a cair devido à precisão limitada (e, consequentemente, aplicabilidade) das soluções numéricas, mas durante o processo de reparo, aplicando restrições nos dados de entrada gerados, essas mesmas restrições tornar-se conhecido.
Requisitos e Invariantes
A idéia principal aqui é que geralmente os próprios requisitos são formulados para serem fáceis de usar como propriedades. Em alguns artigos sobre esses tópicos, os invariantes são destacados separadamente, mas, na minha opinião, a fronteira aqui é instável demais, pois a maioria desses invariantes são conseqüências diretas dos requisitos, por isso provavelmente despejo tudo.
Uma pequena lista de exemplos de várias áreas adequadas para verificar propriedades:
- o campo da classe deve ter um valor atribuído anteriormente (getter-setters)
- o repositório deve poder ler um item gravado anteriormente
- adicionar um item anteriormente inexistente ao repositório não afeta itens adicionados anteriormente
- em muitos dicionários, vários elementos diferentes com a mesma chave não podem ser armazenados
- altura equilibrada da árvore não deve mais onde - número de itens registrados
- O resultado da classificação é uma lista de itens solicitados
- O resultado da codificação base64 deve conter apenas caracteres base64
- o algoritmo de construção de rota deve retornar uma sequência de movimentos permitidos que levarão do ponto A ao ponto B
- para todos os pontos dos isolines construídos devem ser satisfeitos
- o algoritmo de verificação de assinatura eletrônica deve retornar True se a assinatura for real e False caso contrário
- como resultado da ortonormalização, todos os vetores na base devem ter comprimento unitário e zero produtos escalares mútuos
- operações de transferência e rotação de vetores não devem alterar seu comprimento
Em princípio, pode-se dizer que tudo está completo, o artigo está completo, use oráculos de teste ou procure propriedades nos requisitos, mas há alguns “casos especiais” mais interessantes que eu gostaria de destacar separadamente.
Teste de indução e estado
Às vezes você precisa testar algo com um estado. Nesse caso, a maneira mais fácil:
- escreva um teste que verifique a correção do estado inicial (por exemplo, que o contêiner recém-criado esteja vazio)
- escreva um gerador que, usando um conjunto de operações aleatórias, leve o sistema a algum estado arbitrário
- gravar testes para todas as operações usando o resultado do gerador como um estado inicial
Muito semelhante à indução matemática:
- provar a declaração 1
- provar a afirmação N + 1, assumindo que a afirmação N é verdadeira
Outro método (às vezes fornecendo um pouco mais de informações sobre onde ele quebrou) é gerar uma sequência aceitável de eventos, aplicá-la ao sistema em teste e verificar as propriedades após cada etapa.
Para a frente e para trás
Se de repente houver uma necessidade de testar algumas funções para a conversão direta e reversa de alguns dados, considere que você tem muita sorte:
input = arbitrary_data() assert decode(encode(input)) == input
Ótimo para teste:
- serialização-desserialização
- descriptografia de criptografia
- codificação-decodificação
- transformar a matriz base em quaternion e vice-versa
- transformação de coordenadas direta e inversa
- transformada direta e inversa de Fourier
Um caso especial, mas interessante, é a inversão:
input = arbitrary_data() assert invert(invert(input)) == input
Um exemplo impressionante é a inversão ou transposição de uma matriz.
Idempotência
Algumas operações não alteram o resultado do uso repetido. Exemplos típicos:
- triagem
- qualquer normalização de vetores e bases
- adicionando novamente um item existente a um conjunto ou dicionário
- regravando os mesmos dados em alguma propriedade do objeto
- transmitindo dados para o formato canônico (espaços em JSON levam a um estilo unificado, por exemplo)
A idempotência também pode ser usada para testar a serialização-desserialização se o método usual de
decodificação (codificação (entrada)) == não for adequado devido a diferentes representações possíveis para dados de entrada equivalentes (novamente, espaços extras em algum JSON):
def normalize(input): return decode(encode(input)) input = arbitrary_data() assert normalize(normalize(input)) == normalize(input)
Maneiras diferentes, um resultado
Aqui, a idéia se resume a explorar o fato de que, às vezes, existem várias maneiras de fazer a mesma coisa. Pode parecer um caso especial do oráculo de teste, mas, na realidade, não é bem assim. O exemplo mais simples é usar a comutatividade de algumas operações:
a = arbitrary_value() b = arbitrary_value() assert a + b == b + a
Pode parecer trivial, mas é uma ótima maneira de testar:
- adição e multiplicação de números em uma representação não padrão (bigint, racional, isso é tudo)
- "Adição" de pontos em curvas elípticas em campos finitos (olá, criptografia!)
- união de conjuntos (que no interior pode ter estruturas de dados completamente não triviais)
Além disso, a adição de elementos ao dicionário tem a mesma propriedade:
A = dict() A[key_a] = value_a A[key_b] = value_b B = dict() B[key_b] = value_b B[key_a] = value_a assert A == B
A opção é mais complicada - por um longo tempo pensei em como descrevê-la em palavras, mas apenas uma notação matemática vem à mente. Em geral, essas transformações são comuns
para o qual a propriedade possui
, e o argumento e o resultado da função não são necessariamente apenas um número, mas operações
e
- apenas algumas operações binárias nesses objetos. O que você pode testar com isso:
- adição e multiplicação de todos os tipos de números estranhos, vetores, matrizes, quaterniões ( )
- operadores lineares, em particular todos os tipos de integrais, diferenciais, convoluções, filtros digitais, transformadas de Fourier, etc. ( )
- operações em objetos idênticos em diferentes representações, por exemplo
- onde e São quaterniões únicos e - operação de conversão de um quaternion em uma matriz base equivalente
- onde e São sinais - convolução - multiplicação e - Transformada de Fourier
Um exemplo de uma tarefa um pouco mais "comum" - para testar algum algoritmo complicado de fusão de dicionário, você pode fazer algo assim:
a = arbitrary_list_of_kv_pairs() b = arbitrary_list_of_kv_pairs() result = as_dict(a) result.merge(as_dict(b)) assert result == as_dict(a + b)
Em vez de uma conclusão
Isso é basicamente tudo o que eu queria contar neste artigo. Espero que tenha sido interessante, e um pouco mais de pessoas começarão a colocar tudo isso em prática. Para facilitar um pouco a tarefa, apresentarei uma lista de estruturas com diferentes graus de validade para diferentes idiomas:
E, é claro, um agradecimento especial às pessoas que escreveram artigos maravilhosos, graças aos quais aprendi sobre essa abordagem há alguns anos, pararam de se preocupar e começaram a escrever testes com base nas propriedades: