Skip to main content

Revisemos las pruebas unitarias y los mocks en Python

Escrito por
blog hero python code purple

7 de julio de 2018

0 minutos de lectura

En mi publicación anterior, Python Mocking 101: Fake It Before You Make It, expliqué los fundamentos de los mocks y las pruebas unitarias en Python. Esta publicación aborda algunos principios de ingeniería de software de alto nivel que se reflejan en mi experiencia con las pruebas de Python durante el último año y medio. En particular, quiero volver a analizar la idea de aplicar parches a objetos mock en las pruebas unitarias.

Aplicar parches a clientes externos

En esta publicación, los clientes son cualquier objeto que genere efectos secundarios, como operaciones de E/S en disco o por red. Considera una clase, CloudCreator, que recibe mensajes por HTTP, genera efectos secundarios al crear infraestructura en la nube y responde enviando mensajes por HTTP:

import http_client
 class CloudCreator :

def __init__(self) :

self.network_client = http_client.HTTPClient() 

Podemos probar CloudCreator de la siguiente manera:

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 probar nuestra clase CloudCreator sin generar efectos secundarios en la red. Sin embargo, este diseño tiene algunos problemas. Si CloudCreator usa muchos clientes externos, tenemos que encadenar muchas llamadas a patch. Además, CloudCreator y sus pruebas unitarias dependen estrechamente de HTTPClient, lo que dificulta cambiar el cliente de red.

El ingeniero de Fugue Josh Einhorn señala otras desventajas de patch:

  • Usarlo implica que hay dependencias implícitas en alguna parte de la clase, algo que otro desarrollador nunca sabría. Los argumentos del constructor hacen que las dependencias sean explícitas.

  • Al refactorizar implementaciones subyacentes, usar patch obliga a actualizar varias pruebas unitarias que no están relacionadas. Además, no siempre queda claro qué pruebas unitarias hay que cambiar, porque patch usa cadenas codificadas de forma rígida en lugar de referencias más "tipadas" (que los linters o los IDE pueden detectar).

  • Usar patch es una señal de alerta en el código, porque significa que la clase bajo prueba está acoplada a una o más clases concretas externas.

  • El código que depende de patch para las pruebas unitarias no es portable a otros lenguajes. Los lenguajes con tipos estáticos y compiladores no permiten el monkey patching (sin un trabajo considerable). Para probar correctamente una clase de este tipo en otro lenguaje, habría que refactorizar su estructura.

En general, si para probar una clase hay que aplicar muchos patch a clientes externos, es señal de que hace falta una refactorización. Los ingenieros de software con experiencia verán que este ejemplo es una oportunidad ideal para invertir las dependencias.

Inversión e inyección de dependencias

La inversión de dependencias, y en particular la inyección de dependencias en este caso, son temas muy conocidos en el mundo de la ingeniería de software. Para quienes no los conozcan, la inyección de dependencias consiste en que una clase o función reciba los clientes externos de los que depende, en lugar de crearlos por su cuenta. Así, el código puede funcionar en distintos contextos, según los clientes que reciba.

En nuestro ejemplo, la función principal de CloudCreator, crear infraestructura en la nube, no depende de una forma específica de enviar y recibir mensajes. Por lo tanto, lo lógico es escribir la clase de modo que la E/S de red esté a cargo de un cliente que se inyecta en tiempo de ejecución, en lugar de estar codificado de forma rígida (este código usa la sintaxis de Python para las anotaciones de tipo):

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

Esta encapsulación permite usar la clase con un cliente HTTP, TCP/IP, ZMQ o SQS/SNS, siempre que la E/S de red cumpla con una interfaz predefinida en NetworkClient, como NetworkClient.recv() y NetworkClient.send(data). Esto simplifica mucho el proceso. Los observadores atentos notarán que definir la interfaz del cliente cobra una importancia fundamental, pero ese es tema para otra publicación.

Una de las principales ventajas de la inyección de dependencias es que permite al desarrollador pasar objetos mock con facilidad durante las pruebas unitarias. Ahora podemos configurar las pruebas así:

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)

Creamos un cliente de red mock para las pruebas unitarias y usamos el argumento autospec de MagicMock para crear un objeto mock que cumpla con la interfaz NetworkClient.

En este sencillo ejemplo, patch nos permitió disimular un diseño inflexible creando una prueba unitaria aún más inflexible. Como señalé antes, si aplicas parches a más de unas cuantas llamadas, es señal de que deberías refactorizar. Ten en cuenta que patch sigue siendo útil, por ejemplo, para aplicar parches a llamadas como time.time() o a otras llamadas de biblioteca sin efectos secundarios.

Más sobre la inyección de dependencias

La inyección de dependencias es útil, pero ¿qué debemos hacer cuando nuestra clase necesita muchos clientes? Podemos agregar más parámetros e inyectarlos por separado:

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

Al agregar valores predeterminados, podemos inicializar los clientes de forma selectiva, lo que facilita las pruebas unitarias (volveremos sobre esto más adelante). Sin embargo, esta forma sigue siendo poco práctica, sobre todo al hacer pruebas de integración. Imagina que modificamos nuestro controlador de mensajes y luego queremos comprobar que se comunica correctamente con el servidor. Inicializar un CloudCreator implica el tedioso trabajo de crear e inicializar objetos cliente. Una de las fortalezas de Python es su intérprete interactivo, que permite un proceso de desarrollo iterativo. Mantener la posibilidad de usar fácilmente el REPL facilita la vida de los desarrolladores. Exigirles que creen e inicialicen una docena de objetos cliente antes de probar un pequeño cambio en la clase principal genera frustración.

Una solución es encapsular la creación de clientes en un objeto aparte. Incluso podemos codificar en el constructor información sobre el orden de las operaciones y las dependencias:

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)

Observa que CloudCreator sigue inicializándose con referencias explícitas a los servicios que necesita. Así, los futuros desarrolladores pueden entender fácilmente qué servicios requiere CloudCreator. También es posible proponer un diseño en el que el constructor de CloudCreator solo espere un objeto CloudCreatorServices:

class CloudCreator : 

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

Sin embargo, esto vincula CloudCreator a una implementación específica de CloudCreatorServices, con exactamente los servicios que CloudCreator necesita. Si generalizamos CloudCreatorServices para crear servicios para varias clases, quien llama debe suponer que todas las clases que usan la clase generalizada Service necesitan todos los servicios.

Por desgracia, esta implementación ingenua pierde la flexibilidad de la inyección de dependencias directa. Un paso adelante y otro atrás. Es fácil solucionarlo:

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

En este punto, lo único que hicimos fue trasladar la complejidad. El desarrollador sigue siendo responsable de inicializar todos los clientes. No le hemos dado herramientas para facilitarle la vida. Podemos hacerlo incorporando procedimientos de inicialización complejos en 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() 

Ahora ocultamos la creación de clientes en estos métodos de inicialización. Parece una buena solución, pero, si lo analizamos con más detenimiento, tiene una desventaja. Al inicializar CloudCreatorServices, creamos todos los clientes, aunque sepamos que no los vamos a usar. ¿Qué hacemos si uno de nuestros servicios cliente falla y agota el tiempo de espera, pero aun así queremos probar otra funcionalidad? ¿Podemos hacerlo aún más flexible?

Podemos usar métodos getter para cambiar el orden de inicialización:

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 
	...

Esta solución nos permite cargar los clientes de forma diferida, es decir, solo se inicializan cuando se necesitan, y mantener la posibilidad de reemplazarlos según sea necesario. Sin embargo, los métodos getter no son muy propios de Python. ¿Hay alguna característica del lenguaje que podamos aprovechar para encontrar una forma más propia de Python?

@property

La característica que buscamos es @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  
	...

Esto se parece a nuestra solución anterior con métodos getter, pero quitamos get_ y agregamos el decorador @property. @property convierte un método getter en una propiedad. Se puede acceder directamente a una propiedad, como CloudCreatorServices.database_client, sin paréntesis. Además, usar @property nos permite agregar un setter en el futuro. Por ejemplo, podemos decorar la función setter de una propiedad con @.setter:

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

El setter se llamará de forma transparente cuando asignemos un valor a database_client:

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

Usar @property mantiene la convención de Python de acceder directamente a los atributos de instancia y, al mismo tiempo, nos da la flexibilidad de envolver el acceso a los atributos en getters y setters.

Mocks con inyección de dependencias

Una de las ventajas de invertir las dependencias es que simplifica mucho las pruebas unitarias. Recuerda que los argumentos de inicialización de CloudCreator tienen valores predeterminados, lo que nos permite crear mocks de forma selectiva para los objetos de servicios cliente en pruebas específicas:

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 los demás servicios cliente no se inicializan, es fácil detectar si la ruta de código de red accede a objetos fuera de su ámbito, lo que suele ser señal de que algo anda mal. Por supuesto, para tener pruebas unitarias exhaustivas se necesitarían mocks para cada servicio, pero es fácil agregarlos.

Al invertir las dependencias, eliminamos la necesidad de aplicar patch en nuestras pruebas unitarias y, a la vez, ofrecemos a los desarrolladores una herramienta eficaz que les ahorra tiempo en las pruebas de integración.

Publicado en: