Aller au contenu

Chapitre 13 — Tests avec pytest et bonnes pratiques

⏱️ Durée estimée : 5 à 7 heures (cours + exercices + mini-projet)

📋 Prérequis : avoir terminé le Chapitre 12 — Outils : venv, pip et Git (savoir créer un venv et installer un paquet), être à l'aise avec les fonctions (Chapitre 05), les exceptions (Chapitre 08) et les classes (Chapitre 10).

🎯 Objectifs — À la fin de ce chapitre, tu sauras…

  • écrire et lancer des tests automatiques avec pytest, et lire un rapport d'échec ;
  • utiliser les outils clés de pytest : assert, pytest.approx, fixtures, @pytest.mark.parametrize, pytest.raises, tmp_path ;
  • appliquer la démarche TDD (écrire le test avant le code) sur un petit problème ;
  • respecter les règles principales de PEP 8 et documenter ton code avec des docstrings ;
  • vérifier et formater automatiquement ton code avec ruff ;
  • organiser un projet professionnel : src/, tests/, README.md, pyproject.toml.

Cours

Jusqu'à présent, comment vérifiais-tu que ton code marchait ? Probablement en le lançant, en tapant quelques valeurs, et en regardant si le résultat avait l'air juste. Ça fonctionne… jusqu'au jour où tu modifies une fonction pour ajouter une fonctionnalité, et tu casses sans le voir quelque chose qui marchait avant.

Ce chapitre t'apprend à automatiser cette vérification et à écrire du code propre, lisible par les autres (et par toi dans six mois).

1. Pourquoi tester automatiquement ?

Imagine un pilote d'avion. Avant chaque décollage, il suit une checklist : volets, carburant, instruments… Il ne se dit pas « hier tout marchait, ça ira ». Il vérifie, systématiquement, parce que c'est rapide et qu'une erreur coûterait cher.

Un test automatique est une checklist pour ton code : une petite fonction qui appelle ton code avec des données connues et vérifie que le résultat est celui attendu. Une fois écrit, il se relance en une seconde, autant de fois que tu veux.

Les bénéfices :

  • 🛡️ Filet de sécurité : tu modifies ton code sans peur ; si tu casses quelque chose, un test devient rouge immédiatement.
  • 🐛 Bugs trouvés tôt : en pensant aux cas de test (liste vide ? nombre négatif ?), tu découvres des bugs avant tes utilisateurs.
  • 📖 Documentation vivante : un test montre concrètement comment utiliser une fonction.
  • 🧱 Meilleure conception : un code facile à tester est généralement un code bien découpé.

💡 Il existe plusieurs types de tests. Dans ce chapitre, on écrit surtout des tests unitaires : ils testent une petite unité (une fonction, une méthode) isolément.

2. Premier test avec assert

Tu connais peut-être déjà le mot-clé assert : il vérifie qu'une condition est vraie, et lève une AssertionError sinon.

assert 2 + 2 == 4        # rien ne se passe : c'est vrai
assert 2 + 2 == 5        # AssertionError

Un test, c'est donc simplement une fonction qui contient des assert. Prenons un fichier calculs.py :

def additionner(a, b):
    return a + b


def moyenne(notes):
    """Renvoie la moyenne d'une liste de nombres."""
    if not notes:
        raise ValueError("La liste de notes est vide.")
    return sum(notes) / len(notes)


def est_pair(n):
    return n % 2 == 0

3. Installer et lancer pytest

pytest est l'outil de test le plus utilisé en Python. Il trouve tes tests tout seul, les exécute, et affiche un rapport clair. Installe-le dans ton venv :

# venv activé : (.venv) doit apparaître dans l'invite
pip install pytest

3.1 Les conventions de nommage (importantes !)

pytest découvre les tests grâce à leur nom :

  • les fichiers de test s'appellent test_quelquechose.py (ou quelquechose_test.py) ;
  • les fonctions de test commencent par test_ ;
  • (si tu regroupes des tests dans une classe, elle commence par Test et n'a pas de __init__).

Une fonction nommée verifier_addition() ne sera jamais exécutée par pytest, même si elle est dans un fichier de test.

3.2 Notre premier fichier de test

À côté de calculs.py, crée test_calculs.py :

from calculs import additionner, est_pair, moyenne


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


def test_additionner_negatif():
    assert additionner(-1, 1) == 0


def test_moyenne_simple():
    assert moyenne([10, 12, 14]) == 12


def test_est_pair():
    assert est_pair(4)
    assert not est_pair(7)

Donne à tes tests des noms descriptifs : quand un test échoue, son nom est la première chose que tu lis. test_moyenne_liste_vide_leve_une_erreur vaut mieux que test_3.

3.3 Lancer les tests

Dans le terminal, depuis le dossier du projet :

pytest
============================= test session starts ==============================
collected 4 items

test_calculs.py ....                                                     [100%]

============================== 4 passed in 0.01s ===============================

Chaque point . est un test réussi. Options utiles :

pytest -v                          # verbeux : affiche le nom de chaque test
pytest test_calculs.py             # un seul fichier
pytest test_calculs.py::test_est_pair   # un seul test
pytest -k moyenne                  # seulement les tests dont le nom contient "moyenne"
pytest -x                          # s'arrête au premier échec
pytest --lf                        # relance seulement les tests qui ont échoué la dernière fois

💡 Si la commande pytest n'est pas reconnue (fréquent sous Windows), utilise python -m pytest. C'est équivalent, et ça garantit que tu utilises le pytest du venv.

3.4 Lire un échec

Ajoutons volontairement un test faux :

def test_moyenne_fausse():
    assert moyenne([10, 20]) == 16
=================================== FAILURES ===================================
_____________________________ test_moyenne_fausse ______________________________

    def test_moyenne_fausse():
>       assert moyenne([10, 20]) == 16
E       assert 15.0 == 16
E        +  where 15.0 = moyenne([10, 20])

test_echec.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_echec.py::test_moyenne_fausse - assert 15.0 == 16
============================== 1 failed in 0.02s ===============================

C'est là que pytest brille : il ne dit pas juste « faux », il montre la ligne (>), les valeurs comparées (15.0 == 16) et d'où vient la valeur (where 15.0 = moyenne([10, 20])). Ici, c'est le test qui est faux (la moyenne de 10 et 20 est bien 15) ; parfois ce sera le code. À toi d'enquêter !

⚠️ Un test qui échoue n'est pas un échec pour toi : c'est un renseignement. Le vrai danger, c'est un bug que personne ne détecte.

3.5 Les nombres décimaux : pytest.approx

Tu te souviens que 0.1 + 0.2 == 0.3 vaut False en Python, à cause de la représentation des nombres à virgule (chapitre 01) ? Pour comparer des float, utilise pytest.approx qui tolère une minuscule différence :

import pytest


def test_moyenne_avec_decimales():
    assert moyenne([0.1, 0.2]) == pytest.approx(0.15)

4. Tester les erreurs : pytest.raises

Une bonne fonction refuse les données invalides en levant une exception (chapitre 08). Il faut tester ça aussi ! pytest.raises s'utilise avec with :

import pytest


def test_moyenne_liste_vide_leve_une_erreur():
    with pytest.raises(ValueError):
        moyenne([])

Lecture : « dans ce bloc, je m'attends à ce qu'une ValueError soit levée ». Le test réussit si l'exception est levée, et échoue si elle ne l'est pas (ou si c'est une autre exception).

Tu peux aussi vérifier le message, avec match (qui cherche un motif dans le texte de l'erreur) :

def test_moyenne_liste_vide_message():
    with pytest.raises(ValueError, match="vide"):
        moyenne([])

5. Tester beaucoup de cas : @pytest.mark.parametrize

Tester est_pair avec 5 valeurs demanderait 5 fonctions presque identiques. Plutôt que de copier-coller, on paramètre le test :

import pytest


@pytest.mark.parametrize(
    "nombre, attendu",
    [
        (0, True),
        (1, False),
        (2, True),
        (-3, False),
        (100, True),
    ],
)
def test_est_pair_parametre(nombre, attendu):
    assert est_pair(nombre) == attendu
  • Le premier argument est une chaîne avec les noms des paramètres, séparés par des virgules.
  • Le second est une liste de tuples : chaque tuple est un cas de test.
  • pytest exécute la fonction une fois par tuple, et les compte comme des tests séparés :
test_calculs.py::test_est_pair_parametre[0-True] PASSED
test_calculs.py::test_est_pair_parametre[1-False] PASSED
test_calculs.py::test_est_pair_parametre[2-True] PASSED
test_calculs.py::test_est_pair_parametre[-3-False] PASSED
test_calculs.py::test_est_pair_parametre[100-True] PASSED

Si un seul cas échoue, tu sais exactement lequel. Et ajouter un cas = ajouter une ligne. 🎯

💡 Quels cas tester ? Pense toujours à : un cas « normal », les limites (0, 1, liste vide, chaîne vide, valeur pile au seuil), les valeurs négatives, et les entrées invalides. C'est souvent aux bords que se cachent les bugs.

6. Préparer des données : les fixtures

Quand plusieurs tests ont besoin du même objet de départ (un panier rempli, un compte bancaire, un fichier…), on évite de le recréer à la main dans chaque test. On écrit une fixture : une fonction décorée par @pytest.fixture qui fabrique cet objet.

Analogie : au restaurant, la mise en place est préparée avant le service ; chaque plat pioche dedans. Une fixture, c'est la mise en place de tes tests.

Prenons une classe Panier (panier.py) :

class Panier:
    def __init__(self):
        self.articles = {}  # nom -> (prix_unitaire, quantite)

    def ajouter(self, nom, prix, quantite=1):
        if prix < 0 or quantite <= 0:
            raise ValueError("Prix ou quantité invalide.")
        _, deja = self.articles.get(nom, (prix, 0))
        self.articles[nom] = (prix, deja + quantite)

    def total(self):
        return sum(prix * qte for prix, qte in self.articles.values())

    def nombre_articles(self):
        return sum(qte for _, qte in self.articles.values())

Et ses tests (test_panier.py) :

import pytest

from panier import Panier


@pytest.fixture
def panier_vide():
    return Panier()


@pytest.fixture
def panier_rempli():
    panier = Panier()
    panier.ajouter("pomme", 0.5, 4)
    panier.ajouter("pain", 1.2)
    return panier


def test_panier_vide_total_nul(panier_vide):
    assert panier_vide.total() == 0


def test_total(panier_rempli):
    assert panier_rempli.total() == pytest.approx(3.2)


def test_nombre_articles(panier_rempli):
    assert panier_rempli.nombre_articles() == 5


def test_ajouter_meme_article_cumule(panier_rempli):
    panier_rempli.ajouter("pomme", 0.5, 2)
    assert panier_rempli.nombre_articles() == 7


def test_prix_negatif_refuse(panier_vide):
    with pytest.raises(ValueError):
        panier_vide.ajouter("bug", -1)

La magie : il suffit de mettre le nom de la fixture en paramètre du test. pytest voit panier_rempli, appelle la fixture du même nom, et passe le résultat au test.

Point essentiel : chaque test reçoit un objet tout neuf. Dans test_ajouter_meme_article_cumule, on modifie le panier ; ça n'a aucun effet sur test_nombre_articles. Les tests sont indépendants, ce qui est indispensable : un test ne doit jamais dépendre de l'ordre d'exécution ou d'un autre test.

💡 Pour partager des fixtures entre plusieurs fichiers de test, place-les dans un fichier spécial nommé conftest.py dans le dossier des tests : pytest le charge automatiquement, sans import.

6.1 Une fixture intégrée très utile : tmp_path

pytest fournit des fixtures toutes prêtes. La plus utile pour toi : tmp_path, un dossier temporaire (un objet pathlib.Path, voir chapitre 07) différent pour chaque test, et nettoyé automatiquement. Parfait pour tester du code qui lit ou écrit des fichiers sans polluer ton disque.

# sauvegarde.py
import json
from pathlib import Path


def sauvegarder(donnees, chemin):
    Path(chemin).write_text(json.dumps(donnees, ensure_ascii=False), encoding="utf-8")


def charger(chemin):
    chemin = Path(chemin)
    if not chemin.exists():
        return {}
    return json.loads(chemin.read_text(encoding="utf-8"))
# test_sauvegarde.py
from sauvegarde import charger, sauvegarder


def test_aller_retour(tmp_path):
    fichier = tmp_path / "donnees.json"
    sauvegarder({"nom": "Zoé", "age": 12}, fichier)
    assert charger(fichier) == {"nom": "Zoé", "age": 12}


def test_fichier_absent(tmp_path):
    assert charger(tmp_path / "inexistant.json") == {}

7. Structure d'un bon test : Arrange, Act, Assert

Un test lisible suit souvent trois temps, comme une recette :

  1. Arrange (préparer) : créer les données nécessaires (souvent via une fixture) ;
  2. Act (agir) : appeler la fonction testée ;
  3. Assert (vérifier) : comparer le résultat à l'attendu.
def test_virement_debite_et_credite():
    # Arrange
    compte_a = CompteBancaire("Alice", 100)
    compte_b = CompteBancaire("Bob", 0)
    # Act
    compte_a.virer_vers(compte_b, 40)
    # Assert
    assert compte_a.solde == 60
    assert compte_b.solde == 40

Règles d'or :

  • un test = un comportement (plusieurs assert sont OK s'ils vérifient la même idée) ;
  • un test doit être rapide et déterministe (même résultat à chaque fois) ;
  • teste ce que fait la fonction (son résultat), pas comment elle le fait.

💡 Et le hasard ? Une fonction qui utilise random est difficile à tester. Astuce : vérifie des propriétés (« le dé donne toujours un nombre entre 1 et 6 », sur 1000 tirages), ou fixe la graine avec random.seed(42) pour obtenir toujours la même suite.

8. TDD : écrire le test d'abord

Le TDD (Test-Driven Development, développement piloté par les tests) inverse l'ordre habituel : on écrit le test avant le code. Le cycle tient en trois couleurs :

   🔴 ROUGE           🟢 VERT              🔵 REFACTOR
 écrire un test  →  écrire le minimum  →  améliorer le code
 qui échoue         pour qu'il passe       (les tests restent verts)
        ↑___________________________________________|

Exemple avec le classique FizzBuzz : une fonction qui renvoie "Fizz" pour les multiples de 3, "Buzz" pour les multiples de 5, "FizzBuzz" pour les multiples des deux, et le nombre lui-même sinon.

Tour 1 — 🔴 On écrit d'abord le test le plus simple :

from fizzbuzz import fizzbuzz


def test_nombre_ordinaire():
    assert fizzbuzz(1) == "1"

pytest → échec (ImportError : la fonction n'existe pas encore). C'est normal et voulu.

Tour 1 — 🟢 Le minimum pour passer :

def fizzbuzz(n):
    return str(n)

Tour 2 — 🔴 On ajoute assert fizzbuzz(3) == "Fizz" → rouge. 🟢 On ajoute if n % 3 == 0: return "Fizz" → vert.

Tours suivants : même chose pour 5, puis 15. Le piège de 15 (multiple de 3 et de 5) apparaît naturellement : le test fizzbuzz(15) == "FizzBuzz" reste rouge (la fonction renvoie "Fizz") tant que le code ne vérifie pas le cas « multiple de 15 » avant les deux autres.

🔵 Refactor : à la fin, on regroupe tout dans un test paramétré et on relit le code :

def fizzbuzz(n: int) -> str:
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)
import pytest

from fizzbuzz import fizzbuzz


@pytest.mark.parametrize(
    "n, attendu",
    [(1, "1"), (2, "2"), (3, "Fizz"), (9, "Fizz"), (5, "Buzz"), (15, "FizzBuzz")],
)
def test_fizzbuzz(n, attendu):
    assert fizzbuzz(n) == attendu

Pourquoi c'est intéressant ? Tu réfléchis au comportement voulu avant de coder, tu avances par petits pas sûrs, et tu obtiens gratuitement une suite de tests complète. Pas besoin d'être dogmatique : même sans faire du TDD strict, prends l'habitude d'écrire un test dès que tu trouves un bug (le test reproduit le bug → tu corriges → il ne reviendra jamais).

9. PEP 8 : le code de la route de Python

Tu peux conduire en zigzag sur une route vide, mais dès qu'il y a d'autres conducteurs, il faut des règles communes. PEP 8 est le guide de style officiel de Python : il rend le code de tout le monde homogène, donc facile à lire.

« Le code est lu beaucoup plus souvent qu'il n'est écrit. » — Guido van Rossum, créateur de Python

9.1 Les règles principales

Règle ✅ Bien ❌ À éviter
Indentation : 4 espaces (jamais de tabulations) ····return x tabulation, 2 espaces
Variables et fonctions en snake_case prix_total, calculer_tva() prixTotal, CalculerTVA()
Classes en CapWords (PascalCase) class CompteBancaire: class compte_bancaire:
Constantes en MAJUSCULES TAUX_TVA = 0.2 tauxTva = 0.2
Espaces autour des opérateurs =, +, ==… x = a + b x=a+b
Pas d'espace à l'intérieur des parenthèses print(x) print( x )
Espace après la virgule [1, 2, 3] [1,2,3]
Pas d'espace autour de = pour un argument nommé f(x, debut=0) f(x, debut = 0)
Lignes courtes (79 selon PEP 8, 88 avec ruff/black) lignes de 150 caractères
2 lignes vides autour des fonctions et classes de haut niveau, 1 entre les méthodes tout collé
Imports en haut du fichier, un par ligne, groupés : standard, puis externes, puis les tiens import os
import sys
import os, sys
Comparer à None avec is if x is None: if x == None:
Tester un booléen directement if est_valide: if est_valide == True:
Noms explicites nombre_eleves n, l, tmp2

9.2 Avant / après

# ❌ Avant : ça fonctionne, mais c'est pénible à lire
import os, sys
import math
def CalculAire(Rayon) :
    resultat=math.pi*Rayon**2
    x = 3
    if resultat == None :
        return 0
    return resultat
liste = [1,2,3 ,4]
print( CalculAire(2) )
# ✅ Après
import math


def calculer_aire(rayon):
    return math.pi * rayon**2


nombres = [1, 2, 3, 4]
print(calculer_aire(2))

Personne n'a envie de vérifier tout ça à la main… et c'est là qu'intervient ruff.

10. ruff : le correcteur automatique

ruff est un outil ultra-rapide qui fait deux choses :

  1. linter (ruff check) : il repère les erreurs probables et les écarts de style (import inutilisé, variable jamais utilisée, comparaison à None avec ==…) ;
  2. formateur (ruff format) : il réécrit automatiquement la mise en forme de ton code (espaces, sauts de ligne, guillemets) selon un style standard.

C'est comme un correcteur orthographique (check) doublé d'un outil de mise en page (format).

pip install ruff

10.1 ruff check : trouver les problèmes

Sur le fichier « avant » ci-dessus (appelons-le brouillon.py), avec les règles que nous configurerons en 10.3 :

ruff check brouillon.py
brouillon.py:1:1: E401 [*] Multiple imports on one line
brouillon.py:1:1: I001 [*] Import block is un-sorted or un-formatted
brouillon.py:1:8: F401 [*] `os` imported but unused
brouillon.py:1:12: F401 [*] `sys` imported but unused
brouillon.py:3:5: N802 Function name `CalculAire` should be lowercase
brouillon.py:3:16: N803 Argument name `Rayon` should be lowercase
brouillon.py:5:5: F841 Local variable `x` is assigned to but never used
brouillon.py:6:20: E711 Comparison to `None` should be `cond is None`
Found 8 errors.
[*] 4 fixable with the `--fix` option (2 hidden fixes can be enabled with the `--unsafe-fixes` option).

(Selon ta version de ruff, l'affichage peut être plus détaillé, avec un extrait du code pour chaque erreur ; --output-format concise donne cette forme compacte.)

Chaque ligne indique : fichier:ligne:colonne, un code de règle (F401…) et une explication. Les règles marquées [*] peuvent être corrigées automatiquement :

ruff check --fix brouillon.py

Les autres (noms de fonctions, variable inutile…) demandent ta réflexion : ruff ne renomme pas tes fonctions à ta place.

Familles de règles courantes :

Préfixe Origine Exemples
F Pyflakes import ou variable inutilisés, nom non défini
E / W pycodestyle (PEP 8) lignes trop longues, == None
I isort ordre des imports
N pep8-naming conventions de nommage
UP pyupgrade syntaxe Python moderne
B flake8-bugbear pièges classiques (argument par défaut mutable…)

10.2 ruff format : mettre en forme

ruff format brouillon.py        # reformate le fichier
ruff format .                   # reformate tout le projet
ruff format --check .           # vérifie seulement, sans modifier (utile avant un commit)
ruff format --diff brouillon.py # montre ce qui serait changé

Après ruff check --fix puis ruff format, il ne reste plus qu'à corriger à la main les noms et la variable inutile.

💡 VS Code : installe l'extension officielle Ruff (éditeur : Astral Software). Elle souligne les problèmes pendant que tu tapes. Dans les réglages, active « Format On Save » et choisis Ruff comme formateur par défaut : ton code est formaté à chaque sauvegarde. ✨

10.3 Configurer ruff

Par défaut, ruff n'active qu'une sélection prudente de règles (les familles E et F principales). On le configure dans pyproject.toml :

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]

11. Docstrings : documenter son code

Une docstring est une chaîne entre triples guillemets placée juste sous la ligne def (ou class, ou en tête d'un module). Tu les as rencontrées au chapitre 05. Contrairement à un commentaire #, Python la conserve : help(ma_fonction) l'affiche, et VS Code la montre au survol.

Commentaire ou docstring ?

  • la docstring explique quoi et comment l'utiliser (pour l'utilisateur de la fonction) ;
  • le commentaire # explique pourquoi un passage de code est écrit ainsi (pour celui qui lit l'intérieur).

Pour une fonction simple, une ligne suffit :

def fahrenheit_vers_celsius(fahrenheit: float) -> float:
    """Convertit une température de degrés Fahrenheit en degrés Celsius."""
    return (fahrenheit - 32) * 5 / 9

Pour une fonction plus riche, on détaille (ici au style « Google », très lisible) :

ZERO_ABSOLU_C = -273.15


def celsius_vers_fahrenheit(celsius: float) -> float:
    """Convertit une température de degrés Celsius en degrés Fahrenheit.

    Args:
        celsius: température en °C, supérieure ou égale au zéro absolu.

    Returns:
        La température en °F.

    Raises:
        ValueError: si la température est inférieure au zéro absolu.

    Example:
        >>> celsius_vers_fahrenheit(100)
        212.0
    """
    if celsius < ZERO_ABSOLU_C:
        raise ValueError(f"{celsius} °C est sous le zéro absolu.")
    return celsius * 9 / 5 + 32

Conventions (PEP 257) : triples guillemets doubles """, première ligne = résumé court terminé par un point, puis une ligne vide avant les détails.

🎁 Bonus : les doctests. Les lignes >>> ... de l'exemple sont aussi des tests ! pytest --doctest-modules exécute ces exemples et vérifie que le résultat affiché est correct. Ta documentation ne peut ainsi jamais mentir.

12. Structurer un projet

Quand un projet grandit, on range. Voici la structure recommandée aujourd'hui, dite « src layout » :

convertisseur/                  ← dossier du projet (= dépôt Git)
├── .venv/                      ← environnement virtuel (ignoré par Git)
├── .gitignore
├── README.md                   ← présentation, installation, utilisation
├── pyproject.toml              ← métadonnées, dépendances, config des outils
├── src/
│   └── convertisseur/          ← le paquet Python (le code « produit »)
│       ├── __init__.py         ← fait de ce dossier un paquet (peut être presque vide)
│       ├── temperatures.py
│       └── distances.py
└── tests/
    ├── test_temperatures.py
    └── test_distances.py

Pourquoi séparer src/ et tests/ ? Le code du produit et le code qui le vérifie n'ont pas le même rôle. Les tests ne sont pas livrés aux utilisateurs, et ce rangement évite que les tests importent « par accident » des fichiers qui traînent.

12.1 Le pyproject.toml complet

Tu as vu pyproject.toml au chapitre 12. Voici une version complète pour ce projet, qui configure aussi pytest et ruff :

[project]
name = "convertisseur"
version = "0.1.0"
description = "Conversions d'unités simples"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[project.optional-dependencies]
dev = ["pytest", "ruff"]

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

[tool.ruff]
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]
  • testpaths = ["tests"] : pytest cherche les tests dans tests/ ;
  • pythonpath = ["src"] : pytest ajoute src/ aux chemins d'import, ce qui permet d'écrire from convertisseur.temperatures import ... dans les tests ;
  • la section [build-system] permet d'installer ton propre projet dans le venv en mode « éditable » (les modifications du code sont prises en compte immédiatement) :
pip install -e ".[dev]"     # installe le projet + pytest + ruff

Après cette commande, ton paquet convertisseur est importable de n'importe où dans le venv, comme une vraie bibliothèque.

12.2 Un test dans cette structure

tests/test_temperatures.py :

import pytest

from convertisseur.temperatures import (
    celsius_vers_fahrenheit,
    fahrenheit_vers_celsius,
)


@pytest.mark.parametrize(
    "celsius, fahrenheit",
    [(0, 32), (100, 212), (-40, -40), (37, 98.6)],
)
def test_celsius_vers_fahrenheit(celsius, fahrenheit):
    assert celsius_vers_fahrenheit(celsius) == pytest.approx(fahrenheit)


@pytest.mark.parametrize("valeur", [-50, 0, 20.5, 100])
def test_aller_retour(valeur):
    assert fahrenheit_vers_celsius(celsius_vers_fahrenheit(valeur)) == pytest.approx(
        valeur
    )


def test_sous_zero_absolu():
    with pytest.raises(ValueError, match="zéro absolu"):
        celsius_vers_fahrenheit(-300)

Le test test_aller_retour est une jolie idée : convertir dans un sens puis dans l'autre doit redonner la valeur de départ. On appelle ça un test de propriété.

12.3 La routine avant chaque commit

ruff check --fix .
ruff format .
pytest
git add .
git commit -m "..."

Si les trois premières commandes sont vertes, tu peux committer l'esprit tranquille. Les équipes professionnelles automatisent exactement ça sur GitHub (avec GitHub Actions, de l'intégration continue) : chaque push lance les tests sur un serveur.

💡 Couverture de code : le plugin pytest-cov (pip install pytest-cov, puis pytest --cov=src) indique quel pourcentage de ton code est exécuté par les tests. Pratique pour repérer les fonctions oubliées, mais 100 % de couverture ne garantit pas l'absence de bug !


Pièges fréquents

1. Un test qui ne commence pas par test_

Symptôme : pytest affiche collected 0 items ou ignore certains tests, alors que le fichier en contient.

Correction : le fichier doit s'appeler test_xxx.py et chaque fonction de test test_xxx. verifier_moyenne() ou Test_moyenne.py… ne seront pas trouvés. Utilise pytest -v pour voir précisément quels tests sont collectés.

2. ModuleNotFoundError dans les tests

Symptôme : ModuleNotFoundError: No module named 'calculs' (ou le nom de ton paquet) au lancement de pytest.

Correction : lance pytest depuis la racine du projet. Avec une structure src/, ajoute pythonpath = ["src"] dans la section [tool.pytest.ini_options] du pyproject.toml, ou installe le projet avec pip install -e .. Vérifie aussi que le venv est activé et que pytest y est installé (python -m pytest évite d'utiliser un autre pytest).

3. Nommer un fichier comme un module existant

Symptôme : ton fichier pytest.py, random.py ou test.py provoque des erreurs incompréhensibles (AttributeError: module 'random' has no attribute 'randint', module 'pytest' has no attribute 'raises').

Correction : ton fichier masque le vrai module. Renomme-le (mon_jeu.py, test_jeu.py) et supprime le dossier __pycache__ qui a pu se créer.

4. Comparer des float avec ==

Symptôme : assert 0.30000000000000004 == 0.3 échoue alors que le calcul est « juste ».

Correction : utilise pytest.approx : assert resultat == pytest.approx(0.3).

5. Code à tester mélangé avec input() et print()

Symptôme : impossible de tester ta fonction : elle demande une saisie au clavier et affiche le résultat au lieu de le renvoyer.

Correction : sépare la logique (fonctions pures qui reçoivent des paramètres et font return) de l'interface (le main() qui fait input/print). Seule la logique a besoin de tests, et elle devient triviale à tester.

6. pytest.raises mal placé

Symptôme : le test échoue avec l'exception elle-même (ValueError: ...) au lieu de la « capturer » ; ou le test passe alors qu'il ne devrait pas.

Correction : l'appel qui doit lever l'exception doit être à l'intérieur du bloc with pytest.raises(...). Et place un seul appel dans ce bloc : si la première ligne lève l'exception, les suivantes ne sont jamais exécutées.

7. Des tests qui dépendent les uns des autres

Symptôme : les tests passent quand on les lance tous, mais l'un d'eux échoue quand on le lance seul (ou dans un autre ordre).

Correction : n'utilise pas de variable globale partagée et modifiée par les tests. Crée les données dans chaque test ou via une fixture (qui fournit un objet neuf à chaque test), et utilise tmp_path pour les fichiers.

8. Ignorer les avertissements de ruff… ou leur obéir aveuglément

Symptôme : des centaines d'avertissements que tu ne lis plus ; ou au contraire du code tordu pour satisfaire une règle.

Correction : lance ruff dès le début du projet, quand il y a peu à corriger. Lis le message et cherche le code de la règle (ruff rule F841 explique la règle en détail). Si une règle ne convient vraiment pas à ton projet, désactive-la explicitement dans pyproject.toml (ignore = ["E501"]) plutôt que de l'ignorer en silence.


Exercices

Exercice 1 ⭐ — Premiers tests

Voici geometrie.py :

def carre(n):
    return n * n


def est_majeur(age):
    return age >= 18


def perimetre_rectangle(longueur, largeur):
    return 2 * (longueur + largeur)

Écris test_geometrie.py avec au moins 7 tests : trois pour carre (positif, négatif, zéro), deux pour est_majeur (dont le cas limite de 18 ans) et deux pour perimetre_rectangle. Lance pytest -v.

Exercice 2 ⭐ — Chasse aux bugs

Ce module bug.py contient deux bugs :

def valeur_absolue(n):
    if n > 0:
        return n
    return n


def maximum(nombres):
    plus_grand = 0
    for n in nombres:
        if n > plus_grand:
            plus_grand = n
    return plus_grand
  1. Écris des tests qui révèlent ces bugs (ils doivent échouer). Pense aux nombres négatifs !
  2. Lis le rapport de pytest, puis corrige le code jusqu'à ce que tout soit vert.
  3. Bonus : que devrait faire maximum([]) ? Décide d'un comportement, teste-le, implémente-le.

Exercice 3 ⭐⭐ — Palindromes paramétrés

Écris une fonction est_palindrome(texte) qui indique si un texte se lit pareil dans les deux sens, en ignorant la casse, les espaces et la ponctuation (« Esope reste ici et se repose » est un palindrome).

Teste-la avec deux tests paramétrés : un pour au moins 5 palindromes (dont la chaîne vide et une seule lettre), un pour au moins 3 non-palindromes.

Exercice 4 ⭐⭐ — Saisie robuste et pytest.raises

Écris une fonction convertir_note(texte) qui transforme une saisie utilisateur en note sur 20 (float) :

  • elle accepte "12", "12.5" et "12,5" (virgule française) ;
  • elle lève une ValueError avec un message contenant « n'est pas un nombre » si le texte n'est pas convertible ;
  • elle lève une ValueError avec un message contenant « entre 0 et 20 » si la note est hors limites.

Écris les tests correspondants avec parametrize et pytest.raises(..., match=...).

Exercice 5 ⭐⭐ — TDD : évaluer un mot de passe

En suivant le cycle rouge → vert → refactor, écris une fonction evaluer_mot_de_passe(mdp) qui renvoie "faible", "moyen" ou "fort" :

  • moins de 8 caractères → "faible" ;
  • on compte 4 critères : une minuscule, une majuscule, un chiffre, un caractère spécial parmi !@#$%&*?-_+= ;
  • 4 critères et au moins 12 caractères → "fort" ;
  • au moins 3 critères → "moyen" ;
  • sinon → "faible".

Écris d'abord un test, vois-le échouer, puis écris le code minimal… et recommence. Garde une trace des étapes (par exemple un commit Git par tour).

Exercice 6 ⭐⭐ — Nettoyage avec ruff

Voici un fichier sale.py qui fonctionne, mais qui est en piteux état :

import random,os
def TirerDe(Faces = 6) :
  return random.randint(1,Faces)
class joueur:
  def __init__(self,Nom):
    self.Nom=Nom
    self.score=0
  def jouer(self):
      l=TirerDe()
      self.score+=l
      if self.score==None: print("bizarre")
      return l
j = joueur( "Ana" )
print(j.jouer())
  1. Lance ruff check --select E,F,W,I,N,UP,B sale.py. Combien d'erreurs ? Classe-les par famille.
  2. Corrige ce qui est automatique (--fix), formate (ruff format), puis corrige le reste à la main.
  3. Le résultat final doit passer ruff check (mêmes règles) et ruff format --check sans erreur, et le programme doit fonctionner comme avant.

Exercice 7 ⭐⭐⭐ — Tester une classe avec des fixtures

Écris une classe CompteBancaire avec :

  • titulaire, solde (0 par défaut) et historique (liste de chaînes comme "+50" ou "-30") ;
  • deposer(montant) et retirer(montant), qui lèvent ValueError si le montant est nul ou négatif ;
  • retirer lève une exception personnalisée SoldeInsuffisantError si le solde ne suffit pas ;
  • virer_vers(autre_compte, montant).

Écris une suite de tests utilisant deux fixtures (compte_alice avec 100 € et compte_bob vide). Vérifie notamment qu'un virement impossible ne modifie aucun des deux comptes.

Exercice 8 ⭐⭐⭐ — Tester du code qui manipule des fichiers

Écris un module fichiers_texte.py avec :

  • compter_lignes_mots(chemin) qui renvoie un tuple (nombre_de_lignes, nombre_de_mots) d'un fichier texte ;
  • ajouter_ligne(chemin, ligne) qui ajoute une ligne à la fin d'un fichier (et le crée s'il n'existe pas).

Teste-les avec la fixture intégrée tmp_path, et une fixture à toi fichier_exemple qui crée un petit fichier de 4 lignes. Teste aussi le fichier vide et le fichier inexistant (FileNotFoundError).


Mini-projet

🧰 Une suite de tests pour la bibliothèque « boite_a_outils »

Tu vas construire une petite bibliothèque de fonctions utilitaires organisée comme un vrai projet, et surtout sa suite de tests complète.

Cahier des charges

Structure (src layout) :

boite-a-outils/
├── .gitignore
├── README.md
├── pyproject.toml
├── src/
│   └── boite_a_outils/
│       ├── __init__.py
│       ├── nombres.py
│       └── texte.py
└── tests/
    ├── test_nombres.py
    └── test_texte.py

Module nombres.py :

  • est_premier(n) → bool (faux pour n < 2) ;
  • pgcd(a, b) → plus grand commun diviseur, toujours positif, pgcd(0, 5) == 5 ;
  • factorielle(n) → n!, ValueError si n < 0 ;
  • moyenne_ponderee(valeurs, coefficients) → ValueError si listes vides, de tailles différentes, ou si la somme des coefficients est nulle.

Module texte.py :

  • retirer_accents(texte) → "Élève" devient "Eleve" ;
  • compter_voyelles(texte) → compte a e i o u y, accentuées ou non ;
  • slugifier(titre) → "Été 2026 : c'est parti !" devient "ete-2026-c-est-parti" (minuscules, sans accents, mots séparés par des tirets) ;
  • tronquer(texte, longueur_max, suite="…") → résultat de longueur <= longueur_max, suite comprise ; ValueError si longueur_max est plus petit que la suite.

Exigences qualité :

  1. chaque fonction a une docstring (avec au moins deux exemples >>> dans le projet) ;
  2. au moins 40 tests (les cas paramétrés comptent), qui utilisent au moins une fixture, parametrize et pytest.raises ;
  3. pytest est entièrement vert ;
  4. ruff check (règles E, F, W, I, N, UP, B) et ruff format --check ne signalent rien ;
  5. le projet est sous Git avec un historique propre.

Étapes suggérées

  1. Crée l'arborescence, le venv, le pyproject.toml (inspire-toi de la section 12.1), puis pip install -e ".[dev]".
  2. Pour chaque fonction : écris d'abord ses tests (cas normal, limites, erreurs), puis la fonction. Lance pytest souvent.
  3. Ajoute les docstrings, puis vérifie les doctests avec pytest --doctest-modules src.
  4. Lance ruff check --fix . et ruff format ., corrige le reste.
  5. Rédige le README (comment installer, comment lancer les tests), committe.

Indice pour retirer_accents : unicodedata.normalize("NFD", texte) décompose « é » en « e » + un accent séparé ; les accents ont la catégorie Unicode "Mn" (unicodedata.category(c)), il suffit de les filtrer.

Pistes d'amélioration

  • Ajoute pytest-cov et vise une couverture de 100 % de src/.
  • Ajoute un fichier tests/conftest.py avec des fixtures partagées.
  • Ajoute un module dates.py (âge à partir d'une date de naissance, jour de la semaine en français…) en TDD.
  • Crée un workflow GitHub Actions qui lance ruff et pytest à chaque push (cherche « GitHub Actions Python » dans la documentation de GitHub).

Corrigés

Corrigé de l'exercice 1
from geometrie import carre, est_majeur, perimetre_rectangle


def test_carre_positif():
    assert carre(3) == 9


def test_carre_negatif():
    assert carre(-4) == 16


def test_carre_zero():
    assert carre(0) == 0


def test_est_majeur_pile_18_ans():
    assert est_majeur(18) is True


def test_est_mineur():
    assert est_majeur(17) is False


def test_perimetre_rectangle():
    assert perimetre_rectangle(5, 3) == 16


def test_perimetre_carre():
    assert perimetre_rectangle(2, 2) == 8
pytest -v
test_geometrie.py::test_carre_positif PASSED
test_geometrie.py::test_carre_negatif PASSED
...
============================== 7 passed in 0.01s ===============================
**À retenir** : le test de 18 ans est le plus important de la série. C'est une **valeur limite** : un développeur distrait aurait pu écrire `age > 18`, et seul ce test l'aurait détecté. `is True` / `is False` est plus strict que `== True` : on vérifie que la fonction renvoie vraiment un booléen.
Corrigé de l'exercice 2 **1. Tests qui révèlent les bugs** (`test_bug.py`) :
import pytest

from bug import maximum, valeur_absolue


def test_valeur_absolue_positif():
    assert valeur_absolue(5) == 5


def test_valeur_absolue_negatif():
    assert valeur_absolue(-5) == 5


def test_valeur_absolue_zero():
    assert valeur_absolue(0) == 0


def test_maximum_positifs():
    assert maximum([3, 8, 1]) == 8


def test_maximum_negatifs():
    assert maximum([-7, -2, -9]) == -2


def test_maximum_liste_vide():
    with pytest.raises(ValueError):
        maximum([])
Avec le code bogué, pytest affiche :
E       assert -5 == 5
E        +  where -5 = valeur_absolue(-5)
...
E       assert 0 == -2
E        +  where 0 = maximum([-7, -2, -9])
Les diagnostics parlent d'eux-mêmes : `valeur_absolue` renvoie le nombre négatif tel quel, et `maximum` renvoie 0, une valeur qui n'est même pas dans la liste ! **2–3. Code corrigé** :
def valeur_absolue(n):
    if n >= 0:
        return n
    return -n


def maximum(nombres):
    if not nombres:
        raise ValueError("maximum() d'une liste vide")
    plus_grand = nombres[0]
    for n in nombres[1:]:
        if n > plus_grand:
            plus_grand = n
    return plus_grand
**Explications** : - `valeur_absolue` : il manquait le `-` dans le second `return`. - `maximum` : partir de `0` est une erreur classique. Si tous les nombres sont négatifs, aucun n'est plus grand que 0. Il faut partir du **premier élément** de la liste. Pour la liste vide, il n'y a pas de maximum ; lever une `ValueError` est le choix cohérent avec la fonction `max()` de Python (qui fait pareil). - Les tests sur les nombres positifs passaient déjà : **sans cas négatifs, les bugs seraient passés inaperçus.**
Corrigé de l'exercice 3 `palindrome.py` :
def est_palindrome(texte: str) -> bool:
    """Indique si texte se lit pareil dans les deux sens.

    On ignore la casse, les espaces et la ponctuation.
    """
    lettres = [c.lower() for c in texte if c.isalnum()]
    return lettres == lettres[::-1]
`test_palindrome.py` :
import pytest

from palindrome import est_palindrome


@pytest.mark.parametrize(
    "texte",
    [
        "kayak",
        "Radar",
        "Esope reste ici et se repose",
        "A man, a plan, a canal: Panama!",
        "",
        "x",
    ],
)
def test_palindromes(texte):
    assert est_palindrome(texte)


@pytest.mark.parametrize("texte", ["python", "Bonjour", "ab"])
def test_non_palindromes(texte):
    assert not est_palindrome(texte)
Résultat : `9 passed`. **Explications** : - La compréhension de liste ([chapitre 06](06-chaines-et-comprehensions.md)) garde uniquement les caractères alphanumériques (`isalnum()`), en minuscules. - `lettres[::-1]` est la liste à l'envers (slicing avec un pas de -1). - Avec un seul paramètre, `parametrize` accepte une simple liste de valeurs (pas besoin de tuples). - La chaîne vide et la lettre unique sont des **cas limites** : par convention, ce sont des palindromes.
Corrigé de l'exercice 4 `notes.py` :
def convertir_note(texte: str) -> float:
    """Convertit une saisie utilisateur en note sur 20.

    Accepte la virgule comme séparateur décimal ("12,5").

    Raises:
        ValueError: si le texte n'est pas un nombre ou si la note
            n'est pas comprise entre 0 et 20.
    """
    try:
        note = float(texte.replace(",", "."))
    except ValueError:
        raise ValueError(f"« {texte} » n'est pas un nombre.") from None
    if not 0 <= note <= 20:
        raise ValueError(f"La note {note} doit être entre 0 et 20.")
    return note
`test_notes.py` :
import pytest

from notes import convertir_note


@pytest.mark.parametrize(
    "saisie, attendu",
    [("12", 12.0), ("12.5", 12.5), ("12,5", 12.5), ("0", 0.0), ("20", 20.0)],
)
def test_conversion_valide(saisie, attendu):
    assert convertir_note(saisie) == attendu


@pytest.mark.parametrize("saisie", ["douze", "", "12/20"])
def test_pas_un_nombre(saisie):
    with pytest.raises(ValueError, match="n'est pas un nombre"):
        convertir_note(saisie)


@pytest.mark.parametrize("saisie", ["-1", "20.5", "100"])
def test_hors_limites(saisie):
    with pytest.raises(ValueError, match="entre 0 et 20"):
        convertir_note(saisie)
Résultat : `11 passed`. **Explications** : - On attrape la `ValueError` de `float()` pour la relancer avec **notre** message, plus clair pour l'utilisateur. `from None` masque l'exception d'origine dans le traceback ([chapitre 08](08-erreurs-et-debogage.md)). - `0 <= note <= 20` : Python permet d'enchaîner les comparaisons. - `match=` distingue les **deux** sortes d'erreurs : sans lui, un test « hors limites » passerait même si la fonction se trompait de raison. - Les cas `"0"` et `"20"` vérifient que les bornes sont **incluses**.
Corrigé de l'exercice 5 **Déroulé TDD** (une possibilité) : | Tour | Test ajouté (🔴) | Code ajouté (🟢) | |---|---|---| | 1 | `"Ab1!"` → `"faible"` | `return "faible"` (oui, c'est tout !) | | 2 | `"Bonjour2026!Py"` → `"fort"` | compter les 4 critères ; `if nombre == 4 and len >= 12: return "fort"` | | 3 | `"Bonjour123"` → `"moyen"` | `if nombre >= 3: return "moyen"` | | 4 | `"abcdefghijkl"` → `"faible"` | déjà vert : le test confirme le comportement | | 5 | `"Bonjour1!"` → `"moyen"` (4 critères, mais 9 caractères) | déjà vert | | 🔵 | regroupement dans un test paramétré | extraction de la constante `CARACTERES_SPECIAUX` | **Code final** (`mot_de_passe.py`) :
CARACTERES_SPECIAUX = "!@#$%&*?-_+="


def evaluer_mot_de_passe(mot_de_passe: str) -> str:
    """Renvoie "faible", "moyen" ou "fort" selon la robustesse du mot de passe."""
    if len(mot_de_passe) < 8:
        return "faible"
    criteres = [
        any(c.islower() for c in mot_de_passe),
        any(c.isupper() for c in mot_de_passe),
        any(c.isdigit() for c in mot_de_passe),
        any(c in CARACTERES_SPECIAUX for c in mot_de_passe),
    ]
    nombre = sum(criteres)
    if nombre == 4 and len(mot_de_passe) >= 12:
        return "fort"
    if nombre >= 3:
        return "moyen"
    return "faible"
**Tests** (`test_mot_de_passe.py`) :
import pytest

from mot_de_passe import evaluer_mot_de_passe


def test_trop_court_est_faible():
    assert evaluer_mot_de_passe("Ab1!") == "faible"


def test_long_mais_que_des_minuscules_est_faible():
    assert evaluer_mot_de_passe("abcdefghijkl") == "faible"


def test_trois_criteres_est_moyen():
    assert evaluer_mot_de_passe("Bonjour123") == "moyen"


def test_quatre_criteres_mais_court_est_moyen():
    assert evaluer_mot_de_passe("Bonjour1!") == "moyen"


def test_quatre_criteres_et_long_est_fort():
    assert evaluer_mot_de_passe("Bonjour2026!Py") == "fort"


@pytest.mark.parametrize(
    "mot_de_passe, attendu",
    [
        ("", "faible"),
        ("12345678", "faible"),
        ("motdepasse12", "faible"),
        ("MotDePasse12", "moyen"),
        ("M0t-De-Passe-Solide", "fort"),
    ],
)
def test_cas_varies(mot_de_passe, attendu):
    assert evaluer_mot_de_passe(mot_de_passe) == attendu
**Explications** : - `any(...)` renvoie `True` si au moins un caractère satisfait la condition. - `sum(criteres)` compte les `True` (car `True` vaut 1 et `False` vaut 0). - Le tour 1 peut sembler absurde (`return "faible"`), mais c'est le principe : **le minimum pour passer au vert**. Ce sont les tests suivants qui « forcent » le code à devenir général.
Corrigé de l'exercice 6 **1. Diagnostic** :
ruff check --select E,F,W,I,N,UP,B --output-format concise sale.py
sale.py:1:1: E401 [*] Multiple imports on one line
sale.py:1:1: I001 [*] Import block is un-sorted or un-formatted
sale.py:1:15: F401 [*] `os` imported but unused
sale.py:2:5: N802 Function name `TirerDe` should be lowercase
sale.py:2:13: N803 Argument name `Faces` should be lowercase
sale.py:4:7: N801 Class name `joueur` should use CapWords convention
sale.py:5:21: N803 Argument name `Nom` should be lowercase
sale.py:9:7: E741 Ambiguous variable name: `l`
sale.py:11:22: E711 Comparison to `None` should be `cond is None`
sale.py:11:26: E701 Multiple statements on one line (colon)
Found 10 errors.
10 erreurs : 4 de style PEP 8 (`E`), 1 d'ordre des imports (`I`), 1 import inutilisé (`F`), 4 de nommage (`N`). Note que l'indentation à 2 espaces n'est pas signalée par ces règles, mais `ruff format` la corrigera. **2. Correction** : `ruff check --fix` supprime `os` et range l'import ; `ruff format` corrige l'indentation et les espaces. Le reste à la main : - `TirerDe` → `tirer_de`, `Faces` → `faces`, `Nom` → `nom` (snake_case) ; - `joueur` → `Joueur` (CapWords pour une classe) ; - `l` → `resultat` (`l` se confond avec `1` et `I`) ; - la ligne `if self.score == None: print("bizarre")` est inutile (le score ne peut pas être `None`) : on la supprime ; - `j` → `joueur`.
import random


def tirer_de(faces=6):
    return random.randint(1, faces)


class Joueur:
    def __init__(self, nom):
        self.nom = nom
        self.score = 0

    def jouer(self):
        resultat = tirer_de()
        self.score += resultat
        return resultat


joueur = Joueur("Ana")
print(joueur.jouer())
**3. Vérification** :
ruff check --select E,F,W,I,N,UP,B propre.py   # All checks passed!
ruff format --check propre.py                  # 1 file already formatted
python propre.py                               # affiche un nombre entre 1 et 6
Corrigé de l'exercice 7 `compte.py` :
class SoldeInsuffisantError(Exception):
    """Levée quand un retrait dépasse le solde disponible."""


class CompteBancaire:
    def __init__(self, titulaire: str, solde: float = 0.0):
        self.titulaire = titulaire
        self.solde = solde
        self.historique: list[str] = []

    def deposer(self, montant: float) -> None:
        if montant <= 0:
            raise ValueError("Le montant doit être positif.")
        self.solde += montant
        self.historique.append(f"+{montant}")

    def retirer(self, montant: float) -> None:
        if montant <= 0:
            raise ValueError("Le montant doit être positif.")
        if montant > self.solde:
            raise SoldeInsuffisantError(f"Solde insuffisant : {self.solde} €")
        self.solde -= montant
        self.historique.append(f"-{montant}")

    def virer_vers(self, autre: "CompteBancaire", montant: float) -> None:
        self.retirer(montant)
        autre.deposer(montant)
`test_compte.py` :
import pytest

from compte import CompteBancaire, SoldeInsuffisantError


@pytest.fixture
def compte_alice():
    return CompteBancaire("Alice", 100)


@pytest.fixture
def compte_bob():
    return CompteBancaire("Bob")


def test_creation(compte_alice, compte_bob):
    assert compte_alice.titulaire == "Alice"
    assert compte_alice.solde == 100
    assert compte_bob.solde == 0
    assert compte_bob.historique == []


def test_depot(compte_alice):
    compte_alice.deposer(50)
    assert compte_alice.solde == 150
    assert compte_alice.historique == ["+50"]


def test_retrait(compte_alice):
    compte_alice.retirer(30)
    assert compte_alice.solde == 70


def test_retrait_trop_gros(compte_alice):
    with pytest.raises(SoldeInsuffisantError):
        compte_alice.retirer(500)
    assert compte_alice.solde == 100  # le solde n'a pas bougé


@pytest.mark.parametrize("montant", [0, -10])
def test_montant_invalide(compte_alice, montant):
    with pytest.raises(ValueError):
        compte_alice.deposer(montant)
    with pytest.raises(ValueError):
        compte_alice.retirer(montant)


def test_virement(compte_alice, compte_bob):
    compte_alice.virer_vers(compte_bob, 40)
    assert compte_alice.solde == 60
    assert compte_bob.solde == 40


def test_virement_impossible_ne_change_rien(compte_alice, compte_bob):
    with pytest.raises(SoldeInsuffisantError):
        compte_alice.virer_vers(compte_bob, 1000)
    assert compte_alice.solde == 100
    assert compte_bob.solde == 0
Résultat : `8 passed`. **Explications** : - Un test peut recevoir **plusieurs fixtures**, et combiner fixture + `parametrize` (`test_montant_invalide`). - Le nom `SoldeInsuffisantError` suit la convention (règle ruff `N818`) : les exceptions se terminent par `Error`. - `virer_vers` appelle `retirer` **avant** `deposer` : si le retrait échoue, l'exception interrompt la méthode et `deposer` n'est jamais appelée. Le dernier test le prouve. Si on avait écrit l'inverse, Bob aurait reçu de l'argent qu'Alice n'avait pas ! - Après `pytest.raises`, on peut continuer le test (en dehors du `with`) pour vérifier l'état des objets.
Corrigé de l'exercice 8 `fichiers_texte.py` :
from pathlib import Path


def compter_lignes_mots(chemin: Path) -> tuple[int, int]:
    """Renvoie (nombre de lignes, nombre de mots) d'un fichier texte."""
    texte = Path(chemin).read_text(encoding="utf-8")
    return len(texte.splitlines()), len(texte.split())


def ajouter_ligne(chemin: Path, ligne: str) -> None:
    """Ajoute une ligne à la fin du fichier (le crée s'il n'existe pas)."""
    with open(chemin, "a", encoding="utf-8") as fichier:
        fichier.write(ligne + "\n")
`test_fichiers_texte.py` :
import pytest

from fichiers_texte import ajouter_ligne, compter_lignes_mots


@pytest.fixture
def fichier_exemple(tmp_path):
    chemin = tmp_path / "poeme.txt"
    chemin.write_text("Le ciel est bleu\nLa mer est calme\n\nFin\n", encoding="utf-8")
    return chemin


def test_compter(fichier_exemple):
    assert compter_lignes_mots(fichier_exemple) == (4, 9)


def test_fichier_vide(tmp_path):
    chemin = tmp_path / "vide.txt"
    chemin.write_text("", encoding="utf-8")
    assert compter_lignes_mots(chemin) == (0, 0)


def test_fichier_inexistant(tmp_path):
    with pytest.raises(FileNotFoundError):
        compter_lignes_mots(tmp_path / "absent.txt")


def test_ajouter_ligne_cree_le_fichier(tmp_path):
    chemin = tmp_path / "journal.txt"
    ajouter_ligne(chemin, "Bonjour")
    assert chemin.read_text(encoding="utf-8") == "Bonjour\n"


def test_ajouter_ligne_a_la_suite(fichier_exemple):
    ajouter_ligne(fichier_exemple, "Nouvelle ligne")
    assert compter_lignes_mots(fichier_exemple) == (5, 11)
Résultat : `5 passed`. **Explications** : - Une fixture peut **utiliser une autre fixture** : `fichier_exemple` reçoit `tmp_path`. Chaque test a donc son propre fichier, dans son propre dossier temporaire. - Le fichier d'exemple a 4 lignes (dont une vide) et 9 mots : `splitlines()` compte les lignes, `split()` sans argument découpe sur tous les blancs et ignore les lignes vides. - Le mode `"a"` (*append*) ajoute à la fin et crée le fichier s'il n'existe pas ([chapitre 07](07-fichiers-csv-json.md)). - Aucun fichier n'est créé dans ton projet : tout se passe dans un dossier temporaire géré par pytest.
Corrigé du mini-projet **`pyproject.toml`** :
[project]
name = "boite-a-outils"
version = "0.1.0"
description = "Petite bibliothèque de fonctions utilitaires"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[project.optional-dependencies]
dev = ["pytest", "ruff"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

[tool.ruff]
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B"]
(Sans section `[build-system]`, on n'installe pas le projet avec `pip install -e .` : on installe juste les outils avec `pip install pytest ruff`, et `pythonpath = ["src"]` suffit pour que les tests trouvent le paquet.) **`src/boite_a_outils/__init__.py`** :
"""Boîte à outils : fonctions utilitaires sur les nombres et les textes."""
**`src/boite_a_outils/nombres.py`** :
"""Fonctions utilitaires sur les nombres."""


def est_premier(n: int) -> bool:
    """Indique si n est un nombre premier.

    >>> est_premier(7)
    True
    >>> est_premier(8)
    False
    """
    if n < 2:
        return False
    diviseur = 2
    while diviseur * diviseur <= n:
        if n % diviseur == 0:
            return False
        diviseur += 1
    return True


def pgcd(a: int, b: int) -> int:
    """Renvoie le plus grand commun diviseur de a et b (algorithme d'Euclide)."""
    a, b = abs(a), abs(b)
    while b != 0:
        a, b = b, a % b
    return a


def factorielle(n: int) -> int:
    """Renvoie n! = 1 × 2 × … × n.

    Raises:
        ValueError: si n est négatif.
    """
    if n < 0:
        raise ValueError("La factorielle n'est pas définie pour un nombre négatif.")
    resultat = 1
    for i in range(2, n + 1):
        resultat *= i
    return resultat


def moyenne_ponderee(valeurs: list[float], coefficients: list[float]) -> float:
    """Renvoie la moyenne de valeurs pondérée par coefficients.

    Raises:
        ValueError: si les listes sont vides, de tailles différentes,
            ou si la somme des coefficients est nulle.
    """
    if not valeurs or len(valeurs) != len(coefficients):
        raise ValueError("Listes vides ou de tailles différentes.")
    total_coefficients = sum(coefficients)
    if total_coefficients == 0:
        raise ValueError("La somme des coefficients ne peut pas être nulle.")
    somme = sum(v * c for v, c in zip(valeurs, coefficients, strict=True))
    return somme / total_coefficients
**`src/boite_a_outils/texte.py`** :
"""Fonctions utilitaires sur les chaînes de caractères."""

import unicodedata


def compter_voyelles(texte: str) -> int:
    """Compte les voyelles (accentuées ou non, y compris y) dans texte."""
    sans_accents = retirer_accents(texte.lower())
    return sum(1 for lettre in sans_accents if lettre in "aeiouy")


def retirer_accents(texte: str) -> str:
    """Remplace les lettres accentuées par leur équivalent sans accent.

    >>> retirer_accents("Élève à l'école")
    "Eleve a l'ecole"
    """
    decompose = unicodedata.normalize("NFD", texte)
    return "".join(c for c in decompose if unicodedata.category(c) != "Mn")


def slugifier(titre: str) -> str:
    """Transforme un titre en « slug » utilisable dans une URL.

    >>> slugifier("  Bonjour, le Monde ! ")
    'bonjour-le-monde'
    """
    texte = retirer_accents(titre.lower())
    mots = []
    mot_courant = ""
    for caractere in texte:
        if caractere.isalnum():
            mot_courant += caractere
        elif mot_courant:
            mots.append(mot_courant)
            mot_courant = ""
    if mot_courant:
        mots.append(mot_courant)
    return "-".join(mots)


def tronquer(texte: str, longueur_max: int, suite: str = "…") -> str:
    """Coupe texte pour qu'il fasse au plus longueur_max caractères, suite comprise.

    Raises:
        ValueError: si longueur_max est plus petit que la longueur de suite.
    """
    if longueur_max < len(suite):
        raise ValueError("longueur_max est trop petit.")
    if len(texte) <= longueur_max:
        return texte
    return texte[: longueur_max - len(suite)] + suite
**`tests/test_nombres.py`** :
import pytest

from boite_a_outils.nombres import est_premier, factorielle, moyenne_ponderee, pgcd

# --- est_premier -----------------------------------------------------------


@pytest.mark.parametrize("n", [2, 3, 5, 7, 11, 13, 97, 7919])
def test_est_premier_vrai(n):
    assert est_premier(n) is True


@pytest.mark.parametrize("n", [-7, 0, 1, 4, 9, 15, 100, 7917])
def test_est_premier_faux(n):
    assert est_premier(n) is False


def test_premiers_jusqua_30():
    attendus = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
    assert [n for n in range(31) if est_premier(n)] == attendus


# --- pgcd ------------------------------------------------------------------


@pytest.mark.parametrize(
    "a, b, attendu",
    [
        (12, 18, 6),
        (18, 12, 6),  # l'ordre ne compte pas
        (7, 13, 1),  # nombres premiers entre eux
        (0, 5, 5),
        (5, 0, 5),
        (-12, 18, 6),  # le PGCD est toujours positif
        (42, 42, 42),
    ],
)
def test_pgcd(a, b, attendu):
    assert pgcd(a, b) == attendu


# --- factorielle -----------------------------------------------------------


@pytest.mark.parametrize("n, attendu", [(0, 1), (1, 1), (5, 120), (10, 3628800)])
def test_factorielle(n, attendu):
    assert factorielle(n) == attendu


def test_factorielle_negative():
    with pytest.raises(ValueError, match="négatif"):
        factorielle(-1)


# --- moyenne_ponderee ------------------------------------------------------


def test_moyenne_ponderee_coefficients_egaux():
    assert moyenne_ponderee([10, 20], [1, 1]) == 15


def test_moyenne_ponderee_bulletin():
    # maths 16 (coef 4), français 12 (coef 3), sport 18 (coef 1)
    resultat = moyenne_ponderee([16, 12, 18], [4, 3, 1])
    assert resultat == pytest.approx(14.75)


@pytest.mark.parametrize(
    "valeurs, coefficients",
    [
        ([], []),  # vides
        ([10, 12], [1]),  # tailles différentes
        ([10, 12], [0, 0]),  # somme des coefficients nulle
    ],
)
def test_moyenne_ponderee_erreurs(valeurs, coefficients):
    with pytest.raises(ValueError):
        moyenne_ponderee(valeurs, coefficients)
**`tests/test_texte.py`** :
import pytest

from boite_a_outils.texte import (
    compter_voyelles,
    retirer_accents,
    slugifier,
    tronquer,
)


@pytest.fixture
def phrase_accentuee():
    return "L'été à Noël, où ça ?"


# --- retirer_accents -------------------------------------------------------


def test_retirer_accents(phrase_accentuee):
    assert retirer_accents(phrase_accentuee) == "L'ete a Noel, ou ca ?"


def test_retirer_accents_majuscules():
    assert retirer_accents("ÉCOLE") == "ECOLE"


def test_retirer_accents_sans_accent_inchange():
    assert retirer_accents("Python") == "Python"


# --- compter_voyelles ------------------------------------------------------


@pytest.mark.parametrize(
    "texte, attendu",
    [("bonjour", 3), ("Python", 2), ("", 0), ("xkcd", 0), ("Élève", 3)],
)
def test_compter_voyelles(texte, attendu):
    assert compter_voyelles(texte) == attendu


# --- slugifier -------------------------------------------------------------


@pytest.mark.parametrize(
    "titre, attendu",
    [
        ("Bonjour le monde", "bonjour-le-monde"),
        ("  Espaces   multiples  ", "espaces-multiples"),
        ("Été 2026 : c'est parti !", "ete-2026-c-est-parti"),
        ("!!!", ""),
    ],
)
def test_slugifier(titre, attendu):
    assert slugifier(titre) == attendu


def test_slugifier_resultat_sans_espace_ni_majuscule(phrase_accentuee):
    slug = slugifier(phrase_accentuee)
    assert " " not in slug
    assert slug == slug.lower()


# --- tronquer --------------------------------------------------------------


def test_tronquer_texte_court_inchange():
    assert tronquer("Salut", 10) == "Salut"


def test_tronquer_texte_pile_a_la_limite():
    assert tronquer("1234567890", 10) == "1234567890"


def test_tronquer_texte_long():
    resultat = tronquer("Bonjour tout le monde", 10)
    assert resultat == "Bonjour t…"
    assert len(resultat) == 10


def test_tronquer_suite_personnalisee():
    assert tronquer("Bonjour tout le monde", 10, suite="...") == "Bonjour..."


def test_tronquer_longueur_trop_petite():
    with pytest.raises(ValueError):
        tronquer("Bonjour", 2, suite="...")
**Vérifications** :
pytest
tests/test_nombres.py ..................................                 [ 65%]
tests/test_texte.py ..................                                   [100%]

============================== 52 passed in 0.07s ==============================
pytest --doctest-modules src -q    # 3 passed (les exemples >>> des docstrings)
ruff check .                       # All checks passed!
ruff format --check .              # 5 files already formatted
**Points clés de la correction** : - **52 tests** pour 8 fonctions : `parametrize` permet d'être exhaustif sans répétition. - La règle ruff `B905` impose `zip(..., strict=True)` : si les deux listes n'ont pas la même longueur, `zip` lève une erreur au lieu de couper silencieusement. Ici, on a déjà vérifié les longueurs juste avant, mais c'est une sécurité gratuite. - `est_premier` ne teste les diviseurs que jusqu'à la racine carrée de `n` (`diviseur * diviseur <= n`) : c'est bien plus rapide, et les tests sur `7919` (premier) et `7917` (= 3 × 7 × 13 × 29) vérifient que l'optimisation n'a rien cassé. - `retirer_accents` utilise `unicodedata` (bibliothèque standard) : « é » est décomposé en « e » + « ◌́ » (catégorie `Mn`, *Mark, nonspacing*), qu'on filtre. - `slugifier` réutilise `retirer_accents` : on teste chaque brique séparément, puis leur combinaison. - Les commentaires `# --- ... ---` découpent les fichiers de tests en sections lisibles.

Récapitulatif

Points clés à retenir :

  • Un test automatique est une fonction qui appelle ton code et vérifie le résultat avec assert. Il se relance à volonté et te protège des régressions.
  • pytest trouve les fichiers test_*.py et les fonctions test_*. On lance pytest (ou python -m pytest), avec -v, -k, -x au besoin.
  • Les rapports d'échec montrent la ligne et les valeurs comparées : lis-les attentivement.
  • pytest.approx pour les float, pytest.raises (avec match=) pour les exceptions, @pytest.mark.parametrize pour tester de nombreux cas, @pytest.fixture pour préparer des données neuves à chaque test, tmp_path pour les fichiers.
  • Teste les cas limites : vide, zéro, négatif, seuil exact, entrée invalide.
  • TDD : 🔴 test qui échoue → 🟢 code minimal → 🔵 refactor.
  • PEP 8 : 4 espaces, snake_case pour variables et fonctions, CapWords pour les classes, MAJUSCULES pour les constantes, espaces autour des opérateurs, imports en haut.
  • ruff : ruff check --fix (lint) et ruff format (mise en forme), configurés dans pyproject.toml.
  • Docstrings : """Résumé.""" sous chaque fonction, détails Args/Returns/Raises si utile, exemples >>> testables.
  • Structure : src/paquet/, tests/, README.md, pyproject.toml, .gitignore.

Checklist :

  • J'ai installé pytest et ruff dans mon venv.
  • Je nomme mes fichiers et fonctions de test avec le préfixe test_.
  • Je sais lire un rapport d'échec de pytest.
  • Je sais tester une exception avec pytest.raises.
  • J'utilise parametrize plutôt que de copier-coller des tests.
  • Je sais écrire une fixture et utiliser tmp_path.
  • J'ai essayé le cycle TDD rouge → vert → refactor au moins une fois.
  • Mon code respecte PEP 8 : ruff check et ruff format --check ne signalent rien.
  • Mes fonctions ont des docstrings.
  • Je sais organiser un projet avec src/, tests/ et pyproject.toml.
  • Je lance ruff et pytest avant chaque commit.

← Chapitre précédent | Sommaire | Chapitre suivant →