Skip to main content
Niveau : Expert

Chapitre 46 : Le Mocking avec unittest.mock

Objectif

Ce module a pour but de vous initier au concept de mocking (simulation). Vous apprendrez à utiliser le module unittest.mock (qui s'intègre parfaitement avec pytest) pour isoler vos tests de leurs dépendances externes, comme les appels réseau, les bases de données ou les systèmes de fichiers.

1. Qu'est-ce que le Mocking ?

Un test unitaire a pour but de tester une petite unité de code (généralement une fonction ou une méthode) de manière isolée.

Cependant, de nombreuses fonctions ont des dépendances externes :

  • Elles appellent une API via le réseau.
  • Elles lisent ou écrivent dans une base de données.
  • Elles interagissent avec le système de fichiers.

Ces dépendances posent plusieurs problèmes pour les tests unitaires :

  • Lenteur : Un appel réseau peut prendre plusieurs secondes.
  • Non-déterminisme : Une API peut être en panne ou retourner des données différentes.
  • État : Les tests peuvent modifier une base de données, ce qui affecte les tests suivants.
  • Disponibilité : On ne peut pas lancer les tests sans connexion réseau ou sans base de données configurée.

Le mocking est la technique qui consiste à remplacer ces dépendances par des objets factices (mocks) pendant les tests. Ces mocks simulent le comportement de la dépendance réelle de manière contrôlée et prédictible.

2. unittest.mock.Mock et MagicMock

La classe de base pour créer des mocks est Mock. MagicMock est une sous-classe de Mock qui implémente par défaut la plupart des méthodes "magiques" (dunder methods), ce qui la rend plus pratique dans la plupart des cas.

Un MagicMock est un objet caméléon :

  • Il peut être appelé comme une fonction.
  • On peut accéder à n'importe quel attribut sur lui (il le créera à la volée).
  • On peut lui assigner un comportement (valeur de retour, exception à lever).
  • Il enregistre comment il a été utilisé (avec quels arguments il a été appelé, combien de fois, etc.).
from unittest.mock import MagicMock

# Créer un mock
mock_api = MagicMock()

# 1. Configurer une valeur de retour pour une méthode
mock_api.get_user.return_value = {"id": 1, "name": "Alice"}

# Utiliser le mock
user = mock_api.get_user(1)
print(user) # {"id": 1, "name": "Alice"}

# 2. Configurer un effet de bord (lever une exception)
mock_api.delete_user.side_effect = ConnectionError("Impossible de se connecter")

try:
mock_api.delete_user(1)
except ConnectionError as e:
print(e) # Impossible de se connecter

# 3. Vérifier comment le mock a été utilisé
# a-t-il été appelé ?
mock_api.get_user.assert_called()

# a-t-il été appelé une seule fois ?
mock_api.get_user.assert_called_once()

# a-t-il été appelé avec des arguments spécifiques ?
mock_api.get_user.assert_called_with(1)

# a-t-il été appelé avec n'importe quels arguments ?
from unittest.mock import ANY
mock_api.get_user.assert_called_with(ANY)

3. Remplacer des Objets avec monkeypatch ou mock.patch

Savoir créer un mock est une chose, mais comment l'injecter dans notre code pour remplacer la vraie dépendance ? pytest fournit une fixture très pratique pour cela : monkeypatch. unittest.mock a son propre outil, patch, qui fonctionne comme un décorateur ou un gestionnaire de contexte.

Exemple à Tester

Imaginons une fonction qui dépend du module requests pour appeler une API.

# mon_app.py
import requests

def get_todos_for_user(user_id):
"""Récupère la liste des tâches pour un utilisateur depuis une API externe."""
response = requests.get(f"https://jsonplaceholder.typicode.com/todos?userId={user_id}")
response.raise_for_status() # Lève une exception si le statut HTTP est une erreur
return response.json()

Tester cette fonction directement est une mauvaise idée (lenteur, dépendance réseau). Nous allons donc "mocker" requests.get.

Utilisation de la fixture monkeypatch de pytest

monkeypatch permet de modifier dynamiquement des classes, méthodes, variables d'environnement, etc., pour la durée d'un test, et garantit que tout sera restauré à son état original après le test.

# test_mon_app.py
from mon_app import get_todos_for_user
from unittest.mock import MagicMock

def test_get_todos_for_user_success(monkeypatch):
# 1. Créer un mock pour simuler la réponse de requests.get()
mock_response = MagicMock()
mock_response.status_code = 200
# .json() est une méthode, donc on configure la valeur de retour de l'appel
mock_response.json.return_value = [{"title": "Test Todo", "completed": False}]

# 2. Créer un mock pour la fonction requests.get elle-même
mock_get = MagicMock(return_value=mock_response)

# 3. Utiliser monkeypatch pour remplacer 'requests.get' par notre mock
# La chaîne est le chemin d'import complet de ce qu'on veut remplacer.
monkeypatch.setattr("requests.get", mock_get)

# 4. Appeler notre fonction. Elle appellera notre mock au lieu du vrai requests.get
todos = get_todos_for_user(1)

# 5. Vérifier les résultats
assert len(todos) == 1
assert todos[0]["title"] == "Test Todo"

# 6. Vérifier que le mock a été appelé correctement
mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/todos?userId=1")
mock_response.raise_for_status.assert_called_once()

def test_get_todos_for_user_failure(monkeypatch):
# Testons le cas où l'API retourne une erreur
mock_response = MagicMock()
# On configure la méthode .raise_for_status() pour qu'elle lève une exception
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError

mock_get = MagicMock(return_value=mock_response)
monkeypatch.setattr("requests.get", mock_get)

# On vérifie que notre fonction propage bien l'exception
with pytest.raises(requests.exceptions.HTTPError):
get_todos_for_user(1)

Utilisation de unittest.mock.patch

patch est une alternative à monkeypatch. Il peut être utilisé comme un décorateur ou un gestionnaire de contexte.

# test_mon_app_patch.py
from unittest.mock import patch
import pytest

# 'patch' comme décorateur. Le mock est passé en argument au test.
@patch("requests.get")
def test_get_todos_with_decorator(mock_get):
# 'mock_get' est maintenant un MagicMock qui remplace requests.get

# Configurer la chaîne de mocks
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = [{"title": "Test Todo"}]

todos = get_todos_for_user(1)

assert len(todos) == 1
mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/todos?userId=1")

def test_get_todos_with_context_manager():
# 'patch' comme gestionnaire de contexte
with patch("requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = [{"title": "Test Todo"}]

todos = get_todos_for_user(1)

assert len(todos) == 1
mock_get.assert_called_once_with("https://jsonplaceholder.typicode.com/todos?userId=1")

monkeypatch vs patch :

  • monkeypatch est une fixture pytest, elle est donc souvent plus naturelle à utiliser dans un écosystème pytest.
  • patch fait partie de la bibliothèque standard et est plus portable si vous n'utilisez pas pytest.
  • Les deux atteignent le même objectif. Le choix est souvent une question de préférence.

Conclusion

Le mocking est une technique indispensable pour écrire des tests unitaires rapides, fiables et véritablement "unitaires". En remplaçant les dépendances externes par des objets MagicMock contrôlés, vous pouvez tester le comportement de votre code dans une multitude de scénarios (succès, échec, cas limites) sans dépendre de systèmes externes. La fixture monkeypatch de pytest et unittest.mock.patch sont les deux principaux outils pour mettre en œuvre cette technique de manière propre et efficace.


Exercice 01 : Mocker un Appel API pour Tester une Fonction

Objectif

Cet exercice a pour but de vous faire utiliser la fixture monkeypatch de pytest pour mocker un appel réseau et tester une fonction qui dépend d'une API externe.

Contexte

Vous allez écrire une fonction qui récupère la date et l'heure actuelles à partir de l'API de WorldTimeAPI. Cette API retourne un objet JSON avec des informations sur l'heure dans un fuseau horaire donné.

Votre fonction devra parser cette réponse pour extraire la date et l'heure. Comme nous ne voulons pas que nos tests dépendent d'une connexion internet et d'une API externe, vous allez mocker l'appel à requests.get pour simuler la réponse de l'API.

Exemple de réponse de l'API pour l'Europe/Paris : URL : http://worldtimeapi.org/api/timezone/Europe/Paris Réponse JSON (simplifiée) :

{
"datetime": "2023-10-27T10:30:00.123456+02:00",
"timezone": "Europe/Paris",
"utc_offset": "+02:00"
}

Énoncé

Étape 1 : Créer la fonction à tester

Créez un fichier time_app.py avec la fonction suivante :

# time_app.py
import requests

def get_current_time(timezone: str) -> str:
"""
Récupère l'heure actuelle pour un fuseau horaire donné depuis l'API WorldTimeAPI
et retourne la partie 'datetime' de la réponse.
"""
try:
response = requests.get(f"http://worldtimeapi.org/api/timezone/{timezone}")
# Lève une exception pour les codes d'erreur HTTP (4xx ou 5xx)
response.raise_for_status()
data = response.json()
return data["datetime"]
except requests.exceptions.RequestException as e:
# En cas d'erreur réseau ou HTTP, on retourne un message d'erreur
return f"Erreur lors de la récupération de l'heure : {e}"

Étape 2 : Créer le fichier de test

Créez un fichier test_time_app.py.

  1. Importez les modules nécessaires : pytest, requests, MagicMock de unittest.mock, et la fonction get_current_time.

  2. Écrivez un test pour le cas de succès.

    • Nommez la fonction test_get_current_time_success.
    • Elle doit prendre monkeypatch en argument.
    • Configurez le mock :
      • Créez un MagicMock pour simuler l'objet response.
      • Définissez la valeur de retour de la méthode json() de ce mock pour qu'elle soit un dictionnaire simulant une réponse réussie de l'API (par exemple, {"datetime": "2023-01-01T12:00:00+01:00"}).
    • Créez un mock pour requests.get qui retourne votre mock_response.
    • Appliquez le patch : Utilisez monkeypatch.setattr("requests.get", ...) pour remplacer la vraie fonction get par votre mock.
    • Appelez la fonction testée : get_current_time("Europe/Paris").
    • Vérifiez le résultat : Assurez-vous que la fonction retourne bien la chaîne de caractères datetime que vous avez définie dans votre mock.
    • Vérifiez l'appel : Assurez-vous que requests.get a bien été appelé une fois avec l'URL correcte.
  3. Écrivez un test pour le cas d'échec (erreur HTTP).

    • Nommez la fonction test_get_current_time_http_error.
    • Elle doit aussi prendre monkeypatch en argument.
    • Configurez le mock :
      • Cette fois, configurez la méthode raise_for_status() du mock_response pour qu'elle lève une requests.exceptions.HTTPError. Utilisez l'argument side_effect.
    • Appliquez le patch comme précédemment.
    • Appelez la fonction testée.
    • Vérifiez le résultat : Assurez-vous que la fonction retourne une chaîne de caractères contenant le message "Erreur".

Étape 3 : Lancer les tests

  • Installez pytest et requests (pip install pytest requests).
  • Lancez pytest depuis votre terminal.

Résultat Attendu

Les deux tests doivent passer, prouvant que vous avez testé à la fois le chemin de succès et le chemin d'erreur de votre fonction, sans jamais effectuer un seul appel réseau réel.

============================= test session starts ==============================
...
collected 2 items

test_time_app.py .. [100%]

============================== 2 passed in ...s ================================
Cliquez ici pour voir un exemple de code de solution
# test_time_app.py

import pytest
import requests
from unittest.mock import MagicMock
from time_app import get_current_time

def test_get_current_time_success(monkeypatch):
"""
Teste le cas où l'API répond correctement.
"""
# 1. Préparation des données de simulation
fake_response_data = {"datetime": "2023-01-01T12:00:00+01:00"}

# 2. Création et configuration des mocks
mock_response = MagicMock()
# La méthode .json() du mock doit retourner nos fausses données
mock_response.json.return_value = fake_response_data
# On s'assure que raise_for_status() ne fait rien
mock_response.raise_for_status.return_value = None

# Le mock pour la fonction get doit retourner notre mock_response
mock_get = MagicMock(return_value=mock_response)

# 3. Remplacement de la vraie fonction par le mock
monkeypatch.setattr(requests, "get", mock_get)

# 4. Appel de la fonction à tester
timezone = "Europe/Paris"
result = get_current_time(timezone)

# 5. Assertions
# Le résultat est-il correct ?
assert result == "2023-01-01T12:00:00+01:00"
# La fonction a-t-elle été appelée correctement ?
mock_get.assert_called_once_with(f"http://worldtimeapi.org/api/timezone/{timezone}")
mock_response.raise_for_status.assert_called_once()


def test_get_current_time_http_error(monkeypatch):
"""
Teste le cas où l'API retourne une erreur HTTP (ex: 404, 500).
"""
# 1. Création et configuration des mocks
mock_response = MagicMock()
# On configure raise_for_status pour qu'elle lève une exception
error_message = "Not Found"
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(error_message)

mock_get = MagicMock(return_value=mock_response)

# 2. Remplacement
monkeypatch.setattr(requests, "get", mock_get)

# 3. Appel
result = get_current_time("Zone/Invalide")

# 4. Assertions
# Le résultat doit contenir le message d'erreur
assert "Erreur" in result
assert error_message in result