Article très technique
Introduction
J'ai été moi-même dans la situation de vouloir mocker quelque chose, d'échouer encore et encore, avec un douloureux acharnement.
Et je sais que je ne suis pas le seul, donc je vous propose ce petit guide tactique de comment mocker : des exemples concrets et expliqués.
Une petite présentation du mocking
Si vous connaissez déjà les mocks, cette première section est un peu redondante, mais je prends néanmoins le temps de re-contextualiser l'utilisation des mocks.
Le code est rarement écrit de façon à être facilement testable. Dans le cadre des tests (notamment "unitaires"), il y a souvent des dépendances dont on aimerait se passer, des effets de bord qu'on voudrait empêcher ou bien vérifier, des conditions difficiles à réunir normalement. Au cinéma, les acteurs et actrices ont des "doublures" pour effectuer certaines scènes dangereuses ou techniques. Pour les tests, il y a les test doubles ("doublures de test") qui sont des objets spécialement définis par nos tests, qu'on va pouvoir utiliser lors de nos scénarios spécifiques, en remplacement d'un objet normalement présent dans l'application.
Il existe différents types de test doubles, mais on ne dispose pas de ces nuances en Python car les mocks combinent presque toutes les fonctionnalités qu'on pourrait vouloir. Si vous souhaitez avoir plus de détails sur ce sujet, je vous recommande leurs définitions par Martin Fowler. Néanmoins, même sans ces nuances, il convient de distinguer leurs 3 cas principaux d'utilisation :
- Soit pour tester comment le code testé interagit avec d'autres objets (qui il appelle, combien de fois, avec quels paramètres, ...), c'est alors du collaboration testing (le mock y joue le rôle de spy : il espionne les communications entre les objets).
# je veux tester que
print_report()
...
# va bien appeler `printer` comme il faut :
printer(file="report.pdf", indent=4, bw=True)
- Soit pour remplacer certains objets afin de réunir les conditions technico-fonctionnelles nécessaires pour tester certains scénarios (le mock sert alors de dummy ou de fake selon l'usage).
# je voudrais que
datetime.today()
# me renvoie un jour férié une année bissextile
# ou bien que
file.read()
# me lève parfois une OSError comme si la clé USB avait été arrachée
# ou bien tester le code sans accéder à la base de données de production
data = load_data_from_database()
- Soit pour remplacer certains objets simplement parce que le code est peu pratique sinon, mais dans l'absolu on pourrait s'en passer au prix de nombreux désagréments (il s'agit alors d'un stub).
# je veux tester ma fonction `f`
def f(x):
y = slow_computation(x)
return 0 if y < 10 else y - 1
# ce n'est pas la fonction `slow_computation` que je veux tester
# mais je voudrais qu'elle soit rapide pour que mon test de `f` le soit aussi
# ou bien ma fonction requiert un paramètre y
def f(x: int, y: Any):
return [x, y] if x > 10 else []
# mais sa valeur est inintéressante dans le cadre du test
Et parfois c'est un mix des 3 ...
Nous allons donc voir comment utiliser les mocks de Python dans des cas très concrets. Par agnosticisme, je n'utiliserai que la lib standard (unittest.mock), mais ces techniques fonctionnent pareillement dans toutes les stacks de test Python. Bien qu'ils soient numérotés, les cas présentés ne sont pas indépendants, chacun se base sur ce qui a été expliqué avant lui. Si vous êtes débutants dans le mocking Python, il vaut donc mieux les lire dans l'ordre.
Cas pratique n°1 : mocker une fonction d'un module importé
import datetime
def get_tomorrow() -> datetime.date:
today_date = datetime.today()
tomorrow_date = today_date + datetime.timedelta(days=1)
return tomorrow_date
Je voudrais que cette fonction marche pour tous les jours de l'année, ce qui inclut par exemple le 31 Décembre et le 28 Février bissextile. Mais parce que la fonction fait appel en interne à
datetime.today(), son résultat dépend du jour auquel j'exécute le test. Je vais donc mocker
datetime.today() pour qu'elle renvoie le jour que je veux à la place.
import datetime as dt
import unittest.mock as mock
def test_leap_year():
# 2024 est bissextile (il y a un 29 février)
feb28_2024 = dt.date(2024, 2, 28)
feb29_2024 = dt.date(2024, 2, 29)
with mock.patch("datetime.today", return_value=feb28_2024):
assert get_tomorrow() == feb29_2024
def test_not_leap_year():
# 2025 n'est pas bissextile (il n'y en a pas)
feb28_2025 = dt.date(2025, 2, 28)
mar01_2025 = dt.date(2025, 3, 1)
with mock.patch("datetime.today", return_value=feb28_2025):
assert get_tomorrow() == mar01_2025
J'ai utilisé unittest.mock.patch sous sa forme de context manager (with) qui permet que le mock soit temporaire (quelle que soit l'issue) afin d'isoler le test.
Je lui ai donné le chemin qui permet d'importer la fonction que je veux mocker : lui va s'assurer que datetime ait été importé (ou le faire le cas échéant) puis faire un datetime.today = the_mock. On peut en voir l'effet si je print(dt.today) : ce serait <MagicMock name='today' id='2925466858496'> pendant que le patch est appliqué, et ce serait <built-in function today> avant ou après. C'est une manière très efficace et concrète de vérifier que le mock est bien en place. Et avec un debugger et un point d'arrêt, on peut même le vérifier soi-même.
On peut le visualiser ainsi :

Le second paramètre de patch est l'argument mot-clé ("kwarg") return_value qui sera transmis au Mock qu'il va instancier, et qui indique que le mock doit renvoyer cette valeur chaque fois qu'il est appelé (())
Cas pratique n°2 : mocker une fonction importée d'un module avec le mot clé from
from datetime import date, today, timedelta
def get_tomorrow() -> date:
today_date = today()
tomorrow_date = today_date + timedelta(days=1)
return tomorrow_date
Ce code est presque identique à celui du cas n°1, à ceci près qu'il from import à la place de seulement import. Ce qui peut sembler anodin, mais les tests correspondants ne fonctionnent plus, la fonction today semblant ne plus être mockée (elle renvoie la date d'aujourd'hui).
Ce qu'il se passe s'explique par le fonctionnement technique des imports, faisons un bref détour explicatif. L'instruction import datetime peut se voir comme 2 opérations distinctes :
- S'assurer que le module correspondant est chargé (cf. la fonction importlib.import_module)
- Dans le module qui réalise l'import, assigner à une variable avec le nom voulu la valeur "le module importé"
Autrement dit, on pourrait récrire cet import datetime = importlib.import_module("datetime"). On voit que le nom du module est répété dans ce cas, mais ça rend explicite le fonctionnement des import ... as ... car il suffit d'assigner une variable avec nom différent de celui du module importé.
Pour ce qui nous intéresse, le from ... import ... va un peu plus loin : il va charger le module demandé par le from et chercher ce qu'on veut importer dedans pour l'assigner dans une variable du module qui réalise l'import.
En gros, il va faire : today = importlib.import_module("datetime").today.
Ce que ça change concrètement pour notre test, c'est une histoire de "références" (ou de "pointeurs" selon les préférences).
Les références c'est simplement le fait de désigner qu'un objet Python (une list, un dict, une instance d'une class, ...) possède des valeurs qui sont d'autres objets Python. Par exemple pour ["Hello", 14] on a une liste qui possède 2 références : une sur la str "Hello", une seconde sur l'int 14. Et pareil pour les modules : tout ce qu'on définit dedans (imports de modules, constantes, définitions de fonctions) seront des références vers ces objets (respectivement des modules, des str, des functions, ...).
On peut maintenant revenir au test, en voyant ce qui a changé au niveau des références entre les 2 cas. Auparavant, le code testé et celui du test partageaient ("avaient chacun une référence vers") le même objet-module datetime, dont on changeait la référence today par celle du mock. Alors qu'ici le code testé copie la référence de today au moment de l'import et la stocke en local sous le même nom. Donc quand on patche celle dans l'objet-module datetime, on n'a pas remplacé la référence locale qui est vraiment utilisée, on n'a pas mocké la bonne référence. Autrement dit on n'a pas mocké où il fallait.
Voici la situation où le test a échoué car ce n'est pas le mock qui a été appelé :

Assez visuellement, on voit que ce n'est donc pas datetime.today mais bien main.today qu'il faut mocker :

Ayant compris cela, on peut ajuster notre patch :
import datetime as dt
import unittest.mock as mock
from main import get_tomorrow
def test_leap_year():
feb28_2024 = dt.date(2024, 2, 28)
feb29_2024 = dt.date(2024, 2, 29)
# 👇
with mock.patch("main.today", return_value=feb28_2024):
assert get_tomorrow() == feb29_2024
def test_not_leap_year():
feb28_2025 = dt.date(2025, 2, 28)
mar01_2025 = dt.date(2025, 3, 1)
# 👇
with mock.patch("main.today", return_value=feb28_2025):
assert get_tomorrow() == mar01_2025
Au lieu de patcher là d'où vient ce qu'on veut mocker (ici le module commun datetime), il faut patcher l'endroit où il est utilisé (ici le module main).
Et c'est même une règle générale ! Si on l'applique au cas n°1 : with mock.patch("main.datetime.today", ...) aurait eu le même effet (bien que textuellement plus long). Car comme vu précédemment, un import crée une variable, que l'on peut donc importer à son tour dans un autre module. Par exemple import math peut aussi se faire avec from statistics import math, mais possible ne veut pas dire recommandé !
Savoir où mocker est primordial, d'où ce paragraphe essentiel "where to patch" de la doc d'unittest.mock :
The basic principle is that you patch where an object is looked up, which is not necessarily the same place as where it is defined.
Le principe de base est de patcher où l'objet est cherché, ce qui n'est pas nécessairement le même endroit que là où il est défini.
Ici, bien que
today soit définie dans
datetime, ce qu'il faut c'est mocker la référence qui est utilisé, ici dans le
main.
Cas pratique n°3 : mocker une fonction importée avec un alias from x import y as z
from datetime import date, timedelta, today as autre_nom
def tomorrow() -> date:
today = autre_nom()
tomorrow = today + timedelta(days=1)
return tomorrow
Si vous avez bien compris l'explication du
import ... as ... précédemment alors ça devrait paraître simple ! La référence locale vers
datetime.today a juste un
autre_nom, il suffit donc d'ajuster le chemin patché :
import datetime as dt
import unittest.mock as mock
from main import get_tomorrow
def test_leap_year():
feb28_2024 = dt.date(2024, 2, 28)
feb29_2024 = dt.date(2024, 2, 29)
# 👇
with mock.patch("main.autre_nom", return_value=feb28_2024):
assert get_tomorrow() == feb29_2024
Je ne vais même pas faire un schéma cette fois, c'est vraiment pratiquement le même que le cas n°2, juste un nom de changé.
Cas pratique n°4 : mocker une dépendance d'une dépendance d'une dépendance ...
Jusqu'ici on avait des cas assez simples. Dans la vraie vie, on a rarement une hiérarchie de modules aussi simple. Voici un exemple plus complexe :
my_project/
- main.py # le code que je veux tester est ici
- infra.py
- providers.py
- config.py # ce que j'ai besoin de mocker est ici
(par lisibilité je me suis limité à ne pas utiliser de sub-packages, mais vous pouvez imaginer une arborescence plus profonde)
# fichier: main.py
import infra
def get_data():
provider_url = infra.providers.get_url_from_config_file()
# ...
# fichier: infra.py
import providers
# ...
# fichier: providers.py
from config import get_url_from_config_file
# ...
# fichier: config.py
def get_url_from_config_file() -> str: # à mocker !
# ...
On ne veut pas taper sur le serveur de prod pour nos tests. Mais son URL n'est malheureusement pas configurable, c'est le problème de hardcoder les constantes. Puisqu'on ne souhaite toutefois pas mocker les appels au serveur, mais simplement en utiliser un de test, alors il faudrait pouvoir renvoyer l'URL du serveur de test à la place.
Si on se rappelle la règle "patcher à l'endroit où c'est utilisé, pas à l'endroit où c'est défini" ça semble néanmoins moins clair ici : la fonction get_url_from_config_file est définie au fond de la hiérarchie d'imports dans config, et là où elle est utilisée c'est tout en haut dans main, et entre les deux dans providers , elle est localement copiée. Néanmoins, si on trace un diagramme de collaboration comme auparavant, les choses s'illuminent :

Ce que le code à tester utilise, c'est une référence vers
infra qui a une référence vers
providers qui a une référence vers
config.get_url_from_config_file. Donc en suivant la règle, ce qu'on veut mocker c'est celle dans
providers :
def test_not_using_prod_config():
with mock.patch("providers.get_url_from_config_file",
return_value="localhost:1234"):
get_data()
# ...
Dans le main, puisqu'on utilise infra.providers.get_url_from_config_file, on a donc seulement eu à changer vers quoi ce get_url_from_config_file pointe pour que ce soit notre mock. Cela va impacter tout le code qui fait appel à cette fonction, pendant que le patch est appliqué. Mais cela peut avoir d'autres effets innatendus ; les mocks ne sont pas toujours la meilleure solution.
Cas pratique n°5 : mocker une dépendance d'une instance d'une classe d'une dépendance d'une fonction d'une dépendance ...
Voici une version légèrement modifiée du cas n°4 :
# fichier: main.py
from infra import ProviderA
def get_data():
provider_url = ProviderA.config_url()
# ...
# fichier: infra.py
from providers import Provider
ProviderA = Provider(...)
# fichier: providers.py
from config import get_url_from_config_file
class Provider:
def config_url(self):
return get_url_from_config_file()
# fichier: config.py
def get_url_from_config_file() -> str: # à mocker !
# ...
Le main.py importe l'instance ProviderA depuis infra.py, infra.py qui l'a instancié à partir de la classe Provider importée depuis providers.py, providers.py qui l'a définie avec une méthode config_url() qui appelle la fonction get_url_from_config_file() qu'il a importé depuis config.py. Le get_data qu'on veut tester ne fait plus du tout référence à la fonction qui pose problème, il appelle juste config_url(). Est-ce vraiment plus compliqué ? Faisons un schéma !

En fait, il n'y a presque aucune différence : certes, l'utilisation de get_url_from_config_file est moins visible qu'auparavant, mais ça reste une référence obtenue par un import, bien qu'appelée (indirectement) via une instance et sa méthode. Ainsi, l'exact même code de test que pour le cas n°4 fonctionne, on remplace juste la référence importée dans le module providers, car c'est celle qui est utilisée.

Au vu du code testé, on aurait peut-être plutôt pu mocker la méthode config_url de l'instance ProviderA. Certes ça aurait permis d'avoir un mock plus clair par rapport au code testé. Mais ça aurait aussi voulu dire que s'il y a le moindre appel à get_config_url_from_file ailleurs dans le code, présent ou futur, il n'aura pas l'URL de test. Ça peut être un problème. Ou ne pas en être un. Cela dépend de votre approche des tests : s'ils sont fait pour évoluer fréquemment avec le code, ou si au contraire, on recherche la contravariance des tests. Il n'y a pas de meilleure solution universelle.
Mais au fait, comment on aurait pu mocker une méthode ?
Cas pratique n°6 : mocker une méthode d'une variable importée
Reprenons l'exemple du cas n°5 :
# fichier: main.py
from infra import ProviderA
def get_data():
provider_url = ProviderA.config_url()
# ...
# fichier: infra.py
from providers import Provider
ProviderA = Provider(...) # qu'importe
On veut pouvoir renvoyer une URL de notre choix quand la fonction
main.get_data() appelle sur l'instance
ProviderA sa méthode
config_url(). Essayons de mocker la méthode commeon a vu jusqu'à maintenant :
def test_not_using_prod_config():
# 👇
with mock.patch("infra.ProviderA.config_url", # la méthode de classe dans le module
return_value="localhost:1234"):
get_data()
# ...
Et ça marche ! Si on veut vérifier, on peut ajouter un print(ProviderA.config_url) qui nous donnera <bound method Provider.config_url of <providers.Provider object at 0x0000026a31d45c10>> normalement, et lorsqu'il est mocké <MagicMock name='config_url' id='3008924768912'>.
On peut voir sur le schéma qu'on a changé la référence de la méthode config_url dans l'instance pour qu'elle pointe sur le mock, et en suivant les flèches, on peut comprendre alors qu'on a utilisé le ProviderA dont la méthode config_url est celle de la classe, méthode qui avait été mockée.

Trop simple, mais où s'arrête donc le pouvoir de mock.patch ?!
Cas pratique n°7 : mocker une méthode d'une variable pas importée
Si je modifie l'exemple d'avant (n°6) pour faire que le Provider ne soit plus une variable du module, mais qu'elle soit instanciée à la demande, que se passe-t-il ?
# fichier: main.py
from infra import create_provider
def get_data():
provider_url = create_provider().config_url()
return provider_url
# fichier: infra.py
from providers import Provider
def create_provider() -> Provider:
return Provider()
Je ne peux plus mocker
infra.ProviderA dans mon test puisque la variable n'est pas référencée depuis le module, elle existe seulement pendant l'appel de la fonction
get_data, et
mock.patch ne fonctionne que sur ce qui est importable. Essayons plutôt de mocker la méthode de la classe plutôt que celle de l'instance alors, puisque la classe est importable, c'est-à-dire
infra.Provider.config_url() :
def test_not_using_prod_config():
# 👇
with mock.patch("infra.Provider.config_url",
return_value="localhost:1234"):
assert get_data() == "localhost:1234"
Et ça marche encore ! Un petit schéma ? Allez ! (j'ai fait apparaître la fonction create_provider qui utilise le Provider de son module).
En effet, en Python tout est objet. Ainsi, une classe est un objet aussi (méta !), ses méthodes sont des champs dont on peut changer les valeurs.

De ce point de vue, qu'il s'agisse de changer Provider.config_url ou bien ProviderA.config_url, ça ne fait pas grande différence pour réussir à mocker ce qu'on voulait. Par contre, ça peut conduire à mocker ce qu'on ne voulait pas : ici TOUS les Providers ont leur config_url mockée, pas seulement celui qu'on utilise dans notre test. On pourrait bien entendu bricoler un truc plus malin, mais on rentrerait dans un marécage de mocks, je ne le recommande pas.
Ayant terminé avec cet exemple un peu tarabiscoté (mais malheureusement réaliste), il est temps de laisser de côté les classes et méthodes pour nous attaquer aux constantes.
Cas pratique n°8 : changer une constante d'un module
Partons de cet exemple où la fonction get_data du main.py utilise la valeur présente dans le module config importé :
# fichier: main.py
from code import config
def get_data():
return config.SERVER_URL
# fichier: config.py
SERVER_URL = ...
Essayons de dessiner le graphe des objets correspondant :

Le problème c'est que
get_data est l'endroit où l'on l'utilise, mais c'est aussi la fonction qu'on veut tester, donc on ne peut pas la mocker. Il faut donc mocker la "lecture" de la valeur par
get_data. On ne va pas pouvoir utiliser de
Mock ici car on ne cherche pas à remplacer une fonction ou méthode. On va plutôt utiliser une autre possibilité offerte par
mock.patch, qui est de fournir directement la valeur par laquelle remplacer avec le paramètre
new :
def test_not_using_prod_config():
with mock.patch("config.SERVER_URL",
new="localhost:1234"):
# ☝️
assert get_data() == "localhost:1234", get_data()
Ce qu'il va faire, c'est comme avant remplacer la référence SERVER_URL présente dans le module config par une autre valeur, mais cette fois au lieu d'être un Mock (dont on a fourni la valeur à retourner quand appelé), on va lui donner autre chose qu'un Mock. Cela permet bien de mocker la bonne chose, comme on peut le voir autrement via le graphe :

L'avantage ici est qu'on a purement et simplement remplacé la valeur par une autre pour toutes les références qui passent par config.
Cas pratique n°9 : changer une constante importée d'un module
En changeant légèrement l'exemple précédent, pour que le main fasse un from import au lieu d'un simple import, alors le test ne passe plus !
# fichier: main.py
from config import SERVER_URL
def get_data():
return SERVER_URL
Que s'est-il passé ? En se souvenant du cas n°2, on sait qu'une copie est créée lors d'un
from import. Une référence est copiée, donc c'est la copie qu'il faut mock !
def test_not_using_prod_config():
with mock.patch("main.SERVER_URL",
# ☝️
new="localhost:1234"):
assert get_data() == "localhost:1234", get_data()
En image :

Une question devient cependant bien visible : qu'est-ce qu'on fait du SERVER_URL de config qui n'est pas mocké ? C'est une sorte de copie qu'on a remplacé dans main, mais l'original n'est pas du tout affecté. Ça peut ne pas être un problème si cette valeur n'est utilisée nulle part ailleurs. Mais si elle l'est, sa valeur n'est pas mockée.
Et il y a pire encore ! Sans rentrer dans le détail des types mutables versus immutables, que se passe-t-il si cette valeur venait à changer lors de l'exécution du programme ? On ne s'était pas posé la question dans les cas précédents car on traitait des fonctions et des méthodes, lesquelles sont supposément jamais remplacées sauf par des mocks lors des tests. Mais ici, une constante, ça peut tout à fait changer (si si, il y a des gens qui font ça ! Car il n'y a pas de véritables "constantes" en Python). Que se passe-t-il alors ?

C'est le bazar. Rien n'est cohérent. On touche du doigt les limites des mocks, mais surtout d'une archi toute pétée. Pour citer Paul Aubertin à ce sujet :
Les variables globales mutables, c'est le mal !
Il n'y a pas vraiment de jolie solution dans ce cas-là, il faut traquer méthodiquement toutes les copies et les mocker, sinon on restera à patauger dans le marécage des tests confusants pour l'éternité. (ou récrire le code, si l'on peut)
Cas pratique n°10 : renvoyer des instances spécifiques plutôt que des mocks
Archi simple, on le fait déjà dans les cas n°8 et n°9 ! Il suffit de passer à
mock.patch un
new de notre choix. Exemple :
# fichier: main.py
from config import SERVER_CONFIG
def get_data():
return SERVER_CONFIG.server_url
# fichier: config.py
class Config:
def __init__(self):
self.server_url = ...
SERVER_CONFIG = Config()
Et le test correspondant qui prépare un
Config comme voulu et demande à
mock.patch de l'appliquer :
from config import Config
def test_not_using_prod_config():
mock_config = Config()
mock_config.server_url = "localhost:1234"
with mock.patch("main.SERVER_CONFIG",
new=mock_config):
assert get_data() == "localhost:1234", get_data()
Cas pratique n°11 : renvoyer des mocks spécifiques
Même code que pour le cas n°10, mais on peut le faire aussi sans instancier d'objet
Config, on peut créer un véritable
mock.Mock qui se comportera de la manière voulue :
def test_not_using_prod_config():
custom_mock = mock.Mock(**{"server_url": "localhost:1234"})
with mock.patch("main.SERVER_CONFIG",
new=custom_mock):
assert get_data() == "localhost:1234", get_data()
On va créer un Mock en lui passant en kwargs le contenu d'un dictionnaire qui contient les fields que l'objet Mock va implémenter, ici server_url devra avoir une valeur spécifique. Mais on peut aussi affecter comme valeur des fonctions, d'autres objets, d'autres Mocks, ... Bref, on peut très finement personnaliser le comportement du mocking, pour qu'il agisse comme fake object.
Cas pratique n°12 : renvoyer des valeurs différentes
# file: main.py
def read_sensor() -> float:
... # pas besoin de savoir comment c'est implémenté
def read_sensor__more_reliable() -> float:
# lire 10 fois le capteur et renvoyer la moyenne, pour "gommer" le bruit
return sum(read_sensor() for _ in range(10)) / 10
Supposons qu'on veuille tester une fonction qui surveille la valeur d'un capteur. Mais parce qu'il y a du bruit indésirable sur le capteur, on l'appelle plusieurs fois pour trouver une valeur plus fiable. Ce n'est pas forcément facile ni même possible de mocker ce qu'il y a dans
read_sensor (typiquement un appel à une fonction en C, donc insensible aux mocks). Et puis, je préfère ne pas devoir mettre en place un mock très fortement couplé à une dépendance de ma fonction à tester. La méthode la plus simple :
def test__reliable_reading_of_sensor():
with mock.patch("main.read_sensor", return_value=1.25):
assert main.read_sensor__more_reliable() == 1.25
Mais là, je viens plutôt de tester que si l'on me donne que la même valeur, c'est celle que je vais renvoyer à la fin, donc pas vraiment ce que je voudrais. Le mock a été configuré pour
toujours renvoyer la même valeur, mais on peut aussi lui donner une séquence de valeurs à renvoyer :
def test__reliable_reading_of_sensor():
fake_sensor_values = [0.12, 1.23, 2.34, 3.45, 4.56,
5.67, 6.78, 7.89, 8.90, 9.08]
with mock.patch("main.read_sensor", side_effect=fake_sensor_values):
assert main.read_sensor__more_reliable() == 5.002
Pour aller plus loin encore, on peut lui donner une fonction qui sera appelée à chaque fois, et qui permet ainsi de déterminer le comportement très dynamiquement. Par exemple, pour des tests semi-automatisés, on peut recourir à
input :
def test__reliable_reading_of_sensor():
def fake_read_sensor() -> float:
raw_value = input("enter the next value: ")
return float(raw_value) # ou autre ...
with mock.patch("main.read_sensor", side_effect=fake_read_sensor):
assert main.read_sensor__more_reliable() == ...
Cela fonctionne aussi avec des paramètres. Si je modifie un peu mon
main pour rajouter un
name comme paramètre :
def read_sensor(name: str) -> float:
... # pas besoin de savoir comment c'est implémenté
Alors je peux déclarer une fonction fake qui prendra aussi ce nom en paramètre, et pourra en faire ce qu'elle veut, par exemple tester que certaines valeurs ne sont pas autorisées.
from unittest import TestCase # pour utiliser `self.assertRaises`
class MyTests(TestCase):
def test__reliable_reading_of_sensor(self):
def fake_read_sensor(name: str) -> float:
if name == "lake03":
raise ValueError(f"sensor {name!r} is unavailable")
else:
return 123.456
with mock.patch("main.read_sensor", side_effect=fake_read_sensor):
main.read_sensor__more_reliable("lake02")
self.assertRaisesRegex(
ValueError, # expected_exception
"sensor 'lake03' is unavailable", # expected_regex
main.read_sensor__more_reliable, # callable
"lake03", # args and kwargs for the callable
)
Mais on peut plus facilement lever une exception qu'en déclarant une fonction, on peut tout simplement configurer le Mock pour le faire via
side_effect :
class MyTests(TestCase):
def test__reliable_reading_of_sensor(self):
main.read_sensor__more_reliable("lake02")
with mock.patch("main.read_sensor", side_effect=ValueError):
# ☝️
self.assertRaisesRegex(
ValueError, # expected_exception
"", # expected_regex
main.read_sensor__more_reliable, # callable
"lake03", # args and kwargs for the callable
)
Encore une fois, cela dépend de ce que l'on veut faire, il existe toujours une multitude de façons d'y parvenir. Et par exemple d'utiliser les fonctionnalités intégrées de son framework. Avec PyTest et son
pytest-mock, on pourrait faire à la place :
import pytest
# the `mocker` "fixture" gets injected by the
# framework into the test function parameters
# if the lib `pytest-mock` has been installed
# 👇
def test__reliable_reading_of_sensor(mocker):
main.read_sensor__more_reliable()
mocker.patch("main.read_sensor", side_effect=ValueError)
with pytest.raises(expected_exception=ValueError,
match=""):
main.read_sensor__more_reliable()
Cas pratique n°13 : capturer un print sur stdout
Voici une fonction emblématique de la programmation, mais aussi des tests puisqu'elle n'est nativement pas testable !
def hello(name: str) -> None:
print("Hello " + name + " !")
La fonction ne renvoie rien, elle écrit sur
sys.stdout (valeur par défaut du paramètre
file de
print) donc on ne peut pas directement/facilement tester.
Il faudrait qu'on puisse vérifier ce qui a été passé à la fonction
print. On va donc la mocker :
def test__hello():
def fake_print(s: str, *args, **kwargs) -> None:
assert s == "Hello World !", repr(s)
with mock.patch("builtins.print", new=fake_print):
# ☝️
main.hello("World")
Si vous ne saviez pas, on peut importer builtins qui est un module comme les autres, qui contient les
fonctions built-ins de Python :
bool,
dir,
getattr,
next,
zip, ... Et en fait il s'agit d'une espèce de variable globale, qui est
utilisée pour peupler les
globals de chaque module. On peut donc tout à fait mocker dedans, c'est similaire à ce qu'on faisait dans les cas n°4 et n°5, sauf que
print n'est pas explicitement importé ici.

Mais cela a pour effet de changer
print pour
TOUT le programme, ce qui peut être ou pas l'effet désiré. Si on veut être plus fin sur le mocking, alors il faut mocker seulement l'endroit qui est pertinent. Ici, c'est le
main, donc dans le test, on change seulement là où le mock est appliqué :
def test__hello():
def fake_print(s: str, *args, **kwargs) -> None:
assert s == "Hello World !", repr(s)
with mock.patch("main.print", new=fake_print):
# ☝️
main.hello("World")
Mais il n'y a pas de print dans main ... Ce n'est pas grave, mock.patch va se charger de le créer, afin qu'il shadow celui qui viendrait des globals. J'ai moi-même été surpris que cela fonctionne, tout simplement parce que j'évite de shadow en général. Et puisqu'il n'existait pas avant (et a existé durant la durée du with), il est supprimé ensuite et il n'y aura donc plus de shadow. C'est même recommandé, car mocker un builtins impacte l'intégralité de tout le code Python, pas juste celui de la fonction (cf cette réponse sur StackOverflow).
Utiliser un spy
On a défini une fonction custom pour faire la vérification, laquelle est vraiment simpliste, donc on pourrait se contenter d'un spy plutôt que d'un mock. La terminologie de tous les tests doubles est souvent assez vague, alors voici brièvement ce que j'appelle un spy : il s'agit d'un objet (ou fonction, ...) qui va s'intercaler devant celui utilisé d'habitude, en faisant passe-plat (donc ne pas modifier le comportement) mais retenir tous les appels et accès qui auront été faits (nombre, ordre, paramètres, résultat, ...).
En Python, comme précédemment évoqué, il n'existe pas de nuance au niveau des types (les Mock remplissent tous les rôles), donc il s'agit seulement de l'utiliser un peu différemment.
def test__hello():
with mock.patch("main.print", wraps=print) as print_mock:
# ☝️
main.hello("World")
print_mock.assert_called_once_with("Hello World !")
Les Mocks ont tout un tas de méthodes pratiques pour leur utilisation en tant que spy: assert_called_once_with, assert_not_called, assert_any_call, assert_has_calls, ...
Ici, en plus d'être bien plus courte, et plus claire si on l'habitude des spy, cette façon de vérifier le comportement est même plus robuste car elle vérifie que la fonction a bien été appelée. L'implémentation précédente du test vérifiait la valeur lors de l'appel, pas que l'appel était fait (et donc pouvait produire un faux négatif si la fonction print n'était pas appelée par le code testé). Bien connaître les méthodes de mocking améliore la qualité des tests écrits avec !
Cas pratique n°14 : capturer un print sur stderr
Un print sur stderr, ça peut être :
- soit un print comme on a déjà vu, avec l'argument file auquel on a donné la valeur sys.stderr,
- soit une écriture directement sur le file object, du style sys.stderr.write(...).
Dans les 2 cas, la solution a déjà été vue :
- si on se base sur l'appel à print, on va vérifier aussi le paramètre file de manière identique à ce qui a été fait pour le cas n°14 :
def test__hello():
with mock.patch("main.print", wraps=print) as print_mock:
main.hello("World")
print_mock.assert_any_call("Hello World !", file=sys.stderr)
- alors que si le code à tester utilise directement sys.stderr.write (ce qui est plutôt rare car assez bas-niveau !), sachant que sys.stderr est classiquement un io.TextIOWrapper, on aura alors :
def test__hello():
with mock.patch("main.sys.stderr", wraps=main.sys.stderr) as sys_stderr_mock:
main.hello("World")
sys_stderr_mock.write.assert_called_once_with("Hello World !\n")
# ☝️
On peut utiliser sys_stderr_mock.write car Mock lorsqu'il est créé avec wraps=... va automatiquement créer des sous-Mocks pour les attributs. L'appel sur sys.stderr.write est donc réalisé sur le sous-Mock, lequel wraps sys.stderr.write.
Mais on peut aussi créer le mock/spy seulement sur la méthode write si l'on préfère limiter au minimum ce qui est mocké. Cependant, jusqu'à maintenant, on n'a appliqué des Mocks que sur des objets importables. Or, la méthode write elle-même n'est pas importable. On peut alors utiliser mock.patch.object :
def test__hello():
with mock.patch.object(main.sys.stderr, "write", wraps=sys.stderr.write) as sys_stderr_write_mock:
# ☝️
main.hello("World")
sys_stderr_write_mock.assert_called_once_with("Hello World !\n")
Le Mock est alors appliqué seulement à l'objet voulu (ici une méthode, mais ça aurait dû être un champ). Cela permet de mocker plus finement ou profondément que seulement mock.patch.
On peut même aussi se passer complètement de mock, en effet il existe contextlib.redirect_stdout et contextlib.redirect_stderr dans la librairie standard, qui fonctionnent très bien couplés avec des io.StringIO :
def test__hello():
stderr_content = io.StringIO()
with contextlib.redirect_stderr(stderr_content):
main.hello("World")
assert stderr_content.getvalue() == "Hello World !\n"
Et pareillement, votre framework de test vous offre peut-être des outils pour le faire facilement : PyTest propose capsys par exemple, qui le fait très bien et automatiquement.
Cas pratique n°15 : mocker une lecture/écriture de fichier
Un scénario habituel : la plupart des applications lisent ou écrivent dans des fichiers, mais ne sont pas forcément conçues pour que ce soit testable. Exemple :
def get_url_from_config_file(path: str) -> str:
with open(path) as file:
... # do something
Ce code est repris du cas n°4 "mocker une dépendance d'une dépendance d'une dépendance". Le but alors était de se passer du fichier, car il était un obstacle pour tester tout à fait autre chose (fetcher une ressource). Ici l'objectif est de tester spécifiquement la méthode get_url_from_config_file, de vérifier qu'elle fait bien ce qu'il faut en terme de parsing, de gestion d'erreur, de validation, ... Il va donc falloir faire varier le fichier lui-même.
Puisqu'open est une fonction comme les autres, on peut aussi la mocker ! On avait déjà vu dans le cas n°13 comment mocker print, qui elle aussi est une fonction "built-in". On peut reprendre la façon de tester :
def test__get_url():
server_url = "https://example.com/"
def fake_open(path: str, *args, **kwargs) -> typing.TextIO:
return io.StringIO(json.dumps(
{"server_url": server_url}))
with mock.patch("builtins.open", new=fake_open):
# ☝️
assert server_url == main.get_url_from_config_file("my_config.json")
De cette façon, et avec une gestion appropriée des données de test, il est facile de mettre à disposition n'importe quel contenu de fichier, ou pas de fichier du tout (raise une exception), afin de couvrir tous les scénarios à tester.
De même que pour le cas n°13, il y a déjà une fonction à disposition pour faciliter le travail de mocking : mock.mock_open. Elle crée un MagicMock qui est specé pour ressembler à un file-like object, mais il reste à le patcher au bon endroit.
def test__get_url():
server_url = "https://example.com/"
config_content = json.dumps({"server_url": server_url})
with mock.patch("main.open",
new=mock.mock_open(read_data=config_content)):
# ☝️
assert server_url == main.get_url_from_config_file("my_config.json")
Et pour l'écriture de fichiers, la manière est identique : patcher le open au bon endroit, le remplacer par une fonction custom ou un mock.mock_open, et voilà.
Cas pratique n°16 : mocker une requête vers internet
On vient de voir les fichiers, une des principales sources d'IO. Une autre, et parfois plus retorse à tester (car moins facilement contrôlable ou fiable), ce sont les requêtes/réponses vers des serveurs web. Certes, il existe urllib dans la librairie standard de Python, mais la librairie tierce requests est beaucoup plus utilisée.
Si l'on veut tester le comportement de notre code, afin qu'il émette les bonnes requêtes, ou qu'il se comporte correctement selon les réponses qu'il obtient, on a besoin de pouvoir émuler un serveur web. On pourrait bricoler des mocks et des patches afin de remplacer requests.get et requests.Session.get (et pareil pour tous les autres verbes HTTP : HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE et PATCH). Mais il existe les librairies requests-mock et responses justement pour ça.
def fetch_data() -> str:
return requests.get("https://example.com/").content.decode()
def test__fetch_data():
with requests_mock.Mocker() as mocker:
mocker.get("https://example.com/", text="hello") # define responses
result = main.fetch_data() # call indirectly `requests.get`
assert result == "hello"
Ici, il s'agit d'un exemple basique, mais les méthodes Mocker.* permettent de spécifier exactement quelle réponse associer à quelle URL et payload. On a aussi une maîtrise complète de ce qui est retourné. Il ne manque que de pouvoir s'assurer que les mocks ont bien été appelés (mais est-ce judicieux de tester cela ?).
Pour éviter de devoir tout renseigner manuellement, il est possible aussi par exemple d'utiliser un "enregistreur" comme VCR.py. Il se charge de positionner des mocks qui vont dans une première phase surveiller et enregistrer les réponses effectuées à des requêtes sur le serveur réel, afin de pouvoir par la suite renvoyer les réponses enregistrées aux requêtes déjà connues. Un énorme gain de vitesse, reproductibilité et isolation.
Cas pratique n°17 : mocker l'aléatoire
Quoi de plus incertain que l'aléatoire ? On peut essayer de l'amadouer avec un random.seed pour pouvoir reproduire des séquences déterministiquement, mais pas en décider. Pour forcer la chance (ou la malchance), encore une fois, on peut recourir aux mocks :
def draw_winning_number() -> int:
return random.randint(0, 10)
def test__draw_winning_number() -> None:
def my_random(a: int, b: int):
return 9 # cf https://dilbert-viewer.herokuapp.com/2001-10-25
with mock.patch("main.random.randint", new=my_random):
assert [main.draw_winning_number(),
main.draw_winning_number()] == [9, 9]
On peut bien entendu générer des séquences intéressantes plutôt que tout le temps la même chose. Mais même des constantes sont parfois random. Mais au final, la technique reste exactement la même que celle vue dans le cas pratique n°1, l'aspect random ne change rien à l'affaire.
Cas pratique n°18 : mocker le temps
Le problème du temps qu'il est change tout le temps, donc sans outillage ça peut être très compliqué de tester toutes les subtilités du temps. Il n'est pas nécessaire à ce stade d'expliquer comment mocker des fonctions de modules telles que time.sleep, time.time ou datetime.now. Mais encore une fois, pour des usages spécialisés, il existe des outils spécialisés.
La librairie freezegun permet d'appliquer tous les mocks nécessaires, avec des decorators ou des context managers super pratiques, afin de figer le temps et/ou de le faire avancer à la vitesse voulue. Mais comme d'habitude, il faut se méfier des arguments avec des valeurs par défaut mutables. En reprenant le cas n°1 pour le test de get_tomorrow dans le cas des années bissextiles :
from freezegun import freeze_time
@freeze_time("2024-02-28")
def test_leap_year():
feb29_2024 = dt.date(2024, 2, 29)
assert get_tomorrow() == feb29_2024
print("ok")
@freeze_time("2025-02-28")
def test_not_leap_year():
mar01_2025 = dt.date(2025, 3, 1)
assert get_tomorrow() == mar01_2025
print("ok")
Cependant être maître du temps c'est beaucoup de responsabilités et de travail, parfois on veut juste modifier le temps de façon très globale, et pas que pour du Python. libfaketime est à injecter via la technique du LD_PRELOAD ce qui permet de ne pas avoir à modifier le code du tout.
Cas pratique n°19 : capturer les logs
Le [logging] est souvent négligé par les développeurs d'applications, alors qu'il peut véritablement permettre de savoir ce qu'il se passe à l'intérieur du code. Et donc les logs émis peuvent faire partie du contrat de certaines fonctions. Il convient alors de le tester. Encore une fois on pourrait se précipiter pour mocker les bonnes choses aux bons endroits, mais on peut faire plus efficace : unittest.TestCase.assertLogs. Il y a en effet déjà dans la librairie standard exactement ce qu'il faut pour tester que des logs sont émis. Et si vous utilisez un autre framework applicatif ou de test, il a probablement déjà l'outillage prévu pour. Des bons outils aident à faire du bon boulot.
Cas pratique n°20 : le bon mock et le mauvais mock (conclusion)
On a vu au cours des 19 cas précédents nombre de techniques et d'outils venant de la standard library ou de librairies tierces (PyPI), toujours dans le but de pouvoir tester ce qui ne l'était pas avant (ou pas facilement). Néanmoins, les mocks ont leur lot de défauts : il n'est pas toujours facile de mocker partout où il faut et pas de trop, et surtout on se retrouve à tester le code des mocks au lieu des vrais mocks. Cela a un impact direct sur la stratégie de tests logiciels. C'est en essence la différence entre London-style et Chicago-style : le premier teste que le code a bien appelé ses collaborators (le comment), alors que le second va s'assurer que le résultat est le bon (le quoi). Chacun a ses avantages, mais gare à ne tester que le comment, car le quoi aussi est important.
Aussi, il peut être difficile de bien concevoir ses mocks dans tous les cas, donc il vaut mieux au maximum se reposer sur son framework et les librairies spécialisées pour le faire.
Et comme tout outil, les mocks sont utiles dans certains cas, mais il ne faut pas en abuser. Il y a des approches de design qui favorisent la testabilité, par exemple l'Architecture Hexagonale.
Quelques petites notes
- Sous sa forme de context manager, appeler mock.patch renvoie un patch object qu'on peut utiliser pour inspecter (spy) la façon dont il a été utilisé, ou bien le (re-)configurer même après qu'il ait été appliqué (qu'une référence vers lui ait remplacée une référence vers autre chose).
- On n'a utilisé mock.patch que sous sa forme de context manager (with mock.patch(...): ...), or il est possible de l'instancier normalement (my_patch = mock.patch(...)) mais il faut alors le .start() et surtout bien le .stop() après, même en cas d'exception. L'avantage de ses formes de context manager ou de décorateur (@mock.patch(...) def test_something(patch_object)) étant de le faire automatiquement pour nous. (cf)
- Lors de l'utilisation de mocks dans des fixtures de pytest, le moyen le plus simple est de simplement yield à l'intérieur du with, cf yield fixtures.
- Il existe une taxonomie de sous-classes de Mock qui servent différents usages qu'on n'a pas explorés : NonCallable, Property Async et bien entendu Magic.
- Pour spécifier le comportement des sous-Mocks de façon concise, il peut être utile de recourir à un splat unpacking : **{"a.b.c": ...}.
Pour aller plus loin
- L'architecture logicielle et comment elle façonne les stratégies de test (par exemple l'effet de l'architecture hexagonale sur la testabilité, les contrats d'interface, ...)
Mocking is often a solution to code that is not intended to be easily tested, the other solution being to re-architect the code to be more testable in isolation
Introduction à l’architecture hexagonale et Une approche différente de l'architecture hexagonale sur le blog de Kaizen Solutions, ou bien la vidéo "The Clean Architecture in Python" par Brandon Rhodes.
Dédicace à Guillaume Ferrier qui m'a donné l'idée de cet article, et au NotABeer de m'avoir ravitaillé lors de l'écriture.
Retour aux articles