Teste de Unidade e Python



Meu nome é Vadim, sou um desenvolvedor líder no Mail.Ru Search. Compartilharei nossa experiência em testes de unidade. O artigo consiste em três partes: na primeira, vou lhe dizer o que geralmente alcançamos com a ajuda do teste de unidade; a segunda parte descreve os princípios que seguimos; e na terceira parte, você aprenderá como os princípios mencionados são implementados no Python.

Objetivos


É muito importante entender por que você está aplicando testes de unidade. Ações concretas dependerão disso. Se você usar os testes de unidade incorretamente ou, com a ajuda deles, não fizer o que queria, nada de bom resultará disso. Portanto, é muito importante entender com antecedência quais objetivos você está perseguindo.

Em nossos projetos, buscamos vários objetivos.

A primeira é a regressão banal: para consertar algo no código, executar os testes e descobrir que nada quebrou. Embora, de fato, não seja tão simples quanto parece.

O segundo objetivo é avaliar o impacto da arquitetura . Se você introduzir testes de unidade obrigatórios no projeto, ou simplesmente concordar com os desenvolvedores sobre o uso de testes de unidade, isso afetará imediatamente o estilo de escrever código. É impossível escrever funções em 300 linhas com 50 variáveis ​​locais e 15 parâmetros se essas funções forem submetidas a testes de unidade. Além disso, graças a esses testes, as interfaces se tornarão mais compreensíveis e algumas áreas problemáticas aparecerão. Afinal, se o código não estiver tão quente, o teste será uma curva e chamará sua atenção imediatamente.

O terceiro objetivo é tornar o código mais claro . Suponha que você veio para um novo projeto e recebeu 50 MB de código-fonte. Talvez você não consiga entendê-los. Se não houver testes de unidade, a única maneira de se familiarizar com o trabalho do código, além de ler a fonte, é o “método de puxão”. Mas se o sistema for bastante complicado, poderá levar muito tempo para obter os trechos de código necessários por meio da interface. E, graças aos testes de unidade, você pode ver como o código é executado de qualquer lugar.

O quarto objetivo é simplificar a depuração . Por exemplo, você encontrou alguma classe e deseja depurá-la. Se, em vez de testes de unidade, houver apenas testes do sistema ou nenhum teste, resta apenas chegar ao lugar certo através da interface. Por acaso participei de um projeto em que, para testar alguns recursos, demorou meia hora para criar um usuário, cobrar dinheiro, alterar seu status, iniciar algum tipo de cron, para que esse status fosse transferido para outro lugar, clique em algo na interface, inicie algo algum outro cron ... Após meia hora, um programa de bônus para esse usuário finalmente apareceu. E se eu fizesse testes de unidade, poderia chegar imediatamente ao lugar certo.

Finalmente, o objetivo mais importante e muito abstrato, que une todos os anteriores, é o conforto . Quando tenho testes de unidade, sinto menos estresse ao trabalhar com código, porque entendo o que está acontecendo. Posso pegar uma fonte desconhecida, corrigir três linhas, executar testes e garantir que o código funcione conforme o planejado. E nem é que os testes sejam verdes: eles podem ser vermelhos, mas exatamente onde eu espero. Ou seja, eu entendo como o código funciona.

Princípios


Se você entender seus objetivos, poderá entender o que precisa ser feito para alcançá-los. E aqui começam os problemas. O fato é que muitos livros e artigos foram escritos em testes de unidade, mas a teoria ainda é muito imatura.

Se você já leu artigos sobre teste de unidade, tentou aplicar o descrito e não obteve êxito, é muito provável que o motivo seja a imperfeição da teoria. Isso acontece o tempo todo. Eu, como todos os desenvolvedores, uma vez pensei que o problema estava em mim. E então ele percebeu: não posso estar errado tantas vezes. E ele decidiu que, nos testes de unidade, era necessário proceder a partir de suas próprias considerações, para agir com mais sensibilidade.

O conselho padrão que você pode encontrar em todos os livros e artigos: “você deve testar não a implementação, mas a interface”. Afinal, a implementação pode mudar, mas a interface não. Vamos testá-lo para que os testes não caiam o tempo todo em todas as ocasiões. O conselho, ao que parece, não é ruim e tudo parece lógico. Mas sabemos muito bem: para testar algo, você precisa selecionar alguns valores de teste. Normalmente, ao testar funções, as chamadas classes de equivalência são distinguidas: o conjunto de valores nos quais a função se comporta de maneira uniforme. Grosso modo, o teste para cada se. Mas, para saber quais classes de equivalência temos, é necessária uma implementação. Você não o testa, mas precisa, deve investigar para saber quais valores de teste escolher.

Fale com qualquer testador: ele lhe dirá que, com testes manuais, ele sempre imagina uma implementação. Por sua experiência, ele entende perfeitamente onde os programadores geralmente cometem erros. O testador não verifica tudo, primeiro digitando 5, depois 6 e depois 7. Ele verifica 5, abc, –7 e o número tem 100 caracteres, porque ele sabe que a implementação desses valores pode ser diferente, mas para 6 e 7 é improvável .

Portanto, não está claro como seguir o princípio de "testar a interface, não a implementação". Você não pode simplesmente pegar, fechar os olhos e escrever um teste. O TDD está tentando resolver esse problema em parte. A teoria sugere a introdução de classes de equivalência uma de cada vez e a escrita de testes para elas. Eu li muitos livros e artigos sobre esse assunto, mas de alguma forma isso não se mantém. No entanto, concordo com a tese de que os testes devem ser escritos primeiro. Chamamos esse princípio de teste primeiro. Não temos TDD e, em conexão com o exposto acima, os testes não são escritos antes da criação do código, mas em paralelo com ele.

Definitivamente, não recomendo escrever testes retroativamente. Afinal, eles influenciam a arquitetura e, se já se acalmou, é tarde demais para influenciá-la - tudo terá que ser reescrito. Em outras palavras, a testabilidade do código é uma propriedade separada que o código terá que dotar , e não se tornará tal. Portanto, tentamos escrever testes junto com o código. Não acredite em histórias como “vamos escrever um projeto em três meses e depois cobrir tudo com testes em uma semana”, isso nunca acontecerá.

A coisa mais importante a entender: o teste de unidade não é uma maneira de verificar o código, nem uma maneira de verificar sua correção. Isso faz parte da sua arquitetura, o design do seu aplicativo. Ao trabalhar com testes de unidade, você muda seus hábitos. Testes que apenas verificam a exatidão são antes testes de aceitação. Será um erro pensar que você pode cobrir algo com testes de unidade ou que o código não precisará ser verificado.

Implementação Python


Usamos a biblioteca unittest padrão da família xUnit. A história é a seguinte: havia a linguagem SmallTalk e nela a biblioteca SUnit. Todos gostaram, começaram a copiá-lo. A biblioteca foi importada para Java com o nome Junit, de lá em C ++ com o nome CppUnit e em Ruby com o nome RUnit (depois foi renomeada para RSpec). Finalmente, a partir de Java, a biblioteca "mudou-se" para Python com o nome de mais unittest. E eles o importaram tão literalmente que até o CamelCase permaneceu, embora isso não corresponda ao PEP 8.

Sobre o xUnit, há um livro maravilhoso, “xUnit Test Patterns”. Descreve como trabalhar com as estruturas dessa família. A única desvantagem do livro é seu tamanho: é enorme, mas cerca de 2/3 do conteúdo é um catálogo de padrões. E o primeiro terço do livro é maravilhoso, este é um dos melhores livros sobre TI que eu já conheci.

Um teste de unidade é um código regular que possui uma certa arquitetura padrão. Todos os testes de unidade consistem em três estágios: configurar, exercitar e verificar. Você prepara os dados, executa os testes e verifica se tudo está no estado correto.



Configuração


A fase mais difícil e interessante. Trazer o sistema ao seu estado original a partir do qual você deseja testá-lo pode ser muito difícil. E o estado do sistema pode ser arbitrariamente complexo.

Quando sua função é chamada, muitos eventos poderiam ter acontecido, um milhão de objetos poderia ter sido criado na memória. Em todos os componentes associados ao seu software - no sistema de arquivos, banco de dados, caches - algo já está localizado e a função pode funcionar apenas nesse ambiente. E se o ambiente não estiver preparado, as ações da função não terão sentido.

Geralmente, todo mundo afirma que, em nenhum caso, você pode usar sistemas de arquivos, bancos de dados ou qualquer outro componente separado, porque isso torna seu teste não modular, mas integrado. Na minha opinião, isso não é verdade, porque o teste de integração é feito pelo teste de integração. Se você usar alguns componentes não para verificação, mas apenas para fazer o sistema funcionar, não há nada de errado nisso. Seu código interage com muitos componentes do computador e do sistema operacional. O único problema com o uso de um sistema de arquivos ou banco de dados é a velocidade.

Diretamente no código, usamos injeção de dependência . Você pode lançar parâmetros na função em vez dos padrões. Você pode até encaminhar links para bibliotecas. Ou você pode inserir um stub em vez de uma solicitação para que o código dos testes não acesse a rede. Você pode armazenar registradores personalizados nos atributos da classe para não gravar no disco e economizar tempo.

Para stubs, usamos o mock habitual do unittest. Há também uma função de patch que, em vez de implementar honestamente dependências, simplesmente diz: "neste pacote, essa importação é um substituto para outro". É conveniente porque você não precisa jogar nada em lugar nenhum. É verdade que não está claro quem substituiu o que, portanto, use-o com cuidado.

Quanto ao sistema de arquivos, fingir é bem simples. Há um módulo io com io.StringIO e io.BytesIO . Você pode criar objetos semelhantes a arquivos que realmente não acessam o disco. Mas, de repente, isso não é suficiente para você, existe um maravilhoso módulo tempfile com gerenciadores de contexto para arquivos temporários, diretórios, arquivos nomeados, qualquer coisa. Tempfile é um super-módulo se, por algum motivo, o IO não couber em você.

Com um banco de dados, tudo é mais complicado. Há uma recomendação padrão: "Não use uma base real, mas uma base falsa". Não conheço você, mas na minha vida não vi uma única base falsa e suficientemente funcional. Toda vez que eu pedia conselhos sobre o que especificamente levar sob Python ou Perl, eles respondiam que ninguém sabia de nada pronto e se ofereciam para escrever algo próprio. Não consigo imaginar como você pode escrever um emulador, por exemplo, PostgreSQL. Outra dica: "obtenha o SQLite". Mas isso quebrará o isolamento, porque o SQLite trabalha com o sistema de arquivos. Além disso, se você usar algo como MySQL ou PostgreSQL, o SQLite provavelmente não funcionará. Se lhe parece que você não está usando os recursos específicos de produtos específicos, provavelmente está enganado. Certamente, mesmo para coisas comuns, como trabalhar com datas, você usa recursos específicos que apenas o DBMS suporta.

Como resultado, eles geralmente usam uma base real. A solução não é ruim, só precisamos mostrar uma certa precisão. Não use um banco de dados centralizado, porque os testes podem ser interrompidos entre si. Idealmente, a própria base deve subir durante os testes e parar após o teste.

Uma situação um pouco pior é quando você é obrigado a executar um banco de dados local, que será usado. Mas a questão é: como os dados chegarão lá? Já dissemos que deve haver algum estado inicial do sistema, deve haver alguns dados no banco de dados. De onde eles vêm não é uma pergunta fácil.

A abordagem mais ingênua que me deparei é usar uma cópia de um banco de dados real. Uma cópia era tirada regularmente, da qual os dados confidenciais eram excluídos. Os autores argumentaram que dados reais são mais adequados para testes. Além disso, escrever testes para uma cópia de um banco de dados real é um tormento. Você não sabe quais dados existem. Você precisa primeiro encontrar o que vai testar. Se essas informações não estiverem disponíveis, o que fazer não é claro. Acabou que naquele projeto eles decidiram escrever testes para a conta do departamento de operações, que “nunca mudarão”. Claro, depois de algum tempo ela mudou.

Isso geralmente é seguido pela decisão: “vamos criar um elenco da base real, copiá-lo e não sincronizar mais. Então será possível estar vinculado a um objeto específico, observar o que acontece lá e escrever testes. ” Surge imediatamente a pergunta: o que acontecerá quando novas tabelas forem adicionadas ao banco de dados? Aparentemente, você terá que inserir manualmente dados falsos.

Mas, como faremos de qualquer maneira, vamos preparar imediatamente o elenco base manualmente. Essa opção é muito parecida com o que geralmente é chamado de acessórios no Django: eles criam JSON enormes, enviam casos de teste para todas as ocasiões, os enviam para o banco de dados no início do teste, e tudo ficará bem conosco. Essa abordagem também tem muitas desvantagens. Os dados são empilhados em uma pilha, não está claro a que teste ele se relaciona. Ninguém pode entender se os dados foram excluídos ou não. E há estados incompatíveis no banco de dados: por exemplo, um teste não precisa ter usuários no banco de dados e o outro para tê-los. Essas duas condições não podem ser armazenadas simultaneamente no mesmo molde. Nesse caso, um dos testes precisará modificar o banco de dados. E como você ainda precisa lidar com isso, é mais fácil começar com um banco de dados vazio, para que cada teste coloque os dados necessários e, ao final do teste, limpe o banco de dados. A única desvantagem dessa abordagem é a dificuldade de criar dados em cada teste. Em um dos projetos em que trabalhei, para criar um serviço, foi necessário gerar 8 entidades em tabelas diferentes: um serviço em uma conta pessoal, uma conta pessoal em um cliente, um cliente em uma entidade legal, uma entidade legal em uma cidade, um cliente em uma cidade e assim por diante. Até você criar tudo isso em uma cadeia, você não satisfará a chave estrangeira, nada funciona.

Para tais situações, existem bibliotecas especiais que facilitam muito a vida. Você pode escrever ferramentas auxiliares, geralmente chamadas de fábricas (não confunda com o padrão de design). Por exemplo, usamos a biblioteca factory_boy, que é adequada para o Django. Este é um clone da biblioteca factory_girl, que foi renomeada para factory_bot no ano passado por razões de correção política. Escrever uma biblioteca para sua própria estrutura não custa nada. É baseado em uma ideia muito importante: uma vez você cria uma fábrica para os objetos que deseja gerar, estabelece conexões para ela e diz ao usuário: “quando você for criado, pegue seu próximo nome e gere o grupo usando a fábrica de grupo”. E na fábrica, tudo é exatamente o mesmo: gere o nome dessa maneira, entidades relacionadas, tal e tal.

Como resultado, apenas uma última linha permanece no código: user = UserFactory() . O usuário foi criado e você pode trabalhar com ele, porque, sob o capô, ele gerou tudo o que é necessário. Se desejar, você pode configurar algo manualmente.

Para limpar os dados após o teste, usamos transações triviais. No início de cada teste, BEGIN está concluído, o teste faz algo com a base e, após o teste, ROLLBACK está concluído. Se forem necessárias transações no próprio teste - por exemplo, porque compromete algo extra no banco de dados - ele chama o método que chamamos de break_db , informa à estrutura que ela quebrou a base de dados e a estrutura a break_db novamente. Acontece lentamente, mas como geralmente há muito poucos testes que precisam de transações, tudo está em ordem.

Exercício


Não há nada de especial para contar sobre esse estágio. A única coisa que pode dar errado aqui é virar para fora, por exemplo, para a Internet. Por algum tempo, estávamos lutando com isso administrativamente: dissemos aos programadores que devemos mergulhar funções que vão a algum lugar ou lançar sinalizadores especiais para que as funções não funcionem. Se o teste acessar o etcd corporativo, isso não será bom. Como resultado, chegamos à conclusão de que tudo foi desperdiçado: nós mesmos esquecemos constantemente que alguma função chama uma função que chama uma função que vai para o etcd. Portanto, no setUp da classe base, adicionamos o moki de todas as chamadas, ou seja, bloqueado com a ajuda de stubs todas as chamadas onde não foram colocadas.

Os stubs podem ser feitos facilmente usando remendos, coloque remendos em um dicionário separado e dê acesso a todos os testes. Por padrão, os testes não podem ir a lugar algum e, se você ainda precisar abrir o acesso para alguns, poderá redirecioná-lo. Muito confortável Jenkins não enviará mais SMS para seus clientes à noite :)

Verificar


Nesse estágio, usamos ativamente declarações auto-escritas, mesmo de linha única. Se você testar a existência de um arquivo no teste, em vez de afirmar self.assertTrue(file_exists(f)) recomendo escrever afirmando que not file exists . Holivar está conectado com isso: devo continuar usando o CamelCase em nomes, como em unittest, ou devo seguir o PEP 8? Eu não tenho resposta. Se você seguir o PEP 8, no código de teste haverá uma confusão no CamelCase e no snake_case. E se você usa o CamelCase, isso não corresponde ao PEP 8.

E o último. Suponha que você tenha um código que está testando alguma coisa e há muitas opções de dados nas quais esse código precisa ser executado. Se você usar py.test, poderá executar o mesmo teste com diferentes dados de entrada. Se você não possui py.test, pode usar esse decorador . Uma tabela é passada para o decorador e um teste se transforma em vários outros, cada um dos quais testa um dos casos.

Conclusão


Não confie em artigos e livros incondicionalmente. Se você acha que eles estão errados, é possível que sim.

Sinta-se livre para usar testes de dependência. Não há nada de errado nisso. Se você criou o memcached, porque sem ele seu código não funciona normalmente, tudo bem. Mas é melhor ficar sem ele, se possível.

Preste atenção nas fábricas. Este é um padrão muito interessante.

PS Convido você para o canal Telegram do meu autor para programação em Python - @pythonetc.

Source: https://habr.com/ru/post/pt418929/


All Articles