Maîtriser pytest : guide complet du développeur Python (3/3)

Après avoir exploré les bases de pytest dans mon premier article et ses fonctionnalités avancées dans le second, j'aborderais aujourd'hui deux aspects essentiels : le mocking et la structuration d'un projet de test. Comment simuler efficacement des dépendances externes ? Comment organiser son projet pour des tests maintenables ? De la théorie du mocking à l'utilisation de Poetry pour la gestion des dépendances, en passant par la configuration de pytest et mypy, nous verrons ensemble comment construire une base de tests solide et évolutive. Ce troisième volet de notre série vous donnera les outils pour passer à l'échelle et industrialiser vos tests.

Le mocking dans les tests Python

Comprendre le besoin des doubles de test

Lors de l'écriture des tests, nous devons résoudre une problématique fondamentale : comment tester efficacement notre code lorsqu'il dépend de composants complexes externes, tels qu'une base de données ou un service distant ? La solution consiste à remplacer ces composants réels par des objets de substitution plus simples et contrôlables. Dans le domaine du test, nous appelons ces objets de substitution des doubles de test.

Différencier les types de doubles de test

Il existe principalement deux types de doubles de test qui servent des objectifs différents :

Le stub

Un stub est un double qui fournit des réponses prédéfinies à des appels. Il simule le comportement d'un composant réel en retournant des données configurées à l'avance. Un stub est conçu pour retourner des réponses prédéfinies sans considération pour la manière dont il est appelé. Sa fonction se limite à simuler des réponses attendues pour les tests. Pour mieux comprendre son rôle, nous pouvons nous poser la question : "que doit retourner ce composant ?"

Le mock

Un mock, en revanche, est un double qui nous permet de vérifier comment il est utilisé. Il sert à s'assurer que notre code interagit correctement avec ses dépendances : quelles méthodes sont appelées, avec quels arguments, et combien de fois. Le mock répond à la question "comment ce composant doit-il être utilisé ?"

Les outils disponibles en Python

Python offre plusieurs bibliothèques pour implémenter le mocking :

  1. unittest.mock : intégré à la bibliothèque standard Python
  2. mockito-python : inspiré de la bibliothèque Java Mockito
  3. pytest-mock : un plugin pytest qui facilite l'utilisation de unittest.mock

Dans les exemples qui suivent, nous utiliserons unittest.mock, mais les concepts restent similaires quelle que soit la bibliothèque choisie.

Stub simple

Pour comprendre le fonctionnement basique d'un stub, prenons l'exemple d'une simulation de service d'envoi d'email :

from unittest.mock import Mock
from typing import Optional

# Notre classe réelle (pour référence)
class EmailSender:
    def send(self, address: str) -> bool:
        # Code réel d'envoi d'email
        pass

# Création d'un stub pour remplacer EmailSender
email_sender = Mock()
email_sender.send.return_value = True  # Configuration de la réponse

# Utilisation du stub
result: bool = email_sender.send("user@example.com")  # Retourne True

Dans cet exemple :

  • Nous créons un stub qui simule EmailSender
  • Nous définissons que sa méthode send retournera toujours True
  • Nous pouvons l'utiliser comme si c'était un véritable service d'envoi d'email

Cette approche permet de tester notre code sans réellement envoyer d'emails, tout en gardant la même interface que la classe réelle.

Mock de type Spy

Un spy est un type spécial de mock qui permet non seulement de simuler un objet ou une fonction, mais surtout de surveiller et vérifier comment cet objet est utilisé dans notre code. Dans l'exemple ci-dessous, on vérifie que la méthode process_payment a bien été appelée avec les bons arguments (100 et "USD").

payment_spy = Mock()
payment_spy.process_payment(100, "USD")  # Enregistre l'action effectuée

# On peut ensuite vérifier ce qui s'est passé
payment_spy.process_payment.assert_called_once()  # Vérifie que la méthode a été appelée exactement une fois

payment_spy.process_payment.assert_called_with(100, "USD")  # Vérifie que les bons arguments ont été utilisés

assert payment_spy.process_payment.call_count == 1  # Vérifie le nombre total d'appels

Ce mécanisme de vérification nous permet de nous assurer que notre code interagit correctement avec ses dépendances, sans avoir besoin d'exécuter réellement les opérations sensibles comme les paiements.

Patch 

Le décorateur '@patch' nous permet de remplacer temporairement des objets dans nos tests, mais son comportement peut sembler contre-intuitif au premier abord. La clé est de comprendre où appliquer le patch. Prenons un exemple concret :

# Dans email_service.py
class EmailSender:
    def send(self, message: str) -> None:
        # Code réel qui envoie le message
        pass


# Dans user_manager.py
from email_service import EmailSender
from typing import Any

mailer = EmailSender()

def send_welcome_email(user: Any) -> None:
    mailer.send(f"Welcome {user.name}!")

# Dans test_user_manager.py
from unittest.mock import patch

@patch('user_manager.mailer.send')  # On patche dans user_manager, pas dans email_service
def test_welcome_email(mock_send: Any) -> None:
    user = User(name="Alice")
    send_welcome_email(user)
    mock_send.assert_called_with("Welcome Alice!")

Le point crucial est que nous devons patcher l'objet à l'endroit où il est utilisé (‘user_manager.mailer.send’), et non pas à l'endroit où il est défini (‘email_service.EmailSender.send'). C'est parce que Python a déjà importé et créé l'objet mailer dans 'user_manager.py' - c'est cette instance que nous devons remplacer, pas la classe d'origine.

Cependant, l'utilisation de patch n'est pas recommandée pour plusieurs raisons. Les tests deviennent rapidement difficiles à lire et à maintenir. De plus, lors du refactoring, les chemins d'import dans les patch ne sont pas automatiquement mis à jour, ce qui peut créer des problèmes subtils. Le mécanisme d'import de Python peut également rendre le comportement des patch difficile à comprendre. Une meilleure approche consiste à utiliser l'injection de dépendances :

# Dans email_service.py
class EmailSender:
    def send(self, message: str) -> None:
        # Code réel qui envoie le message
        pass

# Dans user_manager.py
def send_welcome_email(user: Any, email_sender: EmailSender) -> None:  # On injecte email_sender comme paramètre
    email_sender.send(f"Welcome {user.name}!")

# Dans test_user_manager.py
from unittest.mock import Mock

def test_welcome_email() -> None:
    mock_sender = Mock()
    user = User(name="Alice")   
    send_welcome_email(user, mock_sender)   
    mock_sender.send.assert_called_with("Welcome Alice!")

L'injection de dépendances en Python est un design pattern utilisé pour rendre le code plus modulaire, maintenable et testable. Il consiste à fournir des dépendances externes (objets, services, configurations) à une classe ou une fonction plutôt que de les instancier directement à l'intérieur. Cette approche rend le code plus modulaire et plus facile à tester car nous pouvons facilement remplacer les vraies dépendances par des mocks pendant les tests.

L'approche avec injection de paramètre est plus maintenable que @patch car elle élimine le besoin de spécifier des chemins d'import qui peuvent se casser lors des refactoring. Plus besoin de comprendre le mécanisme complexe de patch Python - on passe simplement un mock comme paramètre et le test reste valide même si on réorganise le code.

Stub contextuel

Le stub contextuel avec with patch() est particulièrement utile quand nous voulons simuler un comportement seulement pour une portion spécifique de notre test. Imaginons que nous voulons tester une fonction qui fait un appel coûteux à une API, mais nous ne voulons simuler cet appel que pendant une partie précise du test. Voici l'exemple complet :

from unittest.mock import patch
from typing import Any

def test_temporary_mock() -> None:
   # Ici, expensive_operation est normale
   with patch('module.expensive_operation') as mock_op:
       mock_op.return_value = "Mocked Result" 
       result = perform_operation()
       assert result == "Mocked Result"
   
   # Ici, expensive_operation est redevenue normale

Le stub contextuel avec with patch() permet d'isoler précisément la partie de notre test où nous voulons simuler un comportement. Contrairement à un décorateur qui affecte toute la fonction de test, le bloc ‘with’ crée une "zone de simulation" temporaire : tout ce qui est à l'intérieur du bloc utilise notre mock, tandis que le code avant et après utilise les véritables implémentations. Cette flexibilité est particulièrement utile quand nous voulons tester l'interaction entre le code simulé et le code réel dans un même test.

Stub avec comportement dynamique 

Le side effect dans les stubs permet de créer des simulations plus sophistiquées où le comportement change en fonction des arguments reçus. Au lieu de retourner toujours la même valeur, notre stub peut retourner différents résultats ou même lever des exceptions selon les paramètres qu'il reçoit. Dans l'exemple ci-dessus, notre stub agit comme un routeur : il renvoie "First" quand il reçoit 1, "Second" quand il reçoit 2, et lève une erreur pour toute autre valeur. Voici comment l'utiliser en pratique :

mock_function = Mock(side_effect=side_effect)
print(mock_function(1))  # Affiche "First"
print(mock_function(2))  # Affiche "Second"


try:
   mock_function(3)     # Lève une ValueError
except ValueError:
   print("Erreur détectée comme prévu")

Mise en place de l’environnement 

Dans un projet professionnel, la qualité du code ne dépend pas uniquement des tests eux-mêmes, mais aussi de la façon dont nous organisons et configurons notre environnement de test. Cette section vous guidera à travers la mise en place d'un environnement robuste et maintenable.

Gestion des dépendances avec Poetry

Poetry s'est imposé comme l'outil de référence pour la gestion des dépendances en Python. Son approche déclarative et sa gestion des environnements virtuels en font un choix idéal pour les projets professionnels. Pour démarrer un nouveau projet, utilisez :

poetry init

Cette commande interactive vous guide dans la configuration initiale de votre projet. Poetry vous demandera notamment :

  • Le nom du package (utilisez des minuscules et des tirets)
  • La version initiale 
  • Une description de votre projet
  • Les versions Python compatibles
  • Les dépendances requises

Une fois le projet initialisé, installez les outils nécessaires pour le test :

poetry add pytest --group dev         # Framework de test principal
poetry add mypy --group dev           # Vérification des types statiques
poetry add black --group dev          # Formatage du code

Note sur les alternatives émergentes : Bien que Poetry soit aujourd'hui largement adopté, de nouveaux outils comme uv (développé par l'équipe derrière Ruff) gagnent en popularité dans l'écosystème Python. Écrit en Rust, uv propose une approche unifiée qui combine les fonctionnalités de plusieurs outils (pip, pip-tools, pipx, poetry, virtualenv). Bien que plus récent, il représente une alternative intéressante à surveiller pour la gestion des dépendances Python.

Structure recommandée du projet

L'organisation de votre projet a un impact direct sur sa maintenabilité. Voici une structure éprouvée, comme nous l’avons vu dans la section sur l’organisation en miroir du code:

mon_projet/
├── src/
│   └── mon_module/
│       ├── init.py
│       └── core.py
├── tests/
│   ├── init.py
│   ├── conftest.py        # Fixtures partagées
│   ├── unit/             # Tests unitaires
│   │   └── test_core.py
├── pyproject.toml        # Configuration Poetry
├── pytest.ini           # Configuration pytest
└── mypy.ini             # Configuration du typage

Cette structure suit les standards modernes de développement Python. 

Le code source est isolé dans le dossier src, une pratique qui évite de nombreux pièges d'importation et facilite le packaging. Le dossier tests suit une organisation miroir du code source, ce qui rend immédiatement évident où trouver les tests correspondant à chaque module. 

Les fichiers de configuration à la racine permettent de centraliser les paramètres des différents outils : pyproject.toml pour la gestion des dépendances avec Poetry, pytest.ini pour la configuration des tests, et mypy.ini pour le typage statique. Cette organisation permet une bonne maintenabilité et une scalabilité naturelle du projet.

Configuration des outils

Pour garantir la cohérence et l'efficacité de vos tests, configurez les outils principaux :

1. Configuration de pytest (pytest.ini) :

[pytest]
testpaths = tests
python_files = test_*.py
addopts = -v --cov=src --cov-report=term-missing
markers =
    slow: marque les tests lents
    unit: marque les tests unitaires

Cette configuration :

  • Indique où trouver les tests
  • Active le mode verbeux par défaut
  • Configure la mesure de couverture de code
  • Définit des marqueurs utiles pour organiser les tests

2. Configuration mypy pour la vérification des types (mypy.ini) :

[mypy]
python_version = 3.9
warn_return_any = True # Warns when a function implicitly returns Any
warn_unused_configs = True # Warns if configuration options are unused
disallow_untyped_defs = True # Forces explicit type declarations for all function definitions

check_untyped_defs = True # Checks the bodies of functions without type annotations

3. Ajout de scripts utilitaires dans pyproject.toml :

[tool.poetry.scripts]
test = "pytest"
coverage = "pytest --cov=src --cov-report=html"
format = "black src tests"
lint = "mypy src tests"

Ces scripts simplifient l'exécution quotidienne des tâches de développement :

poetry run test          # Lance la suite de tests
poetry run format       # Formate le code automatiquement
poetry run lint         # Vérifie le typage statique

Bonnes pratiques d'implémentation

Pour tirer le meilleur parti de cette configuration :

1. Créez des fixtures réutilisables dans conftest.py :

import pytest
from pathlib import Path
@pytest.fixture
def temp_data_dir(tmp_path):
    """Creates a temporary directory for test data."""      
    data_dir = tmp_path / "data"
    data_dir.mkdir()
    yield data_dir

2. Centralisez la configuration des tests :

# tests/config.py
TEST_CONFIG = {
    "API_URL": "http://test-api.example.com",
    "TIMEOUT": 5,
    "MAX_RETRIES": 3
}

3. Utilisez des variables d'environnement pour les configurations sensibles :

import os
from pathlib import Path

# tests/conftest.py
@pytest.fixture(scope="session")
def api_credentials():
    """Loads test credentials from environment variables."""  
    return {
        "api_key": os.getenv("TEST_API_KEY"),
        "api_secret": os.getenv("TEST_API_SECRET")
    }

Au fil de cette série sur pytest, nous avons progressivement construit une compréhension complète du test en Python. Si les deux premiers articles vous ont donné les bases puis les fonctionnalités avancées de pytest, ce troisième volet vous a montré comment structurer vos tests et simuler efficacement vos dépendances.

Le mocking n'est qu'une partie de l'équation. Une base de tests solide repose aussi sur une organisation réfléchie du projet, des outils bien configurés, et des pratiques cohérentes. L'utilisation de Poetry pour la gestion des dépendances, couplée à mypy pour le typage et pytest pour les tests, forme un trio d'outils particulièrement efficace.

Le test n'est pas une fin en soi, il s'agit d'un outil au service de la qualité du code. En maîtrisant ces concepts et ces outils, vous pourrez vous concentrer sur ce qui compte vraiment : développer des fonctionnalités robustes et maintenables.