Chapitre 1515 minDébutant
Tester son code Python avec pytest
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire et lancer des tests avec pytest ;
- couvrir plusieurs cas avec un test paramétré ;
- tester une exception et une fonction qui lit un fichier ;
- remplacer une commande système pour tester sans réseau.
3 exercices corrigés
pytest est l'outil de test le plus utilisé en Python : il trouve tout seul les fonctions de test, les exécute et explique précisément pourquoi un test échoue. Un test est une fonction qui appelle ton code avec des données connues et vérifie le résultat avec assert. Ce chapitre installe pytest, écrit des tests simples puis paramétrés, teste les exceptions, utilise des fichiers temporaires et remplace une commande système pour tester sans réseau. La démarche de test (pyramide, jeux d'essai, TDD, recette) est présentée dans le cours Tests.
Installer et lancer pytest
pytest est un paquet de PyPI (version 9.2 en novembre 2026). Installe-le dans l'environnement virtuel du projet :
python -m pip install pytest # ou, dans un projet uv : uv add --dev pytest
Les conventions de pytest : les fichiers de test s'appellent test_*.py, les fonctions de test commencent par test_. Pas de classe à écrire, pas d'import particulier pour vérifier : un simple assert. Le module à tester, outils_reseau.py, reprend des fonctions des chapitres précédents :
"""outils_reseau.py : fonctions testées dans ce chapitre."""
import csv
import subprocess
def hotes(prefixe: int) -> int:
"""Nombre d'adresses d'hôtes d'un réseau IPv4 de ce préfixe (de 0 à 30)."""
if not 0 <= prefixe <= 30:
raise ValueError(f"préfixe invalide : {prefixe}")
return 2 ** (32 - prefixe) - 2
def niveau(taux: float, alerte: float = 80, critique: float = 90) -> str:
"""Renvoie OK, ALERTE ou CRITIQUE selon les deux seuils."""
if taux >= critique:
return "CRITIQUE"
if taux >= alerte:
return "ALERTE"
return "OK"
def lire_inventaire(chemin) -> list[dict]:
"""Lit un inventaire CSV (point-virgule, UTF-8 avec ou sans BOM)."""
with open(chemin, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f, delimiter=";"))
def hote_repond(ip: str) -> bool:
"""Envoie un ping (Linux) et renvoie True si l'hôte répond."""
resultat = subprocess.run(["ping", "-c", "1", "-W", "1", ip], capture_output=True)
return resultat.returncode == 0
Dans le même dossier, le fichier de test :
"""test_outils_reseau.py : les tests de outils_reseau.py, lancés par pytest."""
import subprocess
import pytest
from outils_reseau import hote_repond, hotes, lire_inventaire, niveau
def test_hotes_d_un_slash_24():
assert hotes(24) == 254
@pytest.mark.parametrize("prefixe, attendu", [(26, 62), (28, 14), (30, 2), (16, 65534)])
def test_hotes_selon_le_prefixe(prefixe, attendu):
assert hotes(prefixe) == attendu
def test_hotes_refuse_un_prefixe_invalide():
with pytest.raises(ValueError, match="préfixe invalide"):
hotes(33)
@pytest.mark.parametrize("taux, attendu", [
(79.9, "OK"),
(80, "ALERTE"), # cas limite : le seuil lui-même
(89.9, "ALERTE"),
(90, "CRITIQUE"),
])
def test_niveau_aux_limites(taux, attendu):
assert niveau(taux) == attendu
def test_lire_inventaire_avec_bom(tmp_path):
fichier = tmp_path / "inventaire.csv"
fichier.write_bytes("\ufeffnom;service\nPC-0012;Comptabilité\n".encode("utf-8"))
assert lire_inventaire(fichier) == [{"nom": "PC-0012", "service": "Comptabilité"}]
def test_hote_repond_sans_reseau(monkeypatch):
def faux_run(commande, **options):
code = 0 if commande[-1] == "192.168.10.177" else 1
return subprocess.CompletedProcess(commande, code)
monkeypatch.setattr(subprocess, "run", faux_run)
assert hote_repond("192.168.10.177") is True
assert hote_repond("192.168.10.187") is False
Lance tous les tests depuis le dossier du projet :
python -m pytest -q
............ [100%]
12 passed in 0.01s
Chaque point est un test réussi : 12, car les tests paramétrés comptent une fois par jeu de données. Les options utiles :
| Commande | Effet |
|---|---|
python -m pytest | Lance tous les tests trouvés dans le dossier et ses sous-dossiers |
-q / -v | Affichage réduit / un test par ligne, avec son nom |
-k niveau | Seulement les tests dont le nom contient niveau |
-x | S'arrête au premier échec |
--lf | Relance seulement les tests qui ont échoué la dernière fois |
test_outils_reseau.py::test_hotes_d_un_slash_24 | Un seul test |
Écrire un test avec assert
Un test suit le schéma Préparer, Agir, Vérifier (Arrange, Act, Assert) : préparer les données, appeler la fonction, vérifier le résultat (écrire un bon test unitaire). Le nom du test dit ce qu'il vérifie : quand il échoue dans six mois, son nom seul doit dire ce qui est cassé.
Pour voir un échec, introduisons une erreur classique dans niveau() : if taux > alerte: au lieu de >=.
.......F.... [100%]
=================================== FAILURES ===================================
______________________ test_niveau_aux_limites[80-ALERTE] ______________________
taux = 80, attendu = 'ALERTE'
@pytest.mark.parametrize("taux, attendu", [
(79.9, "OK"),
(80, "ALERTE"), # cas limite : le seuil lui-même
(89.9, "ALERTE"),
(90, "CRITIQUE"),
])
def test_niveau_aux_limites(taux, attendu):
> assert niveau(taux) == attendu
E AssertionError: assert 'OK' == 'ALERTE'
E
E - ALERTE
E + OK
test_outils_reseau.py:30: AssertionError
=========================== short test summary info ============================
FAILED test_outils_reseau.py::test_niveau_aux_limites[80-ALERTE] - AssertionE...
1 failed, 11 passed in 0.01s
pytest montre le test en échec, les valeurs des paramètres (taux = 80), la ligne de l'assert (marquée >) et la comparaison détaillée : la fonction a renvoyé 'OK' alors que le test attendait 'ALERTE'. Ce test est précisément celui du cas limite, le seuil lui-même : les tests de 79.9 et 89.9 passaient, et l'erreur serait restée invisible sans lui.
Paramétrer : un test, plusieurs cas
@pytest.mark.parametrize("prefixe, attendu", [...]) exécute le même test pour chaque jeu de valeurs de la liste. Chaque cas est un test à part, avec son propre nom (test_hotes_selon_le_prefixe[28-14]) : un cas qui échoue n'empêche pas les autres de s'exécuter. C'est le moyen le plus lisible d'écrire un jeu d'essai complet. Pour ne lancer que les quatre cas de test_niveau_aux_limites, filtre par nom avec -k :
python -m pytest -q -k niveau
.... [100%]
4 passed, 8 deselected in 0.00s
Tester une exception
Une fonction qui doit refuser une valeur se teste aussi : with pytest.raises(ValueError, match="préfixe invalide"): réussit si le bloc lève une ValueError dont le message contient le texte donné, et échoue si rien n'est levé. Un test d'exception vérifie que ta fonction dit non quand il le faut, au lieu de renvoyer un résultat absurde (lever une exception).
Fichiers temporaires avec tmp_path
Une fonction qui lit un fichier se teste avec un fichier préparé pour le test, jamais avec le vrai inventaire. Le paramètre tmp_path est une fixture de pytest : il suffit de le déclarer dans la signature du test pour recevoir un dossier temporaire neuf, supprimé plus tard. Le test test_lire_inventaire_avec_bom y écrit un CSV qui commence par une BOM, \ufeff, et vérifie que la fonction la retire bien : le piège du chapitre 7, désormais protégé par un test.
Remplacer une commande avec monkeypatch
hote_repond() lance un vrai ping. Un test qui dépend du réseau est lent, et échoue quand le réseau change : ce n'est plus un test unitaire. La fixture monkeypatch remplace, le temps d'un test, une fonction par une autre. Ici, subprocess.run est remplacé par faux_run, qui renvoie un CompletedProcess avec le code de retour voulu : on vérifie la logique de hote_repond() (code 0 → True, autre code → False) sans envoyer un seul paquet. Le remplacement est annulé automatiquement à la fin du test. Ce faux objet s'appelle une doublure de test (mock, stub, spy).
Dans un projet versionné, les tests se lancent aussi automatiquement à chaque envoi sur GitHub, dans un pipeline d'intégration continue (tests automatisés dans la CI). Le paquet pytest-cov mesure la part du code exécutée par les tests (python -m pytest --cov=outils_reseau).
Exercices
Exercice 1 sur 3 Tester formater_taille Réussi À revoir
Écris les tests paramétrés de la fonction formater_taille() du chapitre 4, rangée dans tailles.py. Choisis six cas, dont les limites autour de 1024 et un cas au-delà de la dernière unité (Tio).
Voir la réponseMasquer la réponse de l'exercice 1
import pytest
from tailles import formater_taille
@pytest.mark.parametrize("octets, attendu", [
(0, "0 o"),
(1023, "1023 o"), # juste sous la limite
(1024, "1.0 Kio"), # la limite elle-même
(1536, "1.5 Kio"),
(5 * 1024 ** 3, "5.0 Gio"),
(2 * 1024 ** 5, "2048.0 Tio"), # au-delà de la dernière unité
])
def test_formater_taille(octets, attendu):
assert formater_taille(octets) == attendu
...... [100%]
6 passed in 0.00s
Les cas 1023 et 1024 encadrent la limite du changement d'unité ; 0 vérifie la valeur la plus petite ; 2 * 1024 ** 5 (2 Pio) vérifie qu'on reste en Tio au lieu de sortir de la liste des unités.
Tu avais trouvé ?
Exercice 2 sur 3 Le login, tests d'abord Réussi À revoir
Chez Sorbier, le login est l'initiale du prénom suivie du nom, en minuscules, sans accent ni caractère spécial (apostrophe, tiret, espace). Écris d'abord les tests de login(prenom, nom) avec Théo Marchal, Éloïse Gaillard, Zoë N'Diaye, Jean-Luc Dupré, Inès Roux et un nom avec les ligatures æ et œ, puis la fonction. Indice : unicodedata.normalize("NFD", texte) sépare chaque lettre accentuée en une lettre et un accent, que l'encodage ASCII peut ensuite ignorer.
Voir la réponseMasquer la réponse de l'exercice 2
import pytest
from comptes import login
@pytest.mark.parametrize("prenom, nom, attendu", [
("Théo", "Marchal", "tmarchal"),
("Éloïse", "Gaillard", "egaillard"),
("Zoë", "N'Diaye", "zndiaye"),
("Jean-Luc", "Dupré", "jdupre"),
("Inès", "Roux", "iroux"),
("Cécile", "Lætitia-Œuvray", "claetitiaoeuvray"),
])
def test_login(prenom, nom, attendu):
assert login(prenom, nom) == attendu
"""comptes.py : calcul des identifiants de connexion."""
import re
import unicodedata
def login(prenom: str, nom: str) -> str:
"""Initiale du prénom + nom, en minuscules, sans accent ni caractère spécial."""
brut = (prenom[:1] + nom).lower().replace("æ", "ae").replace("œ", "oe")
sans_accents = unicodedata.normalize("NFD", brut).encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]", "", sans_accents)
...... [100%]
6 passed in 0.00s
normalize("NFD") décompose é en e suivi de l'accent aigu combinant ; encode("ascii", "ignore") jette tout ce qui n'est pas ASCII, donc les accents ; decode revient au texte. Les ligatures æ et œ ne se décomposent pas : elles disparaîtraient, d'où leur remplacement préalable. La regex retire enfin apostrophes et tirets. Écrire les tests avant le code, c'est le TDD (le TDD avec un kata corrigé) ; la règle de calcul est la même que celle du script PowerShell de création de comptes (scripts Active Directory), ce qui garantit des logins identiques quel que soit l'outil.
Tu avais trouvé ?
Exercice 3 sur 3 Quel test manque ? Réussi À revoir
Une collègue a écrit un seul test pour niveau() : assert niveau(95) == "CRITIQUE". Il passe. Cite trois cas qu'elle a oubliés et le défaut que chacun pourrait révéler.
Voir la réponseMasquer la réponse de l'exercice 3
niveau(90), le seuil critique lui-même : un>au lieu de>=renverraitALERTE.niveau(80)etniveau(79.9), autour du seuil d'alerte : l'erreur de ce chapitre, invisible avec 95.- Des seuils personnalisés,
niveau(85, alerte=70, critique=85): un code qui ignorerait ses paramètres et utiliserait toujours 80 et 90 passerait le test unique.
On peut ajouter un taux de 0 et de 100, et décider (puis tester) ce que doit faire la fonction avec un taux négatif ou supérieur à 100 : refuser par une ValueError, par exemple.
Tu avais trouvé ?