Revisitando testes unitários e mocks em Python
7 de julho de 2018
0 minutos de leituraNota do editor
Este blog foi publicado originalmente em fugue.co. A Fugue se juntou à Snyk em 2022 e é um componente essencial do Snyk IaC.
Minha publicação anterior no blog, Introdução a mocks em Python: finja antes de fazer, abordou os conceitos básicos de mocks e testes unitários em Python. Esta publicação aborda alguns princípios mais avançados de engenharia de software que minha experiência com testes em Python revelou ao longo do último ano e meio. Em particular, quero revisitar a ideia de aplicar patches a objetos mock em testes unitários.
Aplicando patches a clientes externos
Neste artigo, clientes são quaisquer objetos que geram efeitos colaterais, como operações de E/S em disco ou pela rede. Considere uma classe, CloudCreator, que recebe mensagens por HTTP, gera alguns efeitos colaterais ao criar infraestrutura na nuvem e envia mensagens por HTTP em resposta:
Podemos testar CloudCreator da seguinte forma:
patch nos permite testar nossa classe CloudCreator sem gerar efeitos colaterais na rede. No entanto, esse design tem algumas falhas. Se CloudCreator usar muitos clientes externos, precisaremos encadear várias chamadas a patch. Além disso, CloudCreator e seus testes unitários dependem fortemente de HTTPClient, o que dificulta a troca do cliente de rede.
O engenheiro da Fugue Josh Einhorn aponta outras desvantagens de patch:
Usá-lo significa que há dependências implícitas em algum ponto da classe — outro desenvolvedor nunca saberia disso. Os argumentos do construtor tornam as dependências explícitas.
Ao refatorar implementações subjacentes, usar
patchexige atualizar vários testes unitários sem relação entre si. Nem sempre fica claro quais testes precisarão de alterações, poispatchusa strings codificadas diretamente em vez de referências mais fortemente "tipadas" (que linters e IDEs podem detectar).Usar
patché um sinal de problema no código, pois significa que a classe em teste está acoplada a uma ou mais classes concretas externas.Código que depende de
patchpara testes unitários não é portável para outras linguagens. Linguagens com tipagem estática e compiladores não permitem monkey patching (sem um trabalho considerável). Seria necessário refatorar a estrutura para testar corretamente uma classe assim em outra linguagem.
Em geral, se testar uma classe exige aplicar muitos patchs a clientes externos, isso é um sinal de que é preciso refatorar. Engenheiros de software experientes perceberão que este exemplo é uma ótima oportunidade para aplicar a inversão de dependência.
Inversão e injeção de dependências
A inversão de dependência e, especificamente neste caso, a injeção de dependência são temas bastante conhecidos na engenharia de software. Para quem ainda não conhece o conceito, injeção de dependência é a ideia de fornecer a uma classe ou função os clientes externos dos quais ela depende, em vez de criá-los dentro dela. Assim, o código pode funcionar em vários contextos, conforme os clientes que recebe.
No nosso exemplo, a funcionalidade principal de CloudCreator — criar infraestrutura na nuvem — não depende de um meio específico para enviar e receber mensagens. Portanto, faz sentido escrever a classe de modo que a E/S de rede seja gerenciada por um cliente injetado em tempo de execução, em vez de ficar codificada diretamente (este código usa a sintaxe de anotação de tipos do Python):
Esse encapsulamento permite usar a classe com um cliente HTTP, TCP/IP, ZMQ ou SQS/SNS, desde que a E/S de rede siga uma interface predefinida em NetworkClient, como NetworkClient.recv() e NetworkClient.send(data). Isso simplifica bastante o processo. Quem estiver atento vai notar que definir a interface do cliente é de suma importância, mas esse é assunto para outra publicação.
Uma das principais vantagens da injeção de dependência é permitir que o desenvolvedor passe objetos mock com facilidade durante os testes unitários. Agora podemos configurar nossos testes assim:
Criamos um cliente de rede mock para testes unitários usando o argumento autospec de MagicMock, que cria um objeto mock compatível com a interface NetworkClient.
Neste exemplo simples, patch nos permitiu contornar um design inflexível criando um teste unitário ainda mais inflexível. Como mencionei antes, se você estiver aplicando patches a mais do que algumas chamadas, é sinal de que deve refatorar. Vale lembrar que patch ainda é útil, por exemplo, para aplicar patches a chamadas como time.time() ou a outras chamadas de bibliotecas sem efeitos colaterais.
Mais sobre injeção de dependência
A injeção de dependência é útil, mas o que fazer quando nossa classe precisa de muitos clientes? Podemos adicionar mais parâmetros e injetar cada cliente separadamente:
Com valores padrão, é possível inicializar clientes de forma seletiva, o que facilita os testes unitários (falaremos mais sobre isso adiante). No entanto, essa abordagem ainda é trabalhosa, especialmente em testes de integração. Imagine que alteramos nosso manipulador de mensagens e, em seguida, queremos testar se ele se comunica corretamente com o servidor. Inicializar um CloudCreator exige muito trabalho tedioso para criar e inicializar objetos cliente. Um dos pontos fortes do Python é seu interpretador interativo, que permite um processo de desenvolvimento iterativo. Manter a facilidade de uso do REPL facilita a vida dos desenvolvedores. Exigir que eles criem e inicializem uma dúzia de objetos cliente antes de testar uma pequena alteração na classe principal é frustrante.
Uma solução é encapsular a criação dos clientes em um objeto separado. Podemos até codificar informações sobre a ordem das operações e as dependências no construtor:
Observe que CloudCreator ainda é inicializado com referências explícitas aos serviços necessários. Assim, futuros desenvolvedores podem entender facilmente de quais serviços CloudCreator precisa. Também é possível defender um design em que o construtor de CloudCreator espere apenas um objeto CloudCreatorServices:
No entanto, isso vincula CloudCreator a uma implementação específica de CloudCreatorServices, com exatamente os serviços de que CloudCreator precisa. Se CloudCreatorServices for generalizado para criar serviços para várias classes, quem o chamar terá de presumir que todas as classes que usam a classe generalizada Service precisam de todos os serviços.
Infelizmente, essa implementação ingênua perde a flexibilidade da injeção direta de dependências. Um passo à frente, outro atrás. É fácil resolver isso:
Até aqui, só mudamos a complexidade de lugar. O desenvolvedor ainda é responsável por inicializar todos os clientes. Não oferecemos ferramentas para facilitar seu trabalho. Podemos fazer isso incorporando procedimentos complexos de inicialização em CloudCreatorServices:
Agora, ocultamos a criação dos clientes nesses métodos de inicialização. Parece uma boa solução, mas, analisando melhor, há uma desvantagem. Quando CloudCreatorServices é inicializado, criamos todos os clientes, mesmo que saibamos que não vamos usá-los. E se um dos nossos serviços cliente estiver com problemas e atingir o tempo limite, mas ainda quisermos testar outras funcionalidades? Há espaço para ainda mais flexibilidade?
Podemos usar métodos getter para mudar a ordem da inicialização:
Essa solução nos oferece carregamento tardio: os clientes só são inicializados quando necessário, sem perder a possibilidade de substituí-los. No entanto, métodos getter não são muito idiomáticos em Python. Será que existe algum recurso da linguagem que possamos aproveitar para encontrar uma abordagem mais natural em Python?
@property
O recurso que estamos procurando é @property:
Esta solução é parecida com a anterior, que usa métodos getter, mas removemos o get_ e adicionamos o decorador @property. @property transforma um método getter em uma propriedade. Uma propriedade pode ser acessada diretamente, como CloudCreatorServices.database_client, sem parênteses. Além disso, usar @property permite adicionar um setter no futuro, decorando a função setter de uma propriedade com @.setter, por exemplo:
O setter será chamado automaticamente quando atribuirmos um valor a database_client:
Usar @property mantém o padrão do Python de acessar diretamente os atributos de instância, ao mesmo tempo que oferece a flexibilidade de encapsular o acesso aos atributos em getters e setters.
Usando mocks com injeção de dependência
Uma das vantagens da inversão de dependência é simplificar bastante os testes unitários. Lembre-se de que os argumentos de inicialização de CloudCreator têm valores padrão, o que nos permite criar mocks seletivamente para objetos de serviços cliente em testes específicos:
Como os outros serviços cliente não são inicializados, fica fácil perceber se o caminho de execução do código de rede acessa objetos fora do seu escopo, o que geralmente indica que algo está errado. É claro que testes unitários abrangentes exigiriam mocks para todos os serviços, mas é fácil adicioná-los.
Com a inversão de dependência, eliminamos a necessidade de aplicar patch nos testes unitários e ainda oferecemos aos desenvolvedores uma ferramenta poderosa que economiza tempo nos testes de integração.
