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.
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 :
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(ouquelquechose_test.py) ; - les fonctions de test commencent par
test_; - (si tu regroupes des tests dans une classe, elle commence par
Testet 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 :
============================= 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
pytestn'est pas reconnue (fréquent sous Windows), utilisepython -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 :
=================================== 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 :
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) :
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.pydans le dossier des tests : pytest le charge automatiquement, sansimport.
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 :
- Arrange (préparer) : créer les données nécessaires (souvent via une fixture) ;
- Act (agir) : appeler la fonction testée ;
- 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
assertsont 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
randomest 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 avecrandom.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 :
pytest → échec (ImportError : la fonction n'existe pas encore). C'est normal et voulu.
Tour 1 — 🟢 Le minimum pour passer :
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 osimport 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 :
- linter (
ruff check) : il repère les erreurs probables et les écarts de style (import inutilisé, variable jamais utilisée, comparaison àNoneavec==…) ; - 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).
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 :
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 :
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-modulesexé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 danstests/;pythonpath = ["src"]: pytest ajoutesrc/aux chemins d'import, ce qui permet d'écrirefrom 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) :
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¶
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, puispytest --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
- Écris des tests qui révèlent ces bugs (ils doivent échouer). Pense aux nombres négatifs !
- Lis le rapport de pytest, puis corrige le code jusqu'à ce que tout soit vert.
- 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
ValueErroravec un message contenant « n'est pas un nombre » si le texte n'est pas convertible ; - elle lève une
ValueErroravec 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())
- Lance
ruff check --select E,F,W,I,N,UP,B sale.py. Combien d'erreurs ? Classe-les par famille. - Corrige ce qui est automatique (
--fix), formate (ruff format), puis corrige le reste à la main. - Le résultat final doit passer
ruff check(mêmes règles) etruff format --checksans 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) ethistorique(liste de chaînes comme"+50"ou"-30") ;deposer(montant)etretirer(montant), qui lèventValueErrorsi le montant est nul ou négatif ;retirerlève une exception personnaliséeSoldeInsuffisantErrorsi 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!,ValueErrorsin < 0;moyenne_ponderee(valeurs, coefficients)→ValueErrorsi 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)→ comptea 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 ;ValueErrorsilongueur_maxest plus petit que la suite.
Exigences qualité :
- chaque fonction a une docstring (avec au moins deux exemples
>>>dans le projet) ; - au moins 40 tests (les cas paramétrés comptent), qui utilisent au moins une fixture,
parametrizeetpytest.raises; pytestest entièrement vert ;ruff check(règlesE, F, W, I, N, UP, B) etruff format --checkne signalent rien ;- le projet est sous Git avec un historique propre.
Étapes suggérées¶
- Crée l'arborescence, le venv, le
pyproject.toml(inspire-toi de la section 12.1), puispip install -e ".[dev]". - Pour chaque fonction : écris d'abord ses tests (cas normal, limites, erreurs), puis la fonction. Lance
pytestsouvent. - Ajoute les docstrings, puis vérifie les doctests avec
pytest --doctest-modules src. - Lance
ruff check --fix .etruff format ., corrige le reste. - 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-covet vise une couverture de 100 % desrc/. - Ajoute un fichier
tests/conftest.pyavec 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
ruffetpytestà chaquepush(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
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([])
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]
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)
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
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)
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"
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
Corrigé de l'exercice 6
**1. Diagnostic** :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.
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)
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
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")
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)
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"]
"""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
"""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
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)
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="...")
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_*.pyet les fonctionstest_*. On lancepytest(oupython -m pytest), avec-v,-k,-xau besoin. - Les rapports d'échec montrent la ligne et les valeurs comparées : lis-les attentivement.
pytest.approxpour lesfloat,pytest.raises(avecmatch=) pour les exceptions,@pytest.mark.parametrizepour tester de nombreux cas,@pytest.fixturepour préparer des données neuves à chaque test,tmp_pathpour 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_casepour variables et fonctions,CapWordspour les classes,MAJUSCULESpour les constantes, espaces autour des opérateurs, imports en haut. - ruff :
ruff check --fix(lint) etruff format(mise en forme), configurés danspyproject.toml. - Docstrings :
"""Résumé."""sous chaque fonction, détailsArgs/Returns/Raisessi 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
parametrizeplutô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 checketruff format --checkne signalent rien. - Mes fonctions ont des docstrings.
- Je sais organiser un projet avec
src/,tests/etpyproject.toml. - Je lance
ruffetpytestavant chaque commit.