Tests avec pytest#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Pourquoi tester ?#

Écrire des tests automatisés est l’une des pratiques de génie logiciel qui a le plus fort impact sur la qualité d’un projet. Un test automatisé est un programme qui vérifie qu’une portion du code se comporte comme prévu. Lorsqu’on modifie le code, on réexécute les tests pour s’assurer que rien n’a été cassé — ce qu’on appelle les tests de régression.

Sans tests, chaque modification est un pari. On espère que la fonctionnalité A n’a pas cassé la fonctionnalité B, mais on ne peut pas en être certain sans tester manuellement l’application entière, ce qui est lent, peu fiable et frustrant. Avec une bonne suite de tests, cette vérification est automatique et prend quelques secondes.

Types de tests#

Les tests sont classiquement organisés en trois niveaux, formant la pyramide des tests :

Tests unitaires. Ils testent une unité isolée — une fonction, une méthode, une classe — indépendamment du reste du système. Les dépendances externes (bases de données, API, système de fichiers) sont remplacées par des objets simulés (mocks). Ce sont les tests les plus rapides à écrire, les plus rapides à exécuter, et ceux que l’on doit avoir en plus grand nombre. Ils localisent précisément la source d’un bug.

Tests d’intégration. Ils vérifient que plusieurs composants fonctionnent correctement ensemble : une couche de service avec une vraie base de données, un module avec le système de fichiers, deux services qui communiquent via une file de messages. Ils sont plus lents et plus coûteux à écrire et à maintenir, car ils nécessitent souvent un environnement plus proche de la production.

Tests de bout en bout (end-to-end, e2e). Ils simulent le comportement d’un utilisateur réel sur l’application complète. Pour une application web, un outil comme playwright ou selenium pilote un vrai navigateur. Ces tests sont les plus lents, les plus fragiles (une modification de l’interface peut casser des dizaines de tests) et les plus coûteux. Ils doivent être peu nombreux et se concentrer sur les chemins critiques de l’application.

Hide code cell source

fig, ax = plt.subplots(figsize=(11, 8))
ax.set_xlim(-0.5, 11)
ax.set_ylim(-0.5, 9)
ax.axis('off')
ax.set_title("La pyramide des tests", fontsize=15, fontweight='bold', pad=12)

# Pyramide en trois niveaux (triangles empilés)
niveaux = [
    # (y_bas, hauteur, largeur_base, couleur, label, sous_label)
    (0.3, 2.8, 9.0, '#27ae60',  "Tests unitaires",
     "Nombreux · Rapides · Précis · Isolation totale"),
    (3.3, 2.5, 6.0, '#e67e22',  "Tests d'intégration",
     "Moyennement nombreux · Plus lents · Environnement réel"),
    (6.0, 2.3, 3.2, '#e74c3c',  "Tests e2e",
     "Peu nombreux · Lents · Chemin critique"),
]

cx = 5.25  # Centre horizontal

for y_bas, hauteur, largeur, couleur, label, sous_label in niveaux:
    demi = largeur / 2
    triangle = plt.Polygon(
        [(cx - demi, y_bas), (cx + demi, y_bas), (cx, y_bas + hauteur)],
        closed=True, facecolor=couleur, edgecolor='white', linewidth=2.5,
        alpha=0.88, zorder=3
    )
    ax.add_patch(triangle)
    ax.text(cx, y_bas + hauteur * 0.42, label,
            ha='center', va='center', fontsize=11,
            fontweight='bold', color='white', zorder=4)
    ax.text(cx, y_bas + hauteur * 0.17, sous_label,
            ha='center', va='center', fontsize=7.5,
            color='white', alpha=0.92, zorder=4)

# Annotations latérales
ax.annotate('', xy=(-0.3, 8.5), xytext=(-0.3, 0.0),
            arrowprops=dict(arrowstyle='->', color='#2c3e50', lw=2.0))
ax.text(-0.45, 4.5, "Coût / Fragilité / Durée",
        ha='center', va='center', fontsize=8.5, color='#2c3e50',
        rotation=90, style='italic')

ax.annotate('', xy=(10.8, 0.0), xytext=(10.8, 8.5),
            arrowprops=dict(arrowstyle='->', color='#2c3e50', lw=2.0))
ax.text(11.0, 4.5, "Quantité recommandée",
        ha='center', va='center', fontsize=8.5, color='#2c3e50',
        rotation=90, style='italic')

# Légende ratio
ratios = [("Tests unitaires", '#27ae60', "70–80 %"),
          ("Tests d'intégration", '#e67e22', "15–25 %"),
          ("Tests e2e", '#e74c3c', "5–10 %")]
for i, (nom, col, ratio) in enumerate(ratios):
    rect = patches.FancyBboxPatch((6.5, 0.4 + i * 0.8), 3.8, 0.65,
                                   boxstyle="round,pad=0.08",
                                   facecolor=col, edgecolor=col, alpha=0.85)
    ax.add_patch(rect)
    ax.text(8.4, 0.72 + i * 0.8, f"{nom} : {ratio}",
            ha='center', va='center', fontsize=8, color='white',
            fontweight='bold')

plt.tight_layout()
plt.show()
_images/689154f7ce08370979067da67bac50da5df55dbe7ba50f08f216492f261f9a58.png

pytest — premiers pas#

pytest est le framework de test de facto en Python. Il offre une syntaxe minimaliste basée sur des assertions Python ordinaires, une découverte automatique des tests, et un écosystème de plugins très riche.

Installation#

# Avec uv (recommandé)
uv add --dev pytest

# Avec pip
pip install pytest

Conventions de nommage#

pytest découvre automatiquement les tests selon ces conventions :

  • Les fichiers de test sont nommés test_*.py ou *_test.py.

  • Les fonctions de test commencent par test_.

  • Les classes de test commencent par Test (sans méthode __init__).

  • Les méthodes de test dans une classe commencent par test_.

Écrire des tests simples#

# Fonctions à tester (simulons un module calculatrice.py)
def additionner(a: float, b: float) -> float:
    return a + b

def diviser(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Division par zéro interdite.")
    return a / b

def est_pair(n: int) -> bool:
    return n % 2 == 0


# Tests correspondants (normalement dans test_calculatrice.py)
import pytest

def test_additionner_entiers():
    assert additionner(2, 3) == 5

def test_additionner_flottants():
    assert additionner(0.1, 0.2) == pytest.approx(0.3)

def test_diviser_normal():
    assert diviser(10, 2) == 5.0

def test_diviser_par_zero():
    with pytest.raises(ValueError, match="Division par zéro"):
        diviser(5, 0)

def test_est_pair():
    assert est_pair(4) is True
    assert est_pair(7) is False
    assert est_pair(0) is True


# Exécutons les tests manuellement pour la démonstration
tests = [
    test_additionner_entiers,
    test_additionner_flottants,
    test_diviser_normal,
    test_diviser_par_zero,
    test_est_pair,
]

for t in tests:
    try:
        t()
        print(f"✓ {t.__name__}")
    except AssertionError as e:
        print(f"✗ {t.__name__}: {e}")
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[3], line 15
     11     return n % 2 == 0
     14 # Tests correspondants (normalement dans test_calculatrice.py)
---> 15 import pytest
     17 def test_additionner_entiers():
     18     assert additionner(2, 3) == 5

ModuleNotFoundError: No module named 'pytest'

Remarque 29

pytest.approx() est essentiel pour comparer des nombres flottants. L’assertion assert 0.1 + 0.2 == 0.3 échoue à cause de l’imprécision de la représentation en virgule flottante (0.1 + 0.2 = 0.30000000000000004). pytest.approx(0.3) autorise une tolérance relative de 1e-6 par défaut, ce qui est adapté à la plupart des calculs scientifiques.

Exécution#

# Exécuter tous les tests du répertoire courant
pytest

# Mode verbeux : affiche le nom de chaque test
pytest -v

# Exécuter un fichier spécifique
pytest tests/test_calculatrice.py

# Exécuter un test spécifique
pytest tests/test_calculatrice.py::test_additionner_entiers

# Arrêter au premier échec
pytest -x

# Afficher les print() même pour les tests qui passent
pytest -s

Fixtures#

Les fixtures sont le mécanisme de pytest pour préparer et nettoyer l’environnement de test. Elles remplacent les méthodes setUp / tearDown de unittest avec une approche bien plus flexible et composable.

Définir et utiliser une fixture#

Une fixture est une fonction décorée par @pytest.fixture. Un test la reçoit simplement en la nommant parmi ses paramètres :

import pytest
import tempfile
import os

@pytest.fixture
def fichier_temporaire():
    """Crée un fichier temporaire et le supprime après le test."""
    with tempfile.NamedTemporaryFile(mode='w', suffix='.txt',
                                     delete=False) as f:
        f.write("Ligne 1\nLigne 2\nLigne 3\n")
        chemin = f.name
    yield chemin  # Le test s'exécute ici
    os.unlink(chemin)  # Nettoyage après le test


@pytest.fixture
def base_de_donnees_vide():
    """Simule une base de données vide."""
    return {"utilisateurs": {}, "articles": {}}


def test_lire_fichier(fichier_temporaire):
    with open(fichier_temporaire) as f:
        lignes = f.readlines()
    assert len(lignes) == 3
    assert lignes[0].strip() == "Ligne 1"


def test_ajouter_utilisateur(base_de_donnees_vide):
    db = base_de_donnees_vide
    db["utilisateurs"]["alice"] = {"age": 30}
    assert "alice" in db["utilisateurs"]
    assert db["utilisateurs"]["alice"]["age"] == 30


# Exécution manuelle
import contextlib

@contextlib.contextmanager
def fixture_context(fixture_fn):
    """Simule l'injection de fixture pour la démonstration."""
    gen = fixture_fn()
    if hasattr(gen, '__next__'):
        val = next(gen)
        try:
            yield val
        finally:
            with contextlib.suppress(StopIteration):
                next(gen)
    else:
        yield gen

with fixture_context(fichier_temporaire) as f:
    test_lire_fichier(f)
    print("✓ test_lire_fichier")

db = base_de_donnees_vide()
test_ajouter_utilisateur(db)
print("✓ test_ajouter_utilisateur")

Portées des fixtures#

La portée (scope) d’une fixture contrôle sa durée de vie :

@pytest.fixture(scope="function")  # Par défaut : créée pour chaque test
def connexion_par_test():
    ...

@pytest.fixture(scope="module")    # Créée une fois par fichier de test
def connexion_par_module():
    ...

@pytest.fixture(scope="session")   # Créée une fois pour toute la session
def connexion_globale():
    ...

conftest.py#

Le fichier conftest.py est automatiquement chargé par pytest. C’est l’endroit idéal pour définir des fixtures partagées entre plusieurs fichiers de test :

# tests/conftest.py
import pytest

@pytest.fixture(scope="session")
def client_http():
    """Client HTTP partagé pour tous les tests de la session."""
    import httpx
    with httpx.Client(base_url="http://localhost:8000") as client:
        yield client

Marqueurs et paramétrage#

@pytest.mark.parametrize#

Le décorateur @pytest.mark.parametrize permet d’exécuter un test avec plusieurs jeux de données, en évitant la duplication de code :

import pytest

@pytest.mark.parametrize("entree,attendu", [
    ("bonjour", "BONJOUR"),
    ("Python", "PYTHON"),
    ("", ""),
    ("déjà vu", "DÉJÀ VU"),
])
def test_mettre_en_majuscules(entree, attendu):
    assert entree.upper() == attendu


@pytest.mark.parametrize("a,b,resultat", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, -50, 50),
])
def test_additionner_parametrise(a, b, resultat):
    assert additionner(a, b) == resultat


# Simulation de l'exécution
cas_majuscules = [
    ("bonjour", "BONJOUR"),
    ("Python", "PYTHON"),
    ("", ""),
    ("déjà vu", "DÉJÀ VU"),
]
for entree, attendu in cas_majuscules:
    try:
        test_mettre_en_majuscules(entree, attendu)
        print(f"✓ majuscules({entree!r}) == {attendu!r}")
    except AssertionError as e:
        print(f"✗ {e}")

@pytest.mark.skip et @pytest.mark.xfail#

import sys

@pytest.mark.skip(reason="Fonctionnalité pas encore implémentée")
def test_fonctionnalite_future():
    assert nouvelle_fonctionnalite() == 42

@pytest.mark.skipif(sys.platform == "win32",
                    reason="Test spécifique à Linux/macOS")
def test_permissions_unix():
    ...

@pytest.mark.xfail(reason="Bug connu, issue #123")
def test_comportement_bugge():
    # On s'attend à ce que ce test échoue (xfail = "expected failure")
    # S'il passe, pytest le marque en xpass (unexpected pass)
    assert resultat_bugge() == valeur_attendue

Mocks et patchs#

Les mocks (objets simulacres) remplacent des dépendances réelles — appels HTTP, bases de données, horloge système — par des objets contrôlables dans les tests. Cela isole parfaitement l’unité testée.

unittest.mock#

from unittest.mock import Mock, MagicMock, patch, call

# Mock basique
mock_service = Mock()
mock_service.calculer.return_value = 42

# Appeler le mock
resultat = mock_service.calculer(10, 20)
print(f"Résultat du mock : {resultat}")

# Vérifier les appels
mock_service.calculer.assert_called_once_with(10, 20)
print(f"Appelé {mock_service.calculer.call_count} fois avec : "
      f"{mock_service.calculer.call_args}")
from unittest.mock import patch
import json

def recuperer_configuration(url: str) -> dict:
    """Récupère une configuration depuis une URL."""
    import urllib.request
    with urllib.request.urlopen(url) as reponse:
        return json.loads(reponse.read())


# Test sans appel réseau réel
def test_recuperer_configuration():
    config_attendue = {"debug": True, "version": "1.0"}

    with patch("urllib.request.urlopen") as mock_urlopen:
        # Configurer le mock pour retourner une réponse simulée
        mock_reponse = MagicMock()
        mock_reponse.read.return_value = json.dumps(config_attendue).encode()
        mock_reponse.__enter__ = lambda s: s
        mock_reponse.__exit__ = Mock(return_value=False)
        mock_urlopen.return_value = mock_reponse

        # Appeler la fonction à tester
        resultat = recuperer_configuration("http://exemple.fr/config.json")

        # Vérifier le résultat
        assert resultat == config_attendue
        mock_urlopen.assert_called_once_with("http://exemple.fr/config.json")
        print("✓ test_recuperer_configuration")

test_recuperer_configuration()

pytest-mock#

Le plugin pytest-mock intègre patch comme une fixture mocker, ce qui est plus propre car le patch est automatiquement défait après le test :

# Avec pytest-mock (pip install pytest-mock)
def test_avec_pytest_mock(mocker):
    mock_open = mocker.patch("builtins.open",
                              mocker.mock_open(read_data="contenu"))
    with open("fichier.txt") as f:
        assert f.read() == "contenu"
    mock_open.assert_called_once_with("fichier.txt")

Définition 28 (Mock vs Stub vs Spy)

Ces trois termes décrivent des variantes d’objets de test :

  • Stub : retourne des valeurs prédéfinies, sans vérifier les appels.

  • Mock : retourne des valeurs prédéfinies et vérifie que les appels ont eu lieu comme prévu.

  • Spy : enveloppe un objet réel pour enregistrer les appels sans modifier le comportement.

En Python, unittest.mock.Mock peut jouer les trois rôles selon l’usage qu’on en fait.

Couverture de code#

La couverture de code (code coverage) mesure quelle proportion des lignes, branches et fonctions du code source est exercée par les tests. C’est un indicateur utile, mais pas suffisant : une couverture à 100 % ne signifie pas que les tests sont pertinents — cela signifie seulement que chaque ligne a été exécutée au moins une fois.

pytest-cov#

# Installation
uv add --dev pytest-cov

# Exécution avec rapport de couverture
pytest --cov=mon_module --cov-report=term-missing

# Rapport HTML interactif
pytest --cov=mon_module --cov-report=html
# Ouvre htmlcov/index.html dans le navigateur

Un rapport typique dans le terminal ressemble à :

---------- coverage: platform linux, python 3.12 ----------
Name                Stmts   Miss  Cover   Missing
-------------------------------------------------
mon_module/core.py     45      3    93%   23, 47-48
mon_module/utils.py    18      0   100%
-------------------------------------------------
TOTAL                  63      3    95%

Seuil minimum en CI#

On peut configurer un seuil minimum de couverture, en dessous duquel la pipeline CI échoue :

pytest --cov=src --cov-fail-under=80

Ou dans pyproject.toml :

[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"

Exemple 8 (Organisation des tests dans un projet)

Une organisation typique pour les tests d’un projet Python avec layout src/ :

mon_projet/ ├── src/ │ └── mon_module/ │ ├── init.py │ ├── core.py │ └── utils.py ├── tests/ │ ├── conftest.py # Fixtures partagées │ ├── unit/ │ │ ├── test_core.py │ │ └── test_utils.py │ └── integration/ │ └── test_api.py └── pyproject.toml


Cette séparation entre tests unitaires et tests d'intégration permet de les exécuter indépendamment : `pytest tests/unit/` pour la boucle de développement rapide, `pytest tests/` pour la CI complète.

Résumé#

Ce chapitre a couvert les tests automatisés en Python avec pytest :

  • La pyramide des tests organise les tests en trois niveaux : unitaires (nombreux, rapides, isolés), d’intégration (moyennement nombreux, environnement réel) et e2e (peu nombreux, chemin critique). Les tests unitaires doivent représenter la majorité.

  • pytest découvre les tests automatiquement selon des conventions de nommage claires. Les assertions utilisent le mot-clé assert standard de Python, et pytest.raises vérifie qu’une exception est bien levée.

  • Les fixtures (@pytest.fixture) préparent et nettoient l’environnement de test avec les portées function, module et session. conftest.py partage les fixtures entre fichiers.

  • @pytest.mark.parametrize évite la duplication en exécutant un test avec plusieurs jeux de données. skip et xfail gèrent les tests conditionnels et les échecs attendus.

  • Les mocks (unittest.mock.Mock, patch) remplacent les dépendances externes pour isoler l’unité testée. pytest-mock simplifie leur usage comme fixtures.

  • pytest-cov mesure la couverture de code et peut imposer un seuil minimum en CI.

Dans le chapitre suivant, nous aborderons les modules, paquets et l’outil uv, pour comprendre comment organiser un projet Python complet, gérer ses dépendances et le publier sur PyPI.