Skip to main content

Revisiter les tests unitaires et les mocks en Python

Écrit par
blog hero python code purple

7 juillet 2018

0 minutes de lecture

Note de la rédaction

Cet article est paru à l’origine sur fugue.co. Fugue a rejoint Snyk en 2022 et constitue un élément clé de Snyk IaC.

Dans mon précédent article de blog, Python Mocking 101: Fake It Before You Make It, j’ai abordé les mécanismes de base du mocking et des tests unitaires en Python. Cet article présente quelques principes avancés de génie logiciel mis en évidence par mon expérience des tests Python au cours de l’année et demie écoulée. Je souhaite notamment revenir sur l’idée d’utiliser le patching des objets mock dans les tests unitaires.

Patcher les clients externes

Dans cet article, le terme « clients » désigne tout objet qui produit des effets de bord, comme des opérations d’E/S sur disque ou réseau. Prenons une classe, CloudCreator, qui reçoit des messages via HTTP, génère des effets de bord en créant une infrastructure cloud, puis envoie des messages via HTTP en réponse :

import http_client
 class CloudCreator :

def __init__(self) :

self.network_client = http_client.HTTPClient() 

Nous pouvons tester CloudCreator comme suit :

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 nous permet de tester notre classe CloudCreator sans générer d’effets de bord réseau. Toutefois, cette conception présente quelques défauts. Si CloudCreator utilise beaucoup de clients externes, nous devons empiler de nombreux appels à patch. De plus, CloudCreator et ses tests unitaires dépendent fortement de HTTPClient, ce qui complique le changement de client réseau.

Josh Einhorn, ingénieur chez Fugue, relève d’autres inconvénients de patch :

  • Son utilisation implique des dépendances implicites quelque part dans la classe : un autre développeur ne pourrait pas les connaître. Les arguments du constructeur rendent les dépendances explicites.

  • Lorsqu’on refactorise les implémentations sous-jacentes, l’utilisation de patch oblige à mettre à jour plusieurs tests unitaires sans rapport entre eux. De plus, il n’est pas toujours évident de savoir quels tests unitaires doivent être modifiés, car patch utilise des chaînes codées en dur plutôt que des références plus fortement « typées » (que les linters et les IDE peuvent détecter).

  • Utiliser patch est un signe de mauvaise conception, car cela signifie que la classe testée est couplée à une ou plusieurs autres classes concrètes autres.

  • Le code qui s’appuie sur patch pour les tests unitaires n’est pas portable vers d’autres langages. Les langages à typage statique avec compilateur ne permettent pas le monkey patching (sans un travail considérable). Il faudrait refactoriser la structure pour tester correctement une telle classe dans un autre langage.

En général, si tester une classe nécessite de nombreux appels à patch pour les clients externes, c’est le signe qu’une refactorisation s’impose. Les ingénieurs logiciels expérimentés verront dans cet exemple une occasion idéale d’inverser les dépendances.

Inversion et injection des dépendances

L’inversion des dépendances, et plus précisément l’injection des dépendances dans ce cas, sont des notions bien connues dans le monde du génie logiciel. Pour les néophytes, l’injection des dépendances consiste à fournir à une classe ou à une fonction les clients externes dont elle dépend, plutôt qu’à les créer elle-même. Le code peut ainsi s’exécuter dans différents contextes, selon les clients qui lui sont fournis.

Dans notre exemple, la fonction principale de CloudCreator, qui consiste à créer une infrastructure cloud, ne dépend d’aucun moyen particulier d’envoyer et de recevoir des messages. Il est donc logique d’écrire la classe de façon à ce que les E/S réseau soient gérées par un client injecté à l’exécution, plutôt que codées en dur (ce code utilise la syntaxe de typage des annotations de Python) :

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

Cette encapsulation permet d’utiliser la classe avec un client HTTP, TCP/IP, ZMQ ou SQS/SNS, à condition que les E/S réseau respectent une interface prédéfinie dans NetworkClient, telle que NetworkClient.recv() et NetworkClient.send(data). Le processus s’en trouve grandement simplifié. Les observateurs attentifs noteront que la définition de l’interface client devient primordiale, mais c’est là le sujet d’un autre article de blog.

L’un des principaux avantages de l’injection des dépendances est qu’elle permet au développeur de transmettre facilement des objets mock pour les tests unitaires. Nous pouvons désormais configurer nos tests comme suit :

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)

Nous créons un client réseau mock pour les tests unitaires, en utilisant l’argument autospec de MagicMock afin de créer un objet mock conforme à l’interface NetworkClient.

Dans cet exemple simple, patch nous a permis de masquer une conception rigide en créant un test unitaire encore plus rigide. Comme je l’ai indiqué plus haut, si vous patchez plus de quelques appels, c’est le signe qu’une refactorisation s’impose. Notez que patch reste utile, par exemple pour patcher les appels à time.time() ou à d’autres fonctions de bibliothèque sans effet de bord.

Approfondir l’injection des dépendances

L’injection des dépendances est utile, mais que faire lorsque notre classe a besoin d’un grand nombre de clients ? Nous pouvons ajouter d’autres paramètres et les injecter séparément :

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

En ajoutant des valeurs par défaut, nous pouvons initialiser certains clients seulement, ce qui facilite les tests unitaires (nous y reviendrons). Toutefois, cette forme reste peu pratique, notamment pour les tests d’intégration. Imaginons que nous modifiions notre gestionnaire de messages et souhaitions ensuite vérifier qu’il communique correctement avec le serveur. L’initialisation de CloudCreator demande beaucoup de travail fastidieux pour créer et initialiser des objets clients. L’un des atouts de Python est son interpréteur interactif, qui permet un processus de développement itératif. Préserver la possibilité d’utiliser facilement le REPL simplifie la vie des développeurs. Les obliger à créer et initialiser une douzaine d’objets clients avant de pouvoir tester une petite modification de la classe principale est frustrant.

Une solution consiste à encapsuler la création des clients dans un objet distinct. Nous pouvons même intégrer au constructeur les informations sur l’ordre des opérations et les dépendances :

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)

Notez que CloudCreator est toujours initialisé avec des références explicites aux services dont il a besoin. Les futurs développeurs peuvent ainsi facilement comprendre quels services sont nécessaires à CloudCreator. On peut aussi envisager une conception dans laquelle le constructeur de CloudCreator attend uniquement un objet CloudCreatorServices :

class CloudCreator : 

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

Toutefois, cela lie CloudCreator à une implémentation spécifique de CloudCreatorServices, qui contient exactement les services dont CloudCreator a besoin. Si CloudCreatorServices est généralisé pour créer des services pour plusieurs classes, l’appelant doit supposer que chaque classe utilisant la classe générique Service a besoin de tous les services.

Malheureusement, cette implémentation naïve nous fait perdre la souplesse de l’injection directe des dépendances. Un pas en avant, un pas en arrière. Le problème se corrige facilement :

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

Pour l’instant, nous n’avons fait que déplacer la complexité. Le développeur doit toujours initialiser tous les clients. Nous ne lui avons fourni aucun outil pour lui faciliter la tâche. Nous pouvons le faire en intégrant des procédures d’initialisation complexes dans 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() 

Nous avons maintenant masqué la création des clients dans ces méthodes d’initialisation. La solution semble bonne, mais un examen plus approfondi révèle un inconvénient. À l’initialisation de CloudCreatorServices, nous créons tous les clients, même si nous savons que nous ne les utiliserons pas. Que faire si l’un de nos services clients ne répond pas correctement et finit par expirer, alors que nous voulons tout de même tester d’autres fonctionnalités ? Peut-on gagner encore en souplesse ?

Nous pouvons utiliser des méthodes d’accesseur pour modifier l’ordre d’initialisation imposé :

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

Cette solution permet le chargement différé : les clients sont initialisés uniquement lorsque c’est nécessaire, tout en conservant la possibilité de les remplacer. Toutefois, les méthodes d’accesseur ne sont pas très idiomatiques en Python. Pourrions-nous tirer parti d’une fonctionnalité du langage afin de trouver une approche plus naturelle en Python ?

@property

La fonctionnalité que nous recherchons est @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  
	...

Cette solution ressemble à la précédente, qui utilisait des méthodes d’accesseur, mais nous avons supprimé get_ et ajouté le décorateur @property. @property transforme une méthode d’accesseur en propriété. Une propriété peut être consultée directement, comme CloudCreatorServices.database_client, sans parenthèses. De plus, @property permet d’ajouter ultérieurement un setter, en décorant la fonction setter d’une propriété avec @.setter, par exemple :

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

Le setter sera appelé automatiquement lorsque nous attribuerons une valeur à database_client :

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

L’utilisation de @property respecte la convention Python qui consiste à accéder directement aux attributs d’instance, tout en nous permettant d’encapsuler l’accès aux attributs dans des accesseurs et des mutateurs.

Le mocking avec l’injection des dépendances

L’un des avantages de l’inversion des dépendances est qu’elle simplifie considérablement les tests unitaires. Rappelons que les arguments d’initialisation de CloudCreator ont des valeurs par défaut, ce qui nous permet de créer des mocks pour certains objets de service client uniquement, selon les besoins de chaque test :

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

Comme les autres services clients ne sont pas initialisés, il est facile de repérer si le chemin d’exécution du code réseau touche des objets hors de son périmètre, ce qui indique généralement un problème. Bien sûr, des tests unitaires complets nécessiteraient des mocks pour chaque service, mais il est facile de les ajouter.

Grâce à l’inversion des dépendances, nous avons supprimé le besoin de recourir au patching avec patch dans nos tests unitaires, tout en offrant aux développeurs un outil puissant qui leur fait gagner du temps lors des tests d’intégration.

Publié dans: