47  Tests unitaires : unittest et pytest

Comment garantir que votre code marche ? Et surtout : comment garantir qu’il continue à marcher après chaque modification ? La réponse professionnelle : les tests unitaires. Python offre deux outils : unittest (bibliothèque standard) et pytest (tierce, dominant dans la pratique moderne). Les tests sont attendus du niveau Expert et constituent un skill pro essentiel.

47.1 Pourquoi tester ?

Sans tests :

  • Chaque modification peut casser du code existant sans qu’on le sache.
  • Les bugs découverts en production coûtent 100 à 1000× plus cher qu’en développement.
  • On devient prudent avec son propre code, on évite de le modifier — il « rouille ».

Avec des tests :

  • Une modification qui casse quelque chose est détectée immédiatement.
  • On refactorise sans peur.
  • La suite de tests devient une documentation vivante du comportement attendu.
  • Les bugs trouvés deviennent des tests : on ne les reproduit plus.

47.2 Terminologie

  • Test unitaire : teste une unité (une fonction, une méthode) de façon isolée.
  • Test d’intégration : teste plusieurs composants ensemble.
  • Test de bout en bout (E2E) : teste l’application complète.

Ce chapitre couvre les tests unitaires.

47.3 Tester à la main vs tester avec un framework

À la main (avec assert)

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


# Test manuel
assert additionner(2, 3) == 5
assert additionner(0, 0) == 0
assert additionner(-1, 1) == 0

print("Tous les tests OK !")
Tous les tests OK !

C’est un début, mais limité :

  • Dès qu’un assert échoue, tout s’arrête.
  • Pas de rapport détaillé.
  • Difficile de tester les exceptions.
  • Pas de découverte automatique des tests.

Les frameworks résolvent tout ça.

47.4 unittest — le framework standard

unittest est dans la bibliothèque standard. Inspiré de JUnit (Java), il utilise une approche orientée objet.

Structure de base

import unittest


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


class TestAdditionner(unittest.TestCase):
    def test_positifs(self):
        self.assertEqual(additionner(2, 3), 5)

    def test_negatifs(self):
        self.assertEqual(additionner(-2, -3), -5)

    def test_zero(self):
        self.assertEqual(additionner(0, 5), 5)


# Lancement dans un script : unittest.main()
# Dans un notebook, on simule :
suite = unittest.TestLoader().loadTestsFromTestCase(TestAdditionner)
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
<unittest.runner.TextTestResult run=3 errors=0 failures=0>

Anatomie

  • Une classe qui hérite de unittest.TestCase.
  • Chaque méthode commence par test_ — c’est le framework qui les détecte.
  • On utilise des méthodes self.assert* pour vérifier.

Les principales assertions

Méthode Vérifie
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b
assertIsNone(x) x is None
assertIn(a, b) a in b
assertIsInstance(x, cls) isinstance(x, cls)
assertRaises(exc) Lève l’exception attendue
assertAlmostEqual(a, b) Égalité approchée (flottants)

Tester une exception

import unittest


def diviser(a, b):
    if b == 0:
        raise ZeroDivisionError("Division par zéro")
    return a / b


class TestDiviser(unittest.TestCase):
    def test_division_normale(self):
        self.assertEqual(diviser(10, 2), 5)

    def test_division_par_zero(self):
        with self.assertRaises(ZeroDivisionError):
            diviser(10, 0)

    def test_message_erreur(self):
        with self.assertRaises(ZeroDivisionError) as ctx:
            diviser(10, 0)
        self.assertIn("Division par zéro", str(ctx.exception))


suite = unittest.TestLoader().loadTestsFromTestCase(TestDiviser)
unittest.TextTestRunner(verbosity=2).run(suite)
<unittest.runner.TextTestResult run=3 errors=0 failures=0>

setUp et tearDown

Méthodes appelées avant/après chaque test — pour préparer un état et nettoyer.

import unittest


class CompteBancaire:
    def __init__(self, solde=0):
        self.solde = solde

    def deposer(self, montant):
        self.solde += montant

    def retirer(self, montant):
        if montant > self.solde:
            raise ValueError("Fonds insuffisants")
        self.solde -= montant


class TestCompteBancaire(unittest.TestCase):
    def setUp(self):
        """Appelé AVANT chaque test."""
        self.compte = CompteBancaire(100)

    def test_depot(self):
        self.compte.deposer(50)
        self.assertEqual(self.compte.solde, 150)

    def test_retrait(self):
        self.compte.retirer(30)
        self.assertEqual(self.compte.solde, 70)

    def test_retrait_excessif(self):
        with self.assertRaises(ValueError):
            self.compte.retirer(200)


suite = unittest.TestLoader().loadTestsFromTestCase(TestCompteBancaire)
unittest.TextTestRunner(verbosity=2).run(suite)
<unittest.runner.TextTestResult run=3 errors=0 failures=0>

setUp est rejoué avant chaque test : on a toujours un compte neuf avec 100 €. Impossible qu’un test en pollue un autre.

47.5 pytest — le framework moderne

pytest est une bibliothèque tierce (pip install pytest) qui est devenue le standard de facto dans le monde Python. Plus concis, plus puissant, plus Pythonique que unittest.

Syntaxe minimaliste

# fichier : test_calcul.py

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


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


def test_negatifs():
    assert additionner(-2, -3) == -5

Lancer les tests :

pytest                         # exécute tous les test_*.py du dossier
pytest -v                      # verbeux
pytest test_calcul.py          # un fichier précis
pytest test_calcul.py::test_positifs   # un test précis

Avantages de pytest

  • Juste des fonctions avec assert — pas de classe à écrire.
  • Messages d’erreur détaillés qui montrent les valeurs.
  • Pas d’assertEqual etc. — le bon vieux assert suffit.
  • Fixtures (voir plus bas) plus puissantes que setUp.
  • Plugins nombreux (coverage, parallelisme, etc.).

Tester les exceptions

import pytest

def diviser(a, b):
    if b == 0:
        raise ZeroDivisionError("Division par zéro")
    return a / b


def test_division_par_zero():
    with pytest.raises(ZeroDivisionError):
        diviser(10, 0)


def test_message_exception():
    with pytest.raises(ZeroDivisionError, match="Division par zéro"):
        diviser(10, 0)

Le match= vérifie en plus que le message contient le texte donné (regex).

Fixtures

Une fixture est une fonction qui prépare un contexte pour les tests. On la déclare avec @pytest.fixture.

import pytest


class CompteBancaire:
    def __init__(self, solde=0):
        self.solde = solde

    def deposer(self, montant):
        self.solde += montant


@pytest.fixture
def compte():
    return CompteBancaire(100)


def test_depot(compte):
    compte.deposer(50)
    assert compte.solde == 150


def test_solde_initial(compte):
    assert compte.solde == 100

pytest injecte la fixture dans chaque test qui en déclare le nom en paramètre. Magique et puissant.

Paramétriser les tests

Pour tester plusieurs cas avec la même logique :

import pytest

def carre(x):
    return x ** 2


@pytest.mark.parametrize("entree, attendu", [
    (0, 0),
    (1, 1),
    (2, 4),
    (3, 9),
    (-4, 16),
])
def test_carre(entree, attendu):
    assert carre(entree) == attendu

pytest lance le test 5 fois, avec les paires (entrée, attendu). Rapport détaillé cas par cas.

47.6 Exemple complet

Imaginons une fonction calculer_moyenne.

# fichier : moyenne.py

def calculer_moyenne(notes):
    """Renvoie la moyenne, ou None si la liste est vide."""
    if not notes:
        return None
    return sum(notes) / len(notes)

Tests avec unittest

# fichier : test_moyenne_unittest.py

import unittest
from moyenne import calculer_moyenne


class TestCalculerMoyenne(unittest.TestCase):
    def test_liste_normale(self):
        self.assertEqual(calculer_moyenne([10, 20, 30]), 20)

    def test_liste_vide(self):
        self.assertIsNone(calculer_moyenne([]))

    def test_une_seule_valeur(self):
        self.assertEqual(calculer_moyenne([42]), 42)

    def test_flottants(self):
        self.assertAlmostEqual(calculer_moyenne([1.1, 2.2, 3.3]), 2.2)


if __name__ == "__main__":
    unittest.main()

Tests avec pytest

# fichier : test_moyenne_pytest.py

import pytest
from moyenne import calculer_moyenne


def test_liste_normale():
    assert calculer_moyenne([10, 20, 30]) == 20


def test_liste_vide():
    assert calculer_moyenne([]) is None


def test_une_seule_valeur():
    assert calculer_moyenne([42]) == 42


@pytest.mark.parametrize("notes, attendu", [
    ([1, 2, 3], 2),
    ([0, 0, 0], 0),
    ([10], 10),
    ([100, 200], 150),
])
def test_varies(notes, attendu):
    assert calculer_moyenne(notes) == attendu

Même logique, beaucoup moins de code. C’est pourquoi pytest domine aujourd’hui.

47.7 Tester dans ce notebook

Pour tester un peu de code pytest en environnement Quarto, voici une démo minimale :

# Simule des tests sans framework
def calculer_moyenne(notes):
    if not notes:
        return None
    return sum(notes) / len(notes)


def lancer_tests():
    """Mini-runner de test maison."""
    tests = [
        ("liste normale",     lambda: calculer_moyenne([10, 20, 30]) == 20),
        ("liste vide",        lambda: calculer_moyenne([]) is None),
        ("une valeur",        lambda: calculer_moyenne([42]) == 42),
        ("deux valeurs",      lambda: calculer_moyenne([10, 20]) == 15),
    ]

    ok = 0
    for nom, test in tests:
        try:
            if test():
                print(f"  ✅ {nom}")
                ok += 1
            else:
                print(f"  ❌ {nom}")
        except Exception as e:
            print(f"  💥 {nom} : {e}")

    print(f"\n{ok}/{len(tests)} tests réussis.")


lancer_tests()
  ✅ liste normale
  ✅ liste vide
  ✅ une valeur
  ✅ deux valeurs

4/4 tests réussis.

En vrai projet, pytest ferait tout ça mille fois mieux. Mais ça vous donne l’idée.

47.8 Couverture de tests (coverage)

La couverture mesure quelle proportion du code est exécutée par les tests.

pip install coverage
coverage run -m pytest
coverage report
coverage html          # rapport HTML joli

Exemple de rapport :

Name              Stmts   Miss  Cover
-------------------------------------
moyenne.py            3      0   100%
calcul.py            25      5    80%
utils.py             12      3    75%
Viser 80-90 % de couverture, pas 100 %
  • < 50 % : vos tests sont insuffisants.
  • 80-90 % : zone confortable, les cas critiques sont couverts.
  • 100 % : souvent irréaliste et pas toujours utile (code défensif, cas d’erreurs improbables…).

Ne faites pas de la couverture un but en soi — un test qui exécute du code sans vraiment le vérifier ne sert à rien.

47.9 Tests et doctest

Une autre forme de tests : les exemples dans les docstrings sont automatiquement exécutables.

def carre(x):
    """Renvoie le carré d'un nombre.

    >>> carre(2)
    4
    >>> carre(0)
    0
    >>> carre(-3)
    9
    """
    return x ** 2


# Lancer les doctests
import doctest
resultats = doctest.testmod(verbose=False)
print(f"{resultats.attempted} tests, {resultats.failed} échoués")
3 tests, 0 échoués

doctest lit les >>> dans les docstrings, exécute les expressions, et vérifie que la sortie correspond. Documentation testable — élégant.

Limitations : pas adapté aux cas complexes. Pour de vrais tests unitaires, pytest.

47.10 TDD — Test-Driven Development

Le TDD (développement dirigé par les tests) est une pratique où on écrit les tests AVANT le code. Cycle en 3 étapes :

  1. Red : écrire un test qui échoue (la fonction n’existe pas encore).
  2. Green : écrire le minimum de code pour que le test passe.
  3. Refactor : améliorer le code en gardant les tests au vert.

Avantages :

  • Impossible d’oublier de tester.
  • La conception de l’API vient naturellement (on teste la façon dont on veut utiliser la fonction).
  • Confidence pour refactoriser ensuite.

Difficultés :

  • Discipline.
  • Nécessite de savoir tester.
  • Pas toujours pratique (prototypage, R&D).

C’est une pratique à connaître, à essayer, mais pas un dogme.

47.11 Bonnes pratiques de test

1. Un test teste une chose

# ❌ Test fourre-tout
def test_compte():
    compte = CompteBancaire(100)
    compte.deposer(50)
    assert compte.solde == 150
    compte.retirer(20)
    assert compte.solde == 130
    with pytest.raises(ValueError):
        compte.retirer(1000)

# ✅ Trois tests distincts
def test_depot():
    compte = CompteBancaire(100)
    compte.deposer(50)
    assert compte.solde == 150

def test_retrait():
    compte = CompteBancaire(100)
    compte.retirer(20)
    assert compte.solde == 80

def test_retrait_excessif():
    compte = CompteBancaire(100)
    with pytest.raises(ValueError):
        compte.retirer(1000)

Si plusieurs tests échouent, on voit lesquels → debug plus facile.

2. Nom explicite du test

Le nom du test doit décrire le cas.

# ❌ Peu clair
def test_1(): ...
def test_panier(): ...

# ✅ Clair
def test_retrait_refuse_si_fonds_insuffisants(): ...
def test_panier_vide_renvoie_total_zero(): ...

3. Pattern AAA : Arrange, Act, Assert

def test_depot_augmente_solde():
    # Arrange : préparer
    compte = CompteBancaire(100)

    # Act : agir
    compte.deposer(50)

    # Assert : vérifier
    assert compte.solde == 150

4. Tests indépendants

Un test ne doit jamais dépendre du résultat d’un autre. Chaque test crée son propre contexte (via setUp ou une fixture).

5. Tester les cas limites

  • Liste vide, liste à un élément.
  • Valeurs négatives, zéro, très grandes.
  • Entrées invalides (types, formats).
  • Exceptions attendues.

6. Tests rapides

Une suite qui prend 5 minutes, personne ne la lance. Visez < 10 secondes pour les tests unitaires. Les gros tests (intégration, E2E) sont séparés.


🧩 Quiz 7.1 — Tests unitaires

Question 1

Dans unittest, une méthode de test doit :

  1. Commencer par test_
  2. Se terminer par _test
  3. Avoir n’importe quel nom
  4. Être nommée test()

a) — le préfixe test_ est détecté automatiquement par unittest et pytest. Les autres méthodes dans la classe ne sont pas lancées comme tests.

Question 2

Quel est l’avantage principal de pytest sur unittest ?

  1. Il est plus rapide
  2. Syntaxe plus concise, juste des fonctions avec assert, rapports détaillés
  3. Il est dans la bibliothèque standard
  4. Il génère le code automatiquement

b) — pytest n’exige pas de classes, utilise le assert natif, et donne des messages d’erreur beaucoup plus informatifs. C’est aussi plus léger en boilerplate.

Question 3

À quoi sert la méthode setUp dans unittest.TestCase ?

  1. Lancer les tests
  2. Créer l’environnement avant chaque test
  3. Nettoyer après les tests
  4. Définir les assertions

b)setUp est appelée avant chaque méthode test_*, ce qui garantit un état frais. tearDown est le symétrique après chaque test.

Question 4

Comment tester qu’une fonction lève une exception en pytest ?

  1. try: fonction(); except: pass
  2. assert fonction().raises(Exception)
  3. with pytest.raises(ValueError): fonction()
  4. @pytest.exception(ValueError)

c) — pattern standard. Le test échoue si l’exception n’est pas levée.

import pytest
with pytest.raises(ValueError):
    int("abc")    # lève bien ValueError → test passe

Question 5

Qu’est-ce qu’une fixture en pytest ?

  1. Une constante de configuration
  2. Une fonction qui prépare un contexte réutilisable pour plusieurs tests
  3. Un plugin
  4. Un type d’exception

b) — une fixture est une fonction décorée par @pytest.fixture qui fournit une valeur. Les tests qui l’acceptent en paramètre reçoivent automatiquement cette valeur.

Question 6

Que fait @pytest.mark.parametrize("entree, attendu", [(2, 4), (3, 9)]) ?

  1. Crée 1 test avec 2 assertions
  2. Crée 2 tests, un par tuple de paramètres
  3. Ignore les tests
  4. Une erreur

b) — pytest multiplie le test pour chaque jeu de paramètres. Rapport détaillé cas par cas.

Question 7

Quelle couverture de tests viser en général ?

  1. 100 % obligatoirement
  2. 50 % suffisent
  3. 80-90 % avec focus sur les cas critiques
  4. Peu importe

c) — 80-90 % est un objectif réaliste. Tendre vers 100 % mène souvent à tester du code défensif inutile. La qualité des tests compte plus que le pourcentage.

Question 8

Dans le pattern AAA, que signifient les trois lettres ?

  1. Assert, Act, Arrange
  2. Arrange, Act, Assert
  3. Ask, Answer, Assert
  4. Aucun sens standard

b) Arrange, Act, Assert — préparer le contexte, exécuter l’action, vérifier le résultat. Structure claire pour tout test.


✏️ Exercice 7.1 — Tester une fonction simple

Écrivez des tests unittest pour la fonction suivante, couvrant les cas normaux et limites.

def est_premier(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

Écrire au moins 6 tests.

import unittest

def est_premier(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True


class TestEstPremier(unittest.TestCase):
    def test_2_est_premier(self):
        self.assertTrue(est_premier(2))

    def test_3_est_premier(self):
        self.assertTrue(est_premier(3))

    def test_17_est_premier(self):
        self.assertTrue(est_premier(17))

    def test_4_non_premier(self):
        self.assertFalse(est_premier(4))

    def test_1_non_premier(self):
        self.assertFalse(est_premier(1))

    def test_0_non_premier(self):
        self.assertFalse(est_premier(0))

    def test_negatif_non_premier(self):
        self.assertFalse(est_premier(-7))

    def test_grand_premier(self):
        self.assertTrue(est_premier(997))


suite = unittest.TestLoader().loadTestsFromTestCase(TestEstPremier)
unittest.TextTestRunner(verbosity=2).run(suite)
<unittest.runner.TextTestResult run=8 errors=0 failures=0>

Points à retenir

  • Tester des valeurs limites : 0, 1, 2 (premier cas).
  • Tester des valeurs négatives (edge case).
  • Tester des valeurs typiques (4, 17).
  • Tester un gros nombre pour valider que l’algorithme passe à l’échelle.

✏️ Exercice 7.2 — Tester avec setUp

Écrivez des tests pour cette classe Pile (stack LIFO), en utilisant setUp pour créer une pile fraîche pour chaque test.

class Pile:
    def __init__(self):
        self._items = []

    def empiler(self, x):
        self._items.append(x)

    def depiler(self):
        if not self._items:
            raise IndexError("pile vide")
        return self._items.pop()

    def sommet(self):
        if not self._items:
            raise IndexError("pile vide")
        return self._items[-1]

    def __len__(self):
        return len(self._items)

Écrire les tests avec setUp.

import unittest


class Pile:
    def __init__(self):
        self._items = []

    def empiler(self, x):
        self._items.append(x)

    def depiler(self):
        if not self._items:
            raise IndexError("pile vide")
        return self._items.pop()

    def sommet(self):
        if not self._items:
            raise IndexError("pile vide")
        return self._items[-1]

    def __len__(self):
        return len(self._items)


class TestPile(unittest.TestCase):
    def setUp(self):
        self.pile = Pile()

    def test_pile_vide(self):
        self.assertEqual(len(self.pile), 0)

    def test_empiler_augmente_longueur(self):
        self.pile.empiler(1)
        self.assertEqual(len(self.pile), 1)

    def test_depiler_lifo(self):
        self.pile.empiler(1)
        self.pile.empiler(2)
        self.pile.empiler(3)
        self.assertEqual(self.pile.depiler(), 3)
        self.assertEqual(self.pile.depiler(), 2)
        self.assertEqual(self.pile.depiler(), 1)

    def test_sommet_ne_retire_pas(self):
        self.pile.empiler(42)
        self.assertEqual(self.pile.sommet(), 42)
        self.assertEqual(len(self.pile), 1)

    def test_depiler_pile_vide_leve(self):
        with self.assertRaises(IndexError):
            self.pile.depiler()

    def test_sommet_pile_vide_leve(self):
        with self.assertRaises(IndexError):
            self.pile.sommet()


suite = unittest.TestLoader().loadTestsFromTestCase(TestPile)
unittest.TextTestRunner(verbosity=2).run(suite)
<unittest.runner.TextTestResult run=6 errors=0 failures=0>

Points à retenir

  • setUp crée une Pile() fraîche pour chaque test — aucun test ne pollue un autre.
  • Tests ciblés et courts : un comportement par test.
  • Tester le LIFO (last-in-first-out), la non-modification par sommet(), et les exceptions.

✏️ Exercice 7.3 — Tests paramétrés (pytest)

Écrivez des tests pytest paramétrés pour la fonction mention(note) suivante.

def mention(note):
    if note >= 16: return "Très bien"
    if note >= 14: return "Bien"
    if note >= 12: return "Assez bien"
    if note >= 10: return "Passable"
    return "Insuffisant"

Créez des tests paramétrés pour tous les cas et les frontières (10, 12, 14, 16 exactement).

# fichier : test_mention.py
import pytest

def mention(note):
    if note >= 16: return "Très bien"
    if note >= 14: return "Bien"
    if note >= 12: return "Assez bien"
    if note >= 10: return "Passable"
    return "Insuffisant"


@pytest.mark.parametrize("note, attendu", [
    # Cas clairement dans chaque zone
    (18, "Très bien"),
    (15, "Bien"),
    (13, "Assez bien"),
    (11, "Passable"),
    (5,  "Insuffisant"),

    # Frontières exactes
    (16, "Très bien"),
    (14, "Bien"),
    (12, "Assez bien"),
    (10, "Passable"),

    # Juste en dessous des frontières
    (15.99, "Bien"),
    (13.99, "Assez bien"),
    (11.99, "Passable"),
    (9.99,  "Insuffisant"),

    # Cas limites
    (0,    "Insuffisant"),
    (20,   "Très bien"),
])
def test_mention(note, attendu):
    assert mention(note) == attendu

Simulation sans pytest (dans Quarto)

def mention(note):
    if note >= 16: return "Très bien"
    if note >= 14: return "Bien"
    if note >= 12: return "Assez bien"
    if note >= 10: return "Passable"
    return "Insuffisant"


cas = [
    (18, "Très bien"), (16, "Très bien"), (15.99, "Bien"), (15, "Bien"),
    (14, "Bien"), (13.99, "Assez bien"), (12, "Assez bien"),
    (11, "Passable"), (10, "Passable"), (9.99, "Insuffisant"),
    (0, "Insuffisant"), (20, "Très bien"),
]

ok = 0
for note, attendu in cas:
    obtenu = mention(note)
    if obtenu == attendu:
        ok += 1
    else:
        print(f"  ❌ mention({note}) = {obtenu!r}, attendu {attendu!r}")

print(f"{ok}/{len(cas)} tests réussis.")
12/12 tests réussis.

Points à retenir

  • Les frontières exactes sont critiques : c’est là que se cachent les erreurs d’indice.
  • La paramétrisation évite la répétition de def test_... pour chaque cas.
  • Pensez aux cas limites (0, valeurs max).

✏️ Exercice 7.4 — Tests complets d’une classe

Écrivez une classe PanierAchats avec ses tests en TDD (tests d’abord).

Spécifications du PanierAchats :

  • ajouter(article, prix) — ajoute un article.
  • supprimer(article) — enlève un article (lève KeyError si absent).
  • total() — renvoie le total.
  • __len__() — nombre d’articles.
  • Taille initiale : 0. Total initial : 0.

Commencez par écrire les tests, puis la classe.

Les tests d’abord :

import unittest


# On suppose que PanierAchats existe (à écrire ensuite)
class PanierAchats:
    def __init__(self):
        self._articles = {}

    def ajouter(self, article, prix):
        self._articles[article] = prix

    def supprimer(self, article):
        if article not in self._articles:
            raise KeyError(f"{article} n'est pas dans le panier")
        del self._articles[article]

    def total(self):
        return sum(self._articles.values())

    def __len__(self):
        return len(self._articles)


class TestPanierAchats(unittest.TestCase):
    def setUp(self):
        self.panier = PanierAchats()

    # Vide
    def test_panier_initial_vide(self):
        self.assertEqual(len(self.panier), 0)

    def test_total_initial_zero(self):
        self.assertEqual(self.panier.total(), 0)

    # Ajout
    def test_ajouter_augmente_taille(self):
        self.panier.ajouter("pain", 1.20)
        self.assertEqual(len(self.panier), 1)

    def test_ajouter_augmente_total(self):
        self.panier.ajouter("pain", 1.20)
        self.panier.ajouter("lait", 0.95)
        self.assertAlmostEqual(self.panier.total(), 2.15)

    # Suppression
    def test_supprimer_diminue_taille(self):
        self.panier.ajouter("pain", 1.20)
        self.panier.supprimer("pain")
        self.assertEqual(len(self.panier), 0)

    def test_supprimer_absent_leve(self):
        with self.assertRaises(KeyError):
            self.panier.supprimer("fantome")


suite = unittest.TestLoader().loadTestsFromTestCase(TestPanierAchats)
unittest.TextTestRunner(verbosity=2).run(suite)
<unittest.runner.TextTestResult run=6 errors=0 failures=0>

Démarche TDD

  1. Red : écrire le test test_panier_initial_vide — il échoue (pas de classe).
  2. Green : écrire le minimum → class PanierAchats: pass et __len__.
  3. Red : test_ajouter_augmente_taille → échoue.
  4. Green : implémenter ajouter.
  5. Et ainsi de suite.

À chaque étape, on n’écrit que le nécessaire pour passer le test courant, sans anticiper.


À retenir

Points clés du chapitre
  1. Tester = écrire du code qui vérifie que votre code marche.
  2. unittest : bibliothèque standard, style classe + self.assert*.
  3. pytest : standard de facto moderne. Syntaxe def test_...: assert .... Plus concis, messages d’erreur excellents.
  4. setUp (unittest) / fixtures (pytest) : préparer un contexte frais pour chaque test.
  5. assertRaises / pytest.raises pour tester les exceptions.
  6. @pytest.mark.parametrize pour tester plusieurs cas sans dupliquer le code.
  7. Pattern AAA : Arrange, Act, Assert.
  8. Coverage : viser 80-90 % en ciblant les cas critiques.
  9. TDD : tests d’abord, puis le code minimum pour passer.
  10. Un test teste une chose, avec un nom explicite, indépendamment des autres.

← Chapitre précédent : Qualité du codeChapitre suivant : Type hints →