---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Tests avec pytest

```{code-cell} python
:tags: [hide-input]

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.

```{code-cell} python
:tags: [hide-input]

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()
```

## `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

```bash
# 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

```{code-cell} python
# 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}")
```

```{prf:remark}
:label: remark-15-01
`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

```bash
# 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 :

```{code-cell} python
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 :

```python
@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 :

```python
# 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 :

```{code-cell} python
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`

```python
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`

```{code-cell} python
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}")
```

```{code-cell} python
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 :

```python
# 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")
```

```{prf:definition} Mock vs Stub vs Spy
:label: definition-15-01
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`

```bash
# 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 :

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

Ou dans `pyproject.toml` :

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

```{prf:example} Organisation des tests dans un projet
:label: example-15-01
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.
