Skip to main content

Revisitando testes unitários e mocks em Python

Escrito por
blog hero python code purple

7 de julho de 2018

0 minutos de leitura

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:

import http_client
 class CloudCreator :

def __init__(self) :

self.network_client = http_client.HTTPClient() 

Podemos testar CloudCreator da seguinte forma:

import unittest
import http_client from unittest.mock 
import MagicMock, patch 

class TestCloudCreator (unittest.TestCase) :  
@patch('http_client.HTTPClient')   

	def setUp (self, mock_http_client_call) :   
	self.mock_http_client = MagicMock(autospec=http_client.HTTPClient)

	# Recall that patch patches the _initialization_ call of classes 
	mock_http_client_call.return_value = self.mock_http_client   
	self.cloud_creator = CloudCreator()

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 patch exige atualizar vários testes unitários sem relação entre si. Nem sempre fica claro quais testes precisarão de alterações, pois patch usa 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 patch para 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):

class CloudCreator :   
def __init__(self, network_client: NetworkClient) : 
self.network_client = network_client 

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:

import unittest from unittest.mock 
import MagicMock 
class TestCloudCreator (unittest.TestCase) :   

def setUp (self) :   
self.mock_network_client = MagicMock(autospec=NetworkClient)  
self.cloud_creator = CloudCreator(self.mock_network_client)

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:

class CloudCreator :     
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None:
 AccountsClient, 
 log_writer=None: LogWriter, 
 health_check=None: HealthCheckClient, 
 metrics=None: MetricsWriter, 
 database_client=None: DatabaseClient) :   

 self.network_client = network_client 
 self.authz_client = authz_client 
 self.accts_client = accts_client 
 self.log_writer = log_writer
 self.health_check = health_check 
 self.metrics = metrics 
 self.database_client = database_client

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:

class CloudCreatorServices :   
def __init__() : 

 self.network_client = HTTPClient()   
 self.database_client = SQLClient()     
 self.authz_client = AuthzClient(self.database_client)   
 self.accts_client = AcctsClient(self.database_client)   
 self.log_writer = LogWriter()   
 self.health_check = HealthCheck()   
 self.metrics = Metrics() 

def main() :  
 services = CloudCreatorServices()  
 cc = CloudCreator(services.network_client, services.authz_client, services.accts_client, services.log_writer, services.health_check, services.metrics, services.database_client)

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:

class CloudCreator : 

def __init__(self, services: CloudCreatorServices) :   
	self.services = services 

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:

class CloudCreatorServices :   
def __init__(self, network_client: NetworkClient, authz_client: AuthzClient, accts_client: AccountsClient, log_writer: LogWriter, health_check: HealthCheckClient, metrics: MetricsWriter, database_client: DatabaseClient) :

	self.network_client = network_client   
	self.authz_client = authz_client   
	self.accts_client = accts_client   
	self.log_writer = log_writer   
	self.health_check = health_check   
	self.metrics = metrics   
	self.database_client = database_client

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:

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self.database_client = database_client or self._get_database_client()   
	self.network_client = network_client or self._get_network_client()   
	self.authz_client = authz_client or self._get_authz_client(self.database_client)   
	self.accts_client = accts_client or self._get_accts_client(self.database_client)   
	self.log_writer = log_writer or self._get_log_writer()   
	self.health_check = health_check or self._get_health_check()   
	self.metrics = metrics or self._get_metrics() 

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:

class CloudCreatorServices :  
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client  
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check  
	self._metrics = metrics  

def get_database_client (self) DatabaseClient :   

	if not self._database_client:   
	self._database_client = DatabaseClient()   
	return self._database_client  

	def get_authz_client (self) AuthzClient :   

	if not self._authz_client:   
	self.authz_client = AuthzClient(self.get_database_client())   
	return self.authz_client 
	...

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:

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client   
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check   
	self._metrics = metrics @property   

def database_client (self)  DatabaseClient :   

	if not self._database_client:    
	self._database_client = DatabaseClient()   
	return self._database_client  

def authz_client (self)  AuthzClient :   

	if not self._authz_client:    
	self.authz_client = AuthzClient(self.database_client)   
	return self.authz_client  
	...

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:

class CloudCreatorServices :  
... 
@property   
def database_client (self)  DatabaseClient :   

if not self._database_client:    
self._database_client = DatabaseClient()   
return self._database_client @database_client.setter   

def _set_database_client (self, database_client: DatabaseClient) :   
self.database_client = database_client

O setter será chamado automaticamente quando atribuirmos um valor a database_client:

services = CloudCreatorServices() 
# calls _set_database_client services.database_client = DatabaseClient()

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:

import unittest from unittest.mock 
import MagicMock class TestCloudCreator (unittest.TestCase) :   

def test_network_write_method (self) :   
	self.mock_network_client = MagicMock(autospec=NetworkClient)   
	self.cloud_creator = CloudCreator(network_client=self.mock_network_client)   
	...

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.

Publicado em: