Contactez-nous

Mocks et patchs (unittest.mock)

Découvrez les mocks et le patching en Python avec le module 'unittest.mock' (ou 'mock' en Python < 3.3). Apprenez à remplacer des dépendances par des objets simulés (mocks) pour isoler vos tests unitaires et contrôler le comportement des dépendances.

Pourquoi utiliser des mocks ? Isoler les unités de code

Lors de l'écriture de tests unitaires, il est essentiel d'isoler l'unité de code que vous testez. Cela signifie que vous ne voulez tester que le comportement de cette unité spécifique, sans dépendre du comportement d'autres parties du programme (dépendances).

Les dépendances peuvent être :

  • D'autres fonctions ou méthodes.
  • Des classes.
  • Des modules externes.
  • Des ressources externes (bases de données, API, système de fichiers, etc.).

Si vous ne contrôlez pas le comportement de ces dépendances, vos tests peuvent devenir :

  • Non déterministes : Le résultat du test peut dépendre de l'état des dépendances, qui peut varier.
  • Lents : Si les dépendances impliquent des opérations coûteuses (accès réseau, accès à la base de données, etc.).
  • Difficiles à mettre en place : Vous devez configurer l'environnement pour que les dépendances soient disponibles et dans un état correct.
  • Fragiles : Si une dépendance change, vos tests peuvent échouer, même si l'unité de code que vous testez est correcte.

Les *mocks* (ou objets simulés, ou faux objets) sont des objets qui remplacent les dépendances réelles dans vos tests. Vous pouvez contrôler le comportement des mocks (par exemple, quelles valeurs ils retournent, quelles exceptions ils lèvent) et vérifier qu'ils ont été utilisés comme prévu (par exemple, qu'une méthode a été appelée avec les bons arguments).

En utilisant des mocks, vous pouvez isoler l'unité de code que vous testez et vous concentrer uniquement sur son comportement.

Le module unittest.mock (ou mock) : l'outil de mocking de Python

Le module `unittest.mock` (ou `mock` en Python < 3.3, disponible en tant que bibliothèque externe) fournit des outils pour créer et utiliser des mocks en Python.

Les classes et fonctions principales de `unittest.mock` sont :

  • `Mock` : La classe de base pour créer des objets mocks. Un `Mock` peut remplacer n'importe quel objet Python et enregistrer la manière dont il est utilisé (quelles méthodes sont appelées, avec quels arguments, etc.).
  • `MagicMock` : Une sous-classe de `Mock` qui implémente la plupart des méthodes spéciales de Python (comme `__len__`, `__str__`, `__getitem__`, etc.). Cela permet de simuler des objets plus complexes.
  • `patch` : Un décorateur ou un gestionnaire de contexte qui permet de remplacer temporairement un objet (par exemple, une fonction, une classe, ou un attribut) par un mock, pendant l'exécution d'un test.
  • `PropertyMock` Un mock pour les propriétés.
  • `call` : Un objet qui représente un appel à une méthode d'un mock.
  • `ANY` : Un objet qui correspond à n'importe quelle valeur (utile pour les assertions).

Avec `unittest.mock`, vous pouvez remplacer des dépendances par des mocks, contrôler leur comportement, et vérifier qu'elles ont été utilisées comme prévu.

Créer un mock : la classe Mock

La classe `Mock` est la base de `unittest.mock`. Vous pouvez créer un objet `Mock` et l'utiliser à la place d'un objet réel dans vos tests.

Exemple :

from unittest.mock import Mock

# Créer un mock
mon_mock = Mock()

# Définir le comportement du mock
mon_mock.methode1.return_value = 42  # La méthode methode1 retournera 42
mon_mock.methode2.side_effect = [1, 2, 3]  # methode2 retournera successivement 1, 2, puis 3
mon_mock.methode3.side_effect = Exception("Erreur !")  # methode3 lèvera une exception

# Utiliser le mock
print(mon_mock.methode1())  # Affiche 42
print(mon_mock.methode2())  # Affiche 1
print(mon_mock.methode2())  # Affiche 2
print(mon_mock.methode2())  # Affiche 3
# mon_mock.methode3()       # Lève une Exception("Erreur !")

# Vérifier comment le mock a été utilisé
mon_mock.methode1.assert_called_once()  # Vérifie que methode1 a été appelée une fois
mon_mock.methode2.assert_called_with() #Vérifie avec quels arguments la méthode a été appelée.
mon_mock.methode1.assert_called_with()  # Lève une AssertionError si methode1 n'a pas été appelée

print(mon_mock.mock_calls)  # Affiche la liste de tous les appels effectués sur le mock

Dans cet exemple :

  • On crée un objet `Mock` appelé `mon_mock`.
  • On définit le comportement de certaines méthodes de `mon_mock` :
    • `mon_mock.methode1.return_value = 42` : La méthode `methode1` retournera toujours 42.
    • `mon_mock.methode2.side_effect = [1, 2, 3]` : La méthode `methode2` retournera successivement 1, 2, puis 3, lors des appels successifs.
    • `mon_mock.methode3.side_effect = Exception("Erreur !")` : La méthode `methode3` lèvera une exception.
  • On appelle les méthodes du mock.
  • On vérifie comment le mock a été utilisé, avec des assertions comme `assert_called_once()`, `assert_called_with()`, etc. `mock_calls` contient la liste de tous les appels.

Un objet `Mock` enregistre tous les appels qui lui sont faits (méthodes, attributs), ainsi que les arguments utilisés. Vous pouvez ensuite utiliser des assertions pour vérifier que le mock a été utilisé comme prévu.

Remplacer une dépendance : le décorateur/gestionnaire de contexte patch

La fonction `patch` (et ses variantes, comme `patch.object`, `patch.dict`) est un outil puissant pour remplacer temporairement un objet (par exemple, une fonction, une classe, ou un attribut) par un mock, pendant l'exécution d'un test.

`patch` peut être utilisé comme un *décorateur* (pour décorer une fonction ou une méthode de test) ou comme un *gestionnaire de contexte* (avec l'instruction `with`).

Exemple (utilisation comme décorateur) :

import unittest
from unittest.mock import patch

# Fonction à tester (qui utilise une dépendance externe)
def obtenir_donnees_depuis_api(url):
    # ... (code pour effectuer une requête HTTP à l'URL) ...
  pass #On remplace par un pass pour l'instant

# Test (en utilisant patch pour remplacer la fonction externe)
@patch('__main__.obtenir_donnees_depuis_api')  # Remplace la fonction dans le module courant (__main__)
def test_obtenir_donnees(mock_obtenir_donnees):
    # Configure le comportement du mock
    mock_obtenir_donnees.return_value = {"cle": "valeur"}

    # Appelle la fonction à tester (qui utilise maintenant le mock)
    resultat = obtenir_donnees_depuis_api("http://example.com")

    # Vérifie le résultat
    assert resultat == {"cle": "valeur"}

    #Vérifie que la fonction a bien été appelée avec les bons arguments:
    mock_obtenir_donnees.assert_called_once_with("http://example.com")

Dans cet exemple :

  • `@patch('__main__.obtenir_donnees_depuis_api')` décore la fonction de test `test_obtenir_donnees`. Il remplace la fonction `obtenir_donnees_depuis_api` (définie dans le module courant, `__main__`) par un objet `Mock`.
  • Le mock est passé en argument à la fonction de test (`mock_obtenir_donnees`).
  • On configure le comportement du mock (`return_value`).
  • On appelle la fonction à tester (qui utilise maintenant le mock au lieu de la fonction réelle).
  • On vérifie le résultat et on vérifie que la fonction a bien été appelée.

Exemple (utilisation comme gestionnaire de contexte) :

import unittest
from unittest.mock import patch

# ... (même fonction obtenir_donnees_depuis_api que précédemment) ...

def test_obtenir_donnees():
    with patch('__main__.obtenir_donnees_depuis_api') as mock_obtenir_donnees:
        # Configure le comportement du mock
        mock_obtenir_donnees.return_value = {"cle": "valeur"}

        # Appelle la fonction à tester
        resultat = obtenir_donnees_depuis_api("http://example.com")

        # Vérifie le résultat
        assert resultat == {"cle": "valeur"}

L'utilisation de `patch` comme gestionnaire de contexte est similaire à l'utilisation comme décorateur, mais le remplacement n'est effectif que dans le bloc `with`.

`patch` accepte plusieurs arguments pour personnaliser son comportement (voir la documentation de `unittest.mock`). Par exemple, vous pouvez spécifier une valeur de retour spécifique pour le mock, ou lever une exception.

Autres formes de patch : patch.object, patch.dict, etc.

Il existe d'autres formes de `patch` pour des cas d'utilisation spécifiques :

  • `patch.object(objet, 'nom_attribut', ...)` : Remplace un attribut d'un objet.
  • `patch.dict(dictionnaire, valeurs)` : Remplace (temporairement) le contenu d'un dictionnaire.

Exemple (`patch.object`) :

import unittest
from unittest.mock import patch

class MaClasse:
    attribut = 10

def fonction_a_tester():
    return MaClasse.attribut

class Test(unittest.TestCase):
  @patch.object(MaClasse, 'attribut', 20) #Remplace l'attribut de classe
  def test_fonction(self):
    self.assertEqual(fonction_a_tester(), 20)

`patch` et ses variantes sont des outils très puissants pour isoler vos tests et contrôler le comportement des dépendances.

Bonnes pratiques et limitations

Quelques bonnes pratiques et limitations à connaître :

  • N'abusez pas des mocks : Les mocks sont utiles pour isoler les tests, mais ils ne doivent pas être utilisés de manière excessive. Si vous devez mocker trop de dépendances, cela peut indiquer un problème de conception (votre code est peut-être trop couplé).
  • Testez les interactions, pas l'implémentation : Les mocks sont plus utiles pour tester les *interactions* entre les objets (quelles méthodes sont appelées, avec quels arguments) que pour tester l'implémentation interne d'une fonction.
  • N'oubliez pas de restaurer l'état initial : `patch` restaure automatiquement l'état initial après le test (ou après la sortie du bloc `with`), mais si vous créez des mocks manuellement, assurez-vous qu'ils ne perturbent pas les autres tests.
  • Documentez l'utilisation des mocks : Expliquez clairement quelles dépendances sont mockées et pourquoi.
  • Les mocks ne remplacent pas les tests d'intégration : Les tests unitaires avec des mocks vérifient le comportement d'une unité de code *isolée*. Vous devez également effectuer des tests d'intégration pour vérifier que les différentes parties de votre programme fonctionnent correctement ensemble.