Cours de Python / Écrire des tests avec unittest et vérifier les cas limites

Un test automatisé vérifie de manière répétable un résultat ou une exception pour une entrée définie. Pour repérer une régression, protégez le comportement attendu par l’utilisateur, y compris les cas limites et les échecs, plutôt que de recopier les étapes internes de l’implémentation.

Définir les règles à vérifier

La fonction total de cet exercice reçoit une liste d’entiers positifs ou nuls. Son résultat pour une liste vide vaut zéro ; bool, les chaînes et les valeurs négatives sont refusés. Ces règles déterminent les tests avant de décider comment la fonction est écrite. Un calcul indépendant du réseau, de l’horloge et de fichiers personnels constitue un point de départ facile à reproduire.

Le contrat porte ici sur une liste. Ne généralisez pas automatiquement l’implémentation à tout itérateur : le contrôle avec any peut consommer un parcours à usage unique avant que sum ne le relise. Si un flux de valeurs doit être accepté, il faut définir et tester ce besoin séparément.

Le module standard unittest fournit un lanceur et des méthodes telles que assertEqual et assertRaises pour exprimer les résultats attendus. Les noms de fichiers test_*.py et de méthodes test_* permettent leur découverte. La documentation officielle de unittest décrit l’organisation des tests et des sous-tests.

Enregistrer la fonction et sa suite de tests

Dans un nouveau dossier d’exercice, enregistrez en UTF-8 le fichier complet expense_math.py :

def total(amounts):
    if any(type(x) is not int or x < 0 for x in amounts):
        raise ValueError("invalid amount")
    return sum(amounts)

À côté, enregistrez test_expense_math.py :

import unittest
from expense_math import total

class TotalTests(unittest.TestCase):
    def test_regular(self):
        self.assertEqual(total([3500, 8000, 2500]), 14000)

    def test_empty(self):
        self.assertEqual(total([]), 0)

    def test_invalid(self):
        for values in [[-1], [True], ["8000"]]:
            with self.subTest(values=values):
                with self.assertRaises(ValueError):
                    total(values)

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

Dans un terminal ouvert dans ce dossier avec l’environnement virtuel du projet sélectionné, lancez :

python -m unittest -v

Aucun paquet supplémentaire n’est nécessaire. Si python3 est votre commande d’interpréteur hors environnement virtuel sous macOS ou Ubuntu, utilisez python3 -m unittest -v. Le résumé doit annoncer trois tests et se terminer par OK. La durée et l’ordre exact de certains détails d’affichage peuvent varier.

test_regular attend 14000 à partir des trois montants ; test_empty fixe le résultat de la liste vide. test_invalid reste une seule méthode de test, mais son subTest distingue trois entrées invalides dans les diagnostics. assertRaises exige précisément ValueError : une autre exception ou l’absence d’exception ne satisfait pas cette attente.

Les résultats détaillés de unittest vont normalement sur la sortie d’erreur standard ; ne confondez pas ce canal avec une suite en échec. Vérifiez le résumé et le code de sortie : zéro pour une réussite, non nul pour une erreur ou un test non satisfait. PowerShell expose immédiatement le code par $LASTEXITCODE ; un shell macOS ou Ubuntu par echo $?.

Vérifier que les tests détectent une mauvaise modification

  1. Dans expense_math.py, remplacez temporairement return sum(amounts) par return sum(amounts) + 1. Relancez unittest : test_regular et test_empty doivent échouer, puisque chaque résultat est décalé.
  2. Rétablissez le return initial et relancez pour retrouver les trois tests et OK.
  3. Dans une copie ou après avoir conservé la fonction initiale, retirez le contrôle if avec son raise et gardez seulement le return. Les entrées négatives et True ne sont plus rejetées ; la chaîne peut provoquer TypeError au lieu du ValueError attendu. Le test des entrées invalides doit donc signaler ces écarts.
  4. Restaurez la fonction complète et relancez la suite. Ne laissez pas une mutation volontairement incorrecte comme version finale.

Le résultat attendu vient du contrat, pas d’un second calcul identique à celui du programme. Une suite qui ne détecte jamais une faute introduite dans la règle qu’elle prétend protéger mérite d’être revue. À l’inverse, des tests qui dépendent des noms de variables internes peuvent gêner une réorganisation sans changement de comportement.

Complétez les cas selon les exigences réelles : zéro, plusieurs montants ou type interdit peuvent être utiles, tandis que des exemples sans lien avec le contrat ajoutent surtout de l’entretien. Le cours sur les types et la validation à l’exécution explique pourquoi une annotation ne remplace pas ces contrôles.

Contrôler ensemble un programme CSV et ses fichiers

Une fonction de somme correcte ne garantit pas le bon en-tête CSV, les fichiers produits ou le code de sortie d’un outil complet. Le contrôle suivant utilise le programme entier csv_report.py du projet de rapport CSV avec validation et lignes rejetées. Copiez ce programme dans le même dossier que check_csv.py ; le nom et l’emplacement doivent correspondre exactement. Le test ne crée pas csv_report.py à votre place.

Enregistrez le programme Python complet suivant sous check_csv.py. Depuis ce dossier, lancez python check_csv.py, sans option -O. Les instructions assert de ce petit contrôle sont supprimées par le mode optimisé ; elles ne doivent donc pas être considérées comme une validation obligatoire d’un outil de production.

import subprocess
import sys
import tempfile
from pathlib import Path
program = Path("csv_report.py").resolve()
with tempfile.TemporaryDirectory() as folder:
    work = Path(folder)
    source = work / "expenses-input.csv"
    source.write_text("id,category,amount\ne1,food,8000\ne1,food,8000\n", encoding="utf-8")
    result = subprocess.run([sys.executable, str(program)], cwd=work, capture_output=True, text=True)
    assert result.returncode == 1
    assert "food,8000" in (work / "report.csv").read_text(encoding="utf-8")
    assert "duplicate id" in (work / "rejected.csv").read_text(encoding="utf-8")
print("CSV integration checks passed")

Résultat attendu :

CSV integration checks passed

Le contrôle crée une entrée contenant deux lignes portant le même identifiant e1. Le programme CSV doit accepter la première ligne, refuser le doublon, produire un total food de 8000 et sortir avec le code 1 pour signaler ce rejet. Ici, cette sortie non nulle est attendue par le test : elle ne signifie pas que le contrôle d’intégration a échoué.

Path.resolve fixe le chemin du programme avant de changer le dossier de travail du sous-processus. sys.executable conserve l’interpréteur courant. Les entrées et rapports sont ensuite créés dans le seul dossier temporaire de l’essai, puis supprimés à la sortie du bloc ; vos rapports du dossier de travail restent distincts. La documentation de TemporaryDirectory décrit ce cycle de vie.

Ce contrôle vérifie le code de sortie et deux fragments de contenu. Il ne prouve pas toutes les propriétés possibles du rapport, ni le comportement d’une base de données ou d’un service réseau. Pour étendre la couverture, ajoutez des scénarios indépendants avec leurs propres attentes. Conservez dans vos comptes rendus l’environnement, la commande et la portée réellement vérifiée.