# 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](12-outils-venv-pip-git.md) (savoir créer un venv et installer un paquet), être à l'aise avec les fonctions ([Chapitre 05](05-fonctions.md)), les exceptions ([Chapitre 08](08-erreurs-et-debogage.md)) et les classes ([Chapitre 10](10-poo-classes-objets.md)). > > **🎯 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. ```python assert 2 + 2 == 4 # rien ne se passe : c'est vrai assert 2 + 2 == 5 # AssertionError ``` Un test, c'est donc simplement une fonction qui contient des `assert`. Prenons un fichier `calculs.py` : ```python 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** : ```bash # venv activé : (.venv) doit apparaître dans l'invite pip install pytest ``` #### 3.1 Les conventions de nommage (importantes !) pytest découvre les tests **grâce à leur nom** : - les fichiers de test s'appellent `test_quelquechose.py` (ou `quelquechose_test.py`) ; - les fonctions de test commencent par `test_` ; - (si tu regroupes des tests dans une classe, elle commence par `Test` et n'a pas de `__init__`). Une fonction nommée `verifier_addition()` ne sera **jamais** exécutée par pytest, même si elle est dans un fichier de test. #### 3.2 Notre premier fichier de test À côté de `calculs.py`, crée `test_calculs.py` : ```python 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 : ```bash pytest ``` ```text ============================= test session starts ============================== collected 4 items test_calculs.py .... [100%] ============================== 4 passed in 0.01s =============================== ``` Chaque point `.` est un test réussi. Options utiles : ```bash pytest -v # verbeux : affiche le nom de chaque test pytest test_calculs.py # un seul fichier pytest test_calculs.py::test_est_pair # un seul test pytest -k moyenne # seulement les tests dont le nom contient "moyenne" pytest -x # s'arrête au premier échec pytest --lf # relance seulement les tests qui ont échoué la dernière fois ``` > 💡 Si la commande `pytest` n'est pas reconnue (fréquent sous Windows), utilise `python -m pytest`. C'est équivalent, et ça garantit que tu utilises le pytest du venv. #### 3.4 Lire un échec Ajoutons volontairement un test faux : ```python def test_moyenne_fausse(): assert moyenne([10, 20]) == 16 ``` ```text =================================== 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](01-variables-types-operateurs.md)) ? Pour comparer des `float`, utilise `pytest.approx` qui tolère une minuscule différence : ```python import pytest def test_moyenne_avec_decimales(): assert moyenne([0.1, 0.2]) == pytest.approx(0.15) ``` ### 4. Tester les erreurs : `pytest.raises` Une bonne fonction refuse les données invalides en levant une exception ([chapitre 08](08-erreurs-et-debogage.md)). Il faut **tester ça aussi** ! `pytest.raises` s'utilise avec `with` : ```python 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) : ```python def test_moyenne_liste_vide_message(): with pytest.raises(ValueError, match="vide"): moyenne([]) ``` ### 5. Tester beaucoup de cas : `@pytest.mark.parametrize` Tester `est_pair` avec 5 valeurs demanderait 5 fonctions presque identiques. Plutôt que de copier-coller, on **paramètre** le test : ```python 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 : ```text 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`) : ```python 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`) : ```python import pytest from panier import Panier @pytest.fixture def panier_vide(): return Panier() @pytest.fixture def panier_rempli(): panier = Panier() panier.ajouter("pomme", 0.5, 4) panier.ajouter("pain", 1.2) return panier def test_panier_vide_total_nul(panier_vide): assert panier_vide.total() == 0 def test_total(panier_rempli): assert panier_rempli.total() == pytest.approx(3.2) def test_nombre_articles(panier_rempli): assert panier_rempli.nombre_articles() == 5 def test_ajouter_meme_article_cumule(panier_rempli): panier_rempli.ajouter("pomme", 0.5, 2) assert panier_rempli.nombre_articles() == 7 def test_prix_negatif_refuse(panier_vide): with pytest.raises(ValueError): panier_vide.ajouter("bug", -1) ``` La magie : il suffit de mettre **le nom de la fixture en paramètre** du test. pytest voit `panier_rempli`, appelle la fixture du même nom, et passe le résultat au test. Point essentiel : **chaque test reçoit un objet tout neuf**. Dans `test_ajouter_meme_article_cumule`, on modifie le panier ; ça n'a aucun effet sur `test_nombre_articles`. Les tests sont **indépendants**, ce qui est indispensable : un test ne doit jamais dépendre de l'ordre d'exécution ou d'un autre test. > 💡 Pour partager des fixtures entre plusieurs fichiers de test, place-les dans un fichier spécial nommé `conftest.py` dans le dossier des tests : pytest le charge automatiquement, sans `import`. #### 6.1 Une fixture intégrée très utile : `tmp_path` pytest fournit des fixtures toutes prêtes. La plus utile pour toi : **`tmp_path`**, un dossier temporaire (un objet `pathlib.Path`, voir [chapitre 07](07-fichiers-csv-json.md)) **différent pour chaque test**, et nettoyé automatiquement. Parfait pour tester du code qui lit ou écrit des fichiers sans polluer ton disque. ```python # 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")) ``` ```python # test_sauvegarde.py from sauvegarde import charger, sauvegarder def test_aller_retour(tmp_path): fichier = tmp_path / "donnees.json" sauvegarder({"nom": "Zoé", "age": 12}, fichier) assert charger(fichier) == {"nom": "Zoé", "age": 12} def test_fichier_absent(tmp_path): assert charger(tmp_path / "inexistant.json") == {} ``` ### 7. Structure d'un bon test : Arrange, Act, Assert Un test lisible suit souvent trois temps, comme une recette : 1. **Arrange** (préparer) : créer les données nécessaires (souvent via une fixture) ; 2. **Act** (agir) : appeler la fonction testée ; 3. **Assert** (vérifier) : comparer le résultat à l'attendu. ```python def test_virement_debite_et_credite(): # Arrange compte_a = CompteBancaire("Alice", 100) compte_b = CompteBancaire("Bob", 0) # Act compte_a.virer_vers(compte_b, 40) # Assert assert compte_a.solde == 60 assert compte_b.solde == 40 ``` Règles d'or : - **un test = un comportement** (plusieurs `assert` sont OK s'ils vérifient la même idée) ; - un test doit être **rapide** et **déterministe** (même résultat à chaque fois) ; - teste **ce que fait** la fonction (son résultat), pas **comment** elle le fait. > 💡 Et le hasard ? Une fonction qui utilise `random` est difficile à tester. Astuce : vérifie des **propriétés** (« le dé donne toujours un nombre entre 1 et 6 », sur 1000 tirages), ou fixe la graine avec `random.seed(42)` pour obtenir toujours la même suite. ### 8. TDD : écrire le test d'abord Le **TDD** (*Test-Driven Development*, développement piloté par les tests) inverse l'ordre habituel : on écrit le test **avant** le code. Le cycle tient en trois couleurs : ```text 🔴 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 : ```python from fizzbuzz import fizzbuzz def test_nombre_ordinaire(): assert fizzbuzz(1) == "1" ``` `pytest` → échec (`ImportError` : la fonction n'existe pas encore). C'est **normal** et voulu. **Tour 1 — 🟢** Le minimum pour passer : ```python def fizzbuzz(n): return str(n) ``` **Tour 2 — 🔴** On ajoute `assert fizzbuzz(3) == "Fizz"` → rouge. **🟢** On ajoute `if n % 3 == 0: return "Fizz"` → vert. **Tours suivants** : même chose pour 5, puis 15. Le piège de 15 (multiple de 3 *et* de 5) apparaît naturellement : le test `fizzbuzz(15) == "FizzBuzz"` reste rouge (la fonction renvoie `"Fizz"`) tant que le code ne vérifie pas le cas « multiple de 15 » **avant** les deux autres. **🔵 Refactor** : à la fin, on regroupe tout dans un test paramétré et on relit le code : ```python 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) ``` ```python import pytest from fizzbuzz import fizzbuzz @pytest.mark.parametrize( "n, attendu", [(1, "1"), (2, "2"), (3, "Fizz"), (9, "Fizz"), (5, "Buzz"), (15, "FizzBuzz")], ) def test_fizzbuzz(n, attendu): assert fizzbuzz(n) == attendu ``` **Pourquoi c'est intéressant ?** Tu réfléchis au comportement voulu *avant* de coder, tu avances par petits pas sûrs, et tu obtiens gratuitement une suite de tests complète. Pas besoin d'être dogmatique : même sans faire du TDD strict, prends l'habitude d'écrire un test **dès que tu trouves un bug** (le test reproduit le bug → tu corriges → il ne reviendra jamais). ### 9. PEP 8 : le code de la route de Python Tu peux conduire en zigzag sur une route vide, mais dès qu'il y a d'autres conducteurs, il faut des règles communes. **PEP 8** est le guide de style officiel de Python : il rend le code de tout le monde **homogène**, donc facile à lire. > « Le code est lu beaucoup plus souvent qu'il n'est écrit. » — Guido van Rossum, créateur de Python #### 9.1 Les règles principales | Règle | ✅ Bien | ❌ À éviter | |---|---|---| | Indentation : **4 espaces** (jamais de tabulations) | `····return x` | tabulation, 2 espaces | | Variables et fonctions en **snake_case** | `prix_total`, `calculer_tva()` | `prixTotal`, `CalculerTVA()` | | Classes en **CapWords** (PascalCase) | `class CompteBancaire:` | `class compte_bancaire:` | | Constantes en **MAJUSCULES** | `TAUX_TVA = 0.2` | `tauxTva = 0.2` | | Espaces autour des opérateurs `=`, `+`, `==`… | `x = a + b` | `x=a+b` | | Pas d'espace à l'intérieur des parenthèses | `print(x)` | `print( x )` | | Espace après la virgule | `[1, 2, 3]` | `[1,2,3]` | | Pas d'espace autour de `=` pour un argument nommé | `f(x, debut=0)` | `f(x, debut = 0)` | | Lignes courtes (79 selon PEP 8, 88 avec ruff/black) | | lignes de 150 caractères | | **2 lignes vides** autour des fonctions et classes de haut niveau, 1 entre les méthodes | | tout collé | | Imports en haut du fichier, **un par ligne**, groupés : standard, puis externes, puis les tiens | `import os`
`import sys` | `import os, sys` | | Comparer à `None` avec `is` | `if x is None:` | `if x == None:` | | Tester un booléen directement | `if est_valide:` | `if est_valide == True:` | | Noms explicites | `nombre_eleves` | `n`, `l`, `tmp2` | #### 9.2 Avant / après ```python # ❌ 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) ) ``` ```python # ✅ Après import math def calculer_aire(rayon): return math.pi * rayon**2 nombres = [1, 2, 3, 4] print(calculer_aire(2)) ``` Personne n'a envie de vérifier tout ça à la main… et c'est là qu'intervient ruff. ### 10. ruff : le correcteur automatique **ruff** est un outil ultra-rapide qui fait deux choses : 1. **linter** (`ruff check`) : il repère les erreurs probables et les écarts de style (import inutilisé, variable jamais utilisée, comparaison à `None` avec `==`…) ; 2. **formateur** (`ruff format`) : il **réécrit** automatiquement la mise en forme de ton code (espaces, sauts de ligne, guillemets) selon un style standard. C'est comme un correcteur orthographique (check) doublé d'un outil de mise en page (format). ```bash pip install ruff ``` #### 10.1 `ruff check` : trouver les problèmes Sur le fichier « avant » ci-dessus (appelons-le `brouillon.py`), avec les règles que nous configurerons en 10.3 : ```bash ruff check brouillon.py ``` ```text 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 : ```bash ruff check --fix brouillon.py ``` Les autres (noms de fonctions, variable inutile…) demandent ta réflexion : ruff ne renomme pas tes fonctions à ta place. Familles de règles courantes : | Préfixe | Origine | Exemples | |---|---|---| | `F` | Pyflakes | import ou variable inutilisés, nom non défini | | `E` / `W` | pycodestyle (PEP 8) | lignes trop longues, `== None` | | `I` | isort | ordre des imports | | `N` | pep8-naming | conventions de nommage | | `UP` | pyupgrade | syntaxe Python moderne | | `B` | flake8-bugbear | pièges classiques (argument par défaut mutable…) | #### 10.2 `ruff format` : mettre en forme ```bash 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` : ```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](05-fonctions.md). 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 : ```python 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) : ```python ZERO_ABSOLU_C = -273.15 def celsius_vers_fahrenheit(celsius: float) -> float: """Convertit une température de degrés Celsius en degrés Fahrenheit. Args: celsius: température en °C, supérieure ou égale au zéro absolu. Returns: La température en °F. Raises: ValueError: si la température est inférieure au zéro absolu. Example: >>> celsius_vers_fahrenheit(100) 212.0 """ if celsius < ZERO_ABSOLU_C: raise ValueError(f"{celsius} °C est sous le zéro absolu.") return celsius * 9 / 5 + 32 ``` Conventions (PEP 257) : triples guillemets doubles `"""`, première ligne = résumé court terminé par un point, puis une ligne vide avant les détails. > 🎁 **Bonus : les doctests.** Les lignes `>>> ...` de l'exemple sont aussi des tests ! `pytest --doctest-modules` exécute ces exemples et vérifie que le résultat affiché est correct. Ta documentation ne peut ainsi jamais mentir. ### 12. Structurer un projet Quand un projet grandit, on range. Voici la structure recommandée aujourd'hui, dite **« src layout »** : ```text 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](12-outils-venv-pip-git.md). Voici une version complète pour ce projet, qui configure aussi pytest et ruff : ```toml [project] name = "convertisseur" version = "0.1.0" description = "Conversions d'unités simples" readme = "README.md" requires-python = ">=3.12" dependencies = [] [project.optional-dependencies] dev = ["pytest", "ruff"] [build-system] requires = ["setuptools>=61"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] [tool.ruff] line-length = 88 [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B"] ``` - `testpaths = ["tests"]` : pytest cherche les tests dans `tests/` ; - `pythonpath = ["src"]` : pytest ajoute `src/` aux chemins d'import, ce qui permet d'écrire `from convertisseur.temperatures import ...` dans les tests ; - la section `[build-system]` permet d'**installer ton propre projet** dans le venv en mode « éditable » (les modifications du code sont prises en compte immédiatement) : ```bash pip install -e ".[dev]" # installe le projet + pytest + ruff ``` Après cette commande, ton paquet `convertisseur` est importable de n'importe où dans le venv, comme une vraie bibliothèque. #### 12.2 Un test dans cette structure `tests/test_temperatures.py` : ```python 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 ```bash ruff check --fix . ruff format . pytest git add . git commit -m "..." ``` Si les trois premières commandes sont vertes, tu peux committer l'esprit tranquille. Les équipes professionnelles automatisent exactement ça sur GitHub (avec GitHub Actions, de l'*intégration continue*) : chaque `push` lance les tests sur un serveur. > 💡 **Couverture de code** : le plugin `pytest-cov` (`pip install pytest-cov`, puis `pytest --cov=src`) indique quel pourcentage de ton code est exécuté par les tests. Pratique pour repérer les fonctions oubliées, mais 100 % de couverture ne garantit pas l'absence de bug ! --- ## Pièges fréquents ### 1. Un test qui ne commence pas par `test_` **Symptôme** : pytest affiche `collected 0 items` ou ignore certains tests, alors que le fichier en contient. **Correction** : le fichier doit s'appeler `test_xxx.py` et chaque fonction de test `test_xxx`. `verifier_moyenne()` ou `Test_moyenne.py`… ne seront pas trouvés. Utilise `pytest -v` pour voir précisément quels tests sont collectés. ### 2. `ModuleNotFoundError` dans les tests **Symptôme** : `ModuleNotFoundError: No module named 'calculs'` (ou le nom de ton paquet) au lancement de pytest. **Correction** : lance pytest **depuis la racine du projet**. Avec une structure `src/`, ajoute `pythonpath = ["src"]` dans la section `[tool.pytest.ini_options]` du `pyproject.toml`, ou installe le projet avec `pip install -e .`. Vérifie aussi que le venv est activé et que pytest y est installé (`python -m pytest` évite d'utiliser un autre pytest). ### 3. Nommer un fichier comme un module existant **Symptôme** : ton fichier `pytest.py`, `random.py` ou `test.py` provoque des erreurs incompréhensibles (`AttributeError: module 'random' has no attribute 'randint'`, `module 'pytest' has no attribute 'raises'`). **Correction** : ton fichier masque le vrai module. Renomme-le (`mon_jeu.py`, `test_jeu.py`) et supprime le dossier `__pycache__` qui a pu se créer. ### 4. Comparer des `float` avec `==` **Symptôme** : `assert 0.30000000000000004 == 0.3` échoue alors que le calcul est « juste ». **Correction** : utilise `pytest.approx` : `assert resultat == pytest.approx(0.3)`. ### 5. Code à tester mélangé avec `input()` et `print()` **Symptôme** : impossible de tester ta fonction : elle demande une saisie au clavier et affiche le résultat au lieu de le renvoyer. **Correction** : sépare la **logique** (fonctions pures qui reçoivent des paramètres et font `return`) de l'**interface** (le `main()` qui fait `input`/`print`). Seule la logique a besoin de tests, et elle devient triviale à tester. ### 6. `pytest.raises` mal placé **Symptôme** : le test échoue avec l'exception elle-même (`ValueError: ...`) au lieu de la « capturer » ; ou le test passe alors qu'il ne devrait pas. **Correction** : l'appel qui doit lever l'exception doit être **à l'intérieur** du bloc `with pytest.raises(...)`. Et place **un seul** appel dans ce bloc : si la première ligne lève l'exception, les suivantes ne sont jamais exécutées. ### 7. Des tests qui dépendent les uns des autres **Symptôme** : les tests passent quand on les lance tous, mais l'un d'eux échoue quand on le lance seul (ou dans un autre ordre). **Correction** : n'utilise pas de variable globale partagée et modifiée par les tests. Crée les données dans chaque test ou via une **fixture** (qui fournit un objet neuf à chaque test), et utilise `tmp_path` pour les fichiers. ### 8. Ignorer les avertissements de ruff… ou leur obéir aveuglément **Symptôme** : des centaines d'avertissements que tu ne lis plus ; ou au contraire du code tordu pour satisfaire une règle. **Correction** : lance ruff **dès le début** du projet, quand il y a peu à corriger. Lis le message et cherche le code de la règle (`ruff rule F841` explique la règle en détail). Si une règle ne convient vraiment pas à ton projet, désactive-la explicitement dans `pyproject.toml` (`ignore = ["E501"]`) plutôt que de l'ignorer en silence. --- ## Exercices ### Exercice 1 ⭐ — Premiers tests Voici `geometrie.py` : ```python 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 : ```python def valeur_absolue(n): if n > 0: return n return n def maximum(nombres): plus_grand = 0 for n in nombres: if n > plus_grand: plus_grand = n return plus_grand ``` 1. Écris des tests qui **révèlent** ces bugs (ils doivent échouer). Pense aux nombres négatifs ! 2. Lis le rapport de pytest, puis corrige le code jusqu'à ce que tout soit vert. 3. Bonus : que devrait faire `maximum([])` ? Décide d'un comportement, teste-le, implémente-le. ### Exercice 3 ⭐⭐ — Palindromes paramétrés Écris une fonction `est_palindrome(texte)` qui indique si un texte se lit pareil dans les deux sens, **en ignorant** la casse, les espaces et la ponctuation (« Esope reste ici et se repose » est un palindrome). Teste-la avec **deux** tests paramétrés : un pour au moins 5 palindromes (dont la chaîne vide et une seule lettre), un pour au moins 3 non-palindromes. ### Exercice 4 ⭐⭐ — Saisie robuste et `pytest.raises` Écris une fonction `convertir_note(texte)` qui transforme une saisie utilisateur en note sur 20 (`float`) : - elle accepte `"12"`, `"12.5"` et `"12,5"` (virgule française) ; - elle lève une `ValueError` avec un message contenant « n'est pas un nombre » si le texte n'est pas convertible ; - elle lève une `ValueError` avec un message contenant « entre 0 et 20 » si la note est hors limites. Écris les tests correspondants avec `parametrize` et `pytest.raises(..., match=...)`. ### Exercice 5 ⭐⭐ — TDD : évaluer un mot de passe En suivant le cycle **rouge → vert → refactor**, écris une fonction `evaluer_mot_de_passe(mdp)` qui renvoie `"faible"`, `"moyen"` ou `"fort"` : - moins de 8 caractères → `"faible"` ; - on compte 4 critères : une minuscule, une majuscule, un chiffre, un caractère spécial parmi `!@#$%&*?-_+=` ; - 4 critères **et** au moins 12 caractères → `"fort"` ; - au moins 3 critères → `"moyen"` ; - sinon → `"faible"`. Écris **d'abord** un test, vois-le échouer, puis écris le code minimal… et recommence. Garde une trace des étapes (par exemple un commit Git par tour). ### Exercice 6 ⭐⭐ — Nettoyage avec ruff Voici un fichier `sale.py` qui fonctionne, mais qui est en piteux état : ```python import random,os def TirerDe(Faces = 6) : return random.randint(1,Faces) class joueur: def __init__(self,Nom): self.Nom=Nom self.score=0 def jouer(self): l=TirerDe() self.score+=l if self.score==None: print("bizarre") return l j = joueur( "Ana" ) print(j.jouer()) ``` 1. Lance `ruff check --select E,F,W,I,N,UP,B sale.py`. Combien d'erreurs ? Classe-les par famille. 2. Corrige ce qui est automatique (`--fix`), formate (`ruff format`), puis corrige le reste à la main. 3. Le résultat final doit passer `ruff check` (mêmes règles) et `ruff format --check` sans erreur, et le programme doit fonctionner comme avant. ### Exercice 7 ⭐⭐⭐ — Tester une classe avec des fixtures Écris une classe `CompteBancaire` avec : - `titulaire`, `solde` (0 par défaut) et `historique` (liste de chaînes comme `"+50"` ou `"-30"`) ; - `deposer(montant)` et `retirer(montant)`, qui lèvent `ValueError` si le montant est nul ou négatif ; - `retirer` lève une exception personnalisée `SoldeInsuffisantError` si le solde ne suffit pas ; - `virer_vers(autre_compte, montant)`. Écris une suite de tests utilisant **deux fixtures** (`compte_alice` avec 100 € et `compte_bob` vide). Vérifie notamment qu'un virement impossible **ne modifie aucun des deux comptes**. ### Exercice 8 ⭐⭐⭐ — Tester du code qui manipule des fichiers Écris un module `fichiers_texte.py` avec : - `compter_lignes_mots(chemin)` qui renvoie un tuple `(nombre_de_lignes, nombre_de_mots)` d'un fichier texte ; - `ajouter_ligne(chemin, ligne)` qui ajoute une ligne à la fin d'un fichier (et le crée s'il n'existe pas). Teste-les avec la fixture intégrée `tmp_path`, et une fixture à toi `fichier_exemple` qui crée un petit fichier de 4 lignes. Teste aussi le fichier vide et le fichier inexistant (`FileNotFoundError`). --- ## Mini-projet ### 🧰 Une suite de tests pour la bibliothèque « boite_a_outils » Tu vas construire une petite bibliothèque de fonctions utilitaires **organisée comme un vrai projet**, et surtout sa **suite de tests complète**. #### Cahier des charges **Structure** (src layout) : ```text boite-a-outils/ ├── .gitignore ├── README.md ├── pyproject.toml ├── src/ │ └── boite_a_outils/ │ ├── __init__.py │ ├── nombres.py │ └── texte.py └── tests/ ├── test_nombres.py └── test_texte.py ``` **Module `nombres.py`** : - `est_premier(n)` → `bool` (faux pour n < 2) ; - `pgcd(a, b)` → plus grand commun diviseur, toujours positif, `pgcd(0, 5) == 5` ; - `factorielle(n)` → `n!`, `ValueError` si `n < 0` ; - `moyenne_ponderee(valeurs, coefficients)` → `ValueError` si listes vides, de tailles différentes, ou si la somme des coefficients est nulle. **Module `texte.py`** : - `retirer_accents(texte)` → `"Élève"` devient `"Eleve"` ; - `compter_voyelles(texte)` → compte `a e i o u y`, accentuées ou non ; - `slugifier(titre)` → `"Été 2026 : c'est parti !"` devient `"ete-2026-c-est-parti"` (minuscules, sans accents, mots séparés par des tirets) ; - `tronquer(texte, longueur_max, suite="…")` → résultat de longueur `<= longueur_max`, suite comprise ; `ValueError` si `longueur_max` est plus petit que la suite. **Exigences qualité** : 1. chaque fonction a une **docstring** (avec au moins deux exemples `>>>` dans le projet) ; 2. au moins **40 tests** (les cas paramétrés comptent), qui utilisent au moins **une fixture**, **`parametrize`** et **`pytest.raises`** ; 3. `pytest` est entièrement vert ; 4. `ruff check` (règles `E, F, W, I, N, UP, B`) et `ruff format --check` ne signalent rien ; 5. le projet est sous Git avec un historique propre. #### Étapes suggérées 1. Crée l'arborescence, le venv, le `pyproject.toml` (inspire-toi de la section 12.1), puis `pip install -e ".[dev]"`. 2. Pour chaque fonction : **écris d'abord ses tests** (cas normal, limites, erreurs), puis la fonction. Lance `pytest` souvent. 3. Ajoute les docstrings, puis vérifie les doctests avec `pytest --doctest-modules src`. 4. Lance `ruff check --fix .` et `ruff format .`, corrige le reste. 5. Rédige le README (comment installer, comment lancer les tests), committe. *Indice pour `retirer_accents`* : `unicodedata.normalize("NFD", texte)` décompose « é » en « e » + un accent séparé ; les accents ont la catégorie Unicode `"Mn"` (`unicodedata.category(c)`), il suffit de les filtrer. #### Pistes d'amélioration - Ajoute `pytest-cov` et vise une couverture de 100 % de `src/`. - Ajoute un fichier `tests/conftest.py` avec des fixtures partagées. - Ajoute un module `dates.py` (âge à partir d'une date de naissance, jour de la semaine en français…) en TDD. - Crée un workflow GitHub Actions qui lance `ruff` et `pytest` à chaque `push` (cherche « GitHub Actions Python » dans la documentation de GitHub). --- ## Corrigés
Corrigé de l'exercice 1 ```python 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 ``` ```bash pytest -v ``` ```text test_geometrie.py::test_carre_positif PASSED test_geometrie.py::test_carre_negatif PASSED ... ============================== 7 passed in 0.01s =============================== ``` **À retenir** : le test de 18 ans est le plus important de la série. C'est une **valeur limite** : un développeur distrait aurait pu écrire `age > 18`, et seul ce test l'aurait détecté. `is True` / `is False` est plus strict que `== True` : on vérifie que la fonction renvoie vraiment un booléen.
Corrigé de l'exercice 2 **1. Tests qui révèlent les bugs** (`test_bug.py`) : ```python import pytest from bug import maximum, valeur_absolue def test_valeur_absolue_positif(): assert valeur_absolue(5) == 5 def test_valeur_absolue_negatif(): assert valeur_absolue(-5) == 5 def test_valeur_absolue_zero(): assert valeur_absolue(0) == 0 def test_maximum_positifs(): assert maximum([3, 8, 1]) == 8 def test_maximum_negatifs(): assert maximum([-7, -2, -9]) == -2 def test_maximum_liste_vide(): with pytest.raises(ValueError): maximum([]) ``` Avec le code bogué, pytest affiche : ```text E assert -5 == 5 E + where -5 = valeur_absolue(-5) ... E assert 0 == -2 E + where 0 = maximum([-7, -2, -9]) ``` Les diagnostics parlent d'eux-mêmes : `valeur_absolue` renvoie le nombre négatif tel quel, et `maximum` renvoie 0, une valeur qui n'est même pas dans la liste ! **2–3. Code corrigé** : ```python def valeur_absolue(n): if n >= 0: return n return -n def maximum(nombres): if not nombres: raise ValueError("maximum() d'une liste vide") plus_grand = nombres[0] for n in nombres[1:]: if n > plus_grand: plus_grand = n return plus_grand ``` **Explications** : - `valeur_absolue` : il manquait le `-` dans le second `return`. - `maximum` : partir de `0` est une erreur classique. Si tous les nombres sont négatifs, aucun n'est plus grand que 0. Il faut partir du **premier élément** de la liste. Pour la liste vide, il n'y a pas de maximum ; lever une `ValueError` est le choix cohérent avec la fonction `max()` de Python (qui fait pareil). - Les tests sur les nombres positifs passaient déjà : **sans cas négatifs, les bugs seraient passés inaperçus.**
Corrigé de l'exercice 3 `palindrome.py` : ```python def est_palindrome(texte: str) -> bool: """Indique si texte se lit pareil dans les deux sens. On ignore la casse, les espaces et la ponctuation. """ lettres = [c.lower() for c in texte if c.isalnum()] return lettres == lettres[::-1] ``` `test_palindrome.py` : ```python import pytest from palindrome import est_palindrome @pytest.mark.parametrize( "texte", [ "kayak", "Radar", "Esope reste ici et se repose", "A man, a plan, a canal: Panama!", "", "x", ], ) def test_palindromes(texte): assert est_palindrome(texte) @pytest.mark.parametrize("texte", ["python", "Bonjour", "ab"]) def test_non_palindromes(texte): assert not est_palindrome(texte) ``` Résultat : `9 passed`. **Explications** : - La compréhension de liste ([chapitre 06](06-chaines-et-comprehensions.md)) garde uniquement les caractères alphanumériques (`isalnum()`), en minuscules. - `lettres[::-1]` est la liste à l'envers (slicing avec un pas de -1). - Avec un seul paramètre, `parametrize` accepte une simple liste de valeurs (pas besoin de tuples). - La chaîne vide et la lettre unique sont des **cas limites** : par convention, ce sont des palindromes.
Corrigé de l'exercice 4 `notes.py` : ```python def convertir_note(texte: str) -> float: """Convertit une saisie utilisateur en note sur 20. Accepte la virgule comme séparateur décimal ("12,5"). Raises: ValueError: si le texte n'est pas un nombre ou si la note n'est pas comprise entre 0 et 20. """ try: note = float(texte.replace(",", ".")) except ValueError: raise ValueError(f"« {texte} » n'est pas un nombre.") from None if not 0 <= note <= 20: raise ValueError(f"La note {note} doit être entre 0 et 20.") return note ``` `test_notes.py` : ```python import pytest from notes import convertir_note @pytest.mark.parametrize( "saisie, attendu", [("12", 12.0), ("12.5", 12.5), ("12,5", 12.5), ("0", 0.0), ("20", 20.0)], ) def test_conversion_valide(saisie, attendu): assert convertir_note(saisie) == attendu @pytest.mark.parametrize("saisie", ["douze", "", "12/20"]) def test_pas_un_nombre(saisie): with pytest.raises(ValueError, match="n'est pas un nombre"): convertir_note(saisie) @pytest.mark.parametrize("saisie", ["-1", "20.5", "100"]) def test_hors_limites(saisie): with pytest.raises(ValueError, match="entre 0 et 20"): convertir_note(saisie) ``` Résultat : `11 passed`. **Explications** : - On attrape la `ValueError` de `float()` pour la relancer avec **notre** message, plus clair pour l'utilisateur. `from None` masque l'exception d'origine dans le traceback ([chapitre 08](08-erreurs-et-debogage.md)). - `0 <= note <= 20` : Python permet d'enchaîner les comparaisons. - `match=` distingue les **deux** sortes d'erreurs : sans lui, un test « hors limites » passerait même si la fonction se trompait de raison. - Les cas `"0"` et `"20"` vérifient que les bornes sont **incluses**.
Corrigé de l'exercice 5 **Déroulé TDD** (une possibilité) : | Tour | Test ajouté (🔴) | Code ajouté (🟢) | |---|---|---| | 1 | `"Ab1!"` → `"faible"` | `return "faible"` (oui, c'est tout !) | | 2 | `"Bonjour2026!Py"` → `"fort"` | compter les 4 critères ; `if nombre == 4 and len >= 12: return "fort"` | | 3 | `"Bonjour123"` → `"moyen"` | `if nombre >= 3: return "moyen"` | | 4 | `"abcdefghijkl"` → `"faible"` | déjà vert : le test confirme le comportement | | 5 | `"Bonjour1!"` → `"moyen"` (4 critères, mais 9 caractères) | déjà vert | | 🔵 | regroupement dans un test paramétré | extraction de la constante `CARACTERES_SPECIAUX` | **Code final** (`mot_de_passe.py`) : ```python CARACTERES_SPECIAUX = "!@#$%&*?-_+=" def evaluer_mot_de_passe(mot_de_passe: str) -> str: """Renvoie "faible", "moyen" ou "fort" selon la robustesse du mot de passe.""" if len(mot_de_passe) < 8: return "faible" criteres = [ any(c.islower() for c in mot_de_passe), any(c.isupper() for c in mot_de_passe), any(c.isdigit() for c in mot_de_passe), any(c in CARACTERES_SPECIAUX for c in mot_de_passe), ] nombre = sum(criteres) if nombre == 4 and len(mot_de_passe) >= 12: return "fort" if nombre >= 3: return "moyen" return "faible" ``` **Tests** (`test_mot_de_passe.py`) : ```python import pytest from mot_de_passe import evaluer_mot_de_passe def test_trop_court_est_faible(): assert evaluer_mot_de_passe("Ab1!") == "faible" def test_long_mais_que_des_minuscules_est_faible(): assert evaluer_mot_de_passe("abcdefghijkl") == "faible" def test_trois_criteres_est_moyen(): assert evaluer_mot_de_passe("Bonjour123") == "moyen" def test_quatre_criteres_mais_court_est_moyen(): assert evaluer_mot_de_passe("Bonjour1!") == "moyen" def test_quatre_criteres_et_long_est_fort(): assert evaluer_mot_de_passe("Bonjour2026!Py") == "fort" @pytest.mark.parametrize( "mot_de_passe, attendu", [ ("", "faible"), ("12345678", "faible"), ("motdepasse12", "faible"), ("MotDePasse12", "moyen"), ("M0t-De-Passe-Solide", "fort"), ], ) def test_cas_varies(mot_de_passe, attendu): assert evaluer_mot_de_passe(mot_de_passe) == attendu ``` **Explications** : - `any(...)` renvoie `True` si au moins un caractère satisfait la condition. - `sum(criteres)` compte les `True` (car `True` vaut 1 et `False` vaut 0). - Le tour 1 peut sembler absurde (`return "faible"`), mais c'est le principe : **le minimum pour passer au vert**. Ce sont les tests suivants qui « forcent » le code à devenir général.
Corrigé de l'exercice 6 **1. Diagnostic** : ```bash ruff check --select E,F,W,I,N,UP,B --output-format concise sale.py ``` ```text sale.py:1:1: E401 [*] Multiple imports on one line sale.py:1:1: I001 [*] Import block is un-sorted or un-formatted sale.py:1:15: F401 [*] `os` imported but unused sale.py:2:5: N802 Function name `TirerDe` should be lowercase sale.py:2:13: N803 Argument name `Faces` should be lowercase sale.py:4:7: N801 Class name `joueur` should use CapWords convention sale.py:5:21: N803 Argument name `Nom` should be lowercase sale.py:9:7: E741 Ambiguous variable name: `l` sale.py:11:22: E711 Comparison to `None` should be `cond is None` sale.py:11:26: E701 Multiple statements on one line (colon) Found 10 errors. ``` 10 erreurs : 4 de style PEP 8 (`E`), 1 d'ordre des imports (`I`), 1 import inutilisé (`F`), 4 de nommage (`N`). Note que l'indentation à 2 espaces n'est pas signalée par ces règles, mais `ruff format` la corrigera. **2. Correction** : `ruff check --fix` supprime `os` et range l'import ; `ruff format` corrige l'indentation et les espaces. Le reste à la main : - `TirerDe` → `tirer_de`, `Faces` → `faces`, `Nom` → `nom` (snake_case) ; - `joueur` → `Joueur` (CapWords pour une classe) ; - `l` → `resultat` (`l` se confond avec `1` et `I`) ; - la ligne `if self.score == None: print("bizarre")` est inutile (le score ne peut pas être `None`) : on la supprime ; - `j` → `joueur`. ```python import random def tirer_de(faces=6): return random.randint(1, faces) class Joueur: def __init__(self, nom): self.nom = nom self.score = 0 def jouer(self): resultat = tirer_de() self.score += resultat return resultat joueur = Joueur("Ana") print(joueur.jouer()) ``` **3. Vérification** : ```bash ruff check --select E,F,W,I,N,UP,B propre.py # All checks passed! ruff format --check propre.py # 1 file already formatted python propre.py # affiche un nombre entre 1 et 6 ```
Corrigé de l'exercice 7 `compte.py` : ```python class SoldeInsuffisantError(Exception): """Levée quand un retrait dépasse le solde disponible.""" class CompteBancaire: def __init__(self, titulaire: str, solde: float = 0.0): self.titulaire = titulaire self.solde = solde self.historique: list[str] = [] def deposer(self, montant: float) -> None: if montant <= 0: raise ValueError("Le montant doit être positif.") self.solde += montant self.historique.append(f"+{montant}") def retirer(self, montant: float) -> None: if montant <= 0: raise ValueError("Le montant doit être positif.") if montant > self.solde: raise SoldeInsuffisantError(f"Solde insuffisant : {self.solde} €") self.solde -= montant self.historique.append(f"-{montant}") def virer_vers(self, autre: "CompteBancaire", montant: float) -> None: self.retirer(montant) autre.deposer(montant) ``` `test_compte.py` : ```python import pytest from compte import CompteBancaire, SoldeInsuffisantError @pytest.fixture def compte_alice(): return CompteBancaire("Alice", 100) @pytest.fixture def compte_bob(): return CompteBancaire("Bob") def test_creation(compte_alice, compte_bob): assert compte_alice.titulaire == "Alice" assert compte_alice.solde == 100 assert compte_bob.solde == 0 assert compte_bob.historique == [] def test_depot(compte_alice): compte_alice.deposer(50) assert compte_alice.solde == 150 assert compte_alice.historique == ["+50"] def test_retrait(compte_alice): compte_alice.retirer(30) assert compte_alice.solde == 70 def test_retrait_trop_gros(compte_alice): with pytest.raises(SoldeInsuffisantError): compte_alice.retirer(500) assert compte_alice.solde == 100 # le solde n'a pas bougé @pytest.mark.parametrize("montant", [0, -10]) def test_montant_invalide(compte_alice, montant): with pytest.raises(ValueError): compte_alice.deposer(montant) with pytest.raises(ValueError): compte_alice.retirer(montant) def test_virement(compte_alice, compte_bob): compte_alice.virer_vers(compte_bob, 40) assert compte_alice.solde == 60 assert compte_bob.solde == 40 def test_virement_impossible_ne_change_rien(compte_alice, compte_bob): with pytest.raises(SoldeInsuffisantError): compte_alice.virer_vers(compte_bob, 1000) assert compte_alice.solde == 100 assert compte_bob.solde == 0 ``` Résultat : `8 passed`. **Explications** : - Un test peut recevoir **plusieurs fixtures**, et combiner fixture + `parametrize` (`test_montant_invalide`). - Le nom `SoldeInsuffisantError` suit la convention (règle ruff `N818`) : les exceptions se terminent par `Error`. - `virer_vers` appelle `retirer` **avant** `deposer` : si le retrait échoue, l'exception interrompt la méthode et `deposer` n'est jamais appelée. Le dernier test le prouve. Si on avait écrit l'inverse, Bob aurait reçu de l'argent qu'Alice n'avait pas ! - Après `pytest.raises`, on peut continuer le test (en dehors du `with`) pour vérifier l'état des objets.
Corrigé de l'exercice 8 `fichiers_texte.py` : ```python from pathlib import Path def compter_lignes_mots(chemin: Path) -> tuple[int, int]: """Renvoie (nombre de lignes, nombre de mots) d'un fichier texte.""" texte = Path(chemin).read_text(encoding="utf-8") return len(texte.splitlines()), len(texte.split()) def ajouter_ligne(chemin: Path, ligne: str) -> None: """Ajoute une ligne à la fin du fichier (le crée s'il n'existe pas).""" with open(chemin, "a", encoding="utf-8") as fichier: fichier.write(ligne + "\n") ``` `test_fichiers_texte.py` : ```python import pytest from fichiers_texte import ajouter_ligne, compter_lignes_mots @pytest.fixture def fichier_exemple(tmp_path): chemin = tmp_path / "poeme.txt" chemin.write_text("Le ciel est bleu\nLa mer est calme\n\nFin\n", encoding="utf-8") return chemin def test_compter(fichier_exemple): assert compter_lignes_mots(fichier_exemple) == (4, 9) def test_fichier_vide(tmp_path): chemin = tmp_path / "vide.txt" chemin.write_text("", encoding="utf-8") assert compter_lignes_mots(chemin) == (0, 0) def test_fichier_inexistant(tmp_path): with pytest.raises(FileNotFoundError): compter_lignes_mots(tmp_path / "absent.txt") def test_ajouter_ligne_cree_le_fichier(tmp_path): chemin = tmp_path / "journal.txt" ajouter_ligne(chemin, "Bonjour") assert chemin.read_text(encoding="utf-8") == "Bonjour\n" def test_ajouter_ligne_a_la_suite(fichier_exemple): ajouter_ligne(fichier_exemple, "Nouvelle ligne") assert compter_lignes_mots(fichier_exemple) == (5, 11) ``` Résultat : `5 passed`. **Explications** : - Une fixture peut **utiliser une autre fixture** : `fichier_exemple` reçoit `tmp_path`. Chaque test a donc son propre fichier, dans son propre dossier temporaire. - Le fichier d'exemple a 4 lignes (dont une vide) et 9 mots : `splitlines()` compte les lignes, `split()` sans argument découpe sur tous les blancs et ignore les lignes vides. - Le mode `"a"` (*append*) ajoute à la fin et crée le fichier s'il n'existe pas ([chapitre 07](07-fichiers-csv-json.md)). - Aucun fichier n'est créé dans ton projet : tout se passe dans un dossier temporaire géré par pytest.
Corrigé du mini-projet **`pyproject.toml`** : ```toml [project] name = "boite-a-outils" version = "0.1.0" description = "Petite bibliothèque de fonctions utilitaires" readme = "README.md" requires-python = ">=3.12" dependencies = [] [project.optional-dependencies] dev = ["pytest", "ruff"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] [tool.ruff] line-length = 88 [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B"] ``` (Sans section `[build-system]`, on n'installe pas le projet avec `pip install -e .` : on installe juste les outils avec `pip install pytest ruff`, et `pythonpath = ["src"]` suffit pour que les tests trouvent le paquet.) **`src/boite_a_outils/__init__.py`** : ```python """Boîte à outils : fonctions utilitaires sur les nombres et les textes.""" ``` **`src/boite_a_outils/nombres.py`** : ```python """Fonctions utilitaires sur les nombres.""" def est_premier(n: int) -> bool: """Indique si n est un nombre premier. >>> est_premier(7) True >>> est_premier(8) False """ if n < 2: return False diviseur = 2 while diviseur * diviseur <= n: if n % diviseur == 0: return False diviseur += 1 return True def pgcd(a: int, b: int) -> int: """Renvoie le plus grand commun diviseur de a et b (algorithme d'Euclide).""" a, b = abs(a), abs(b) while b != 0: a, b = b, a % b return a def factorielle(n: int) -> int: """Renvoie n! = 1 × 2 × … × n. Raises: ValueError: si n est négatif. """ if n < 0: raise ValueError("La factorielle n'est pas définie pour un nombre négatif.") resultat = 1 for i in range(2, n + 1): resultat *= i return resultat def moyenne_ponderee(valeurs: list[float], coefficients: list[float]) -> float: """Renvoie la moyenne de valeurs pondérée par coefficients. Raises: ValueError: si les listes sont vides, de tailles différentes, ou si la somme des coefficients est nulle. """ if not valeurs or len(valeurs) != len(coefficients): raise ValueError("Listes vides ou de tailles différentes.") total_coefficients = sum(coefficients) if total_coefficients == 0: raise ValueError("La somme des coefficients ne peut pas être nulle.") somme = sum(v * c for v, c in zip(valeurs, coefficients, strict=True)) return somme / total_coefficients ``` **`src/boite_a_outils/texte.py`** : ```python """Fonctions utilitaires sur les chaînes de caractères.""" import unicodedata def compter_voyelles(texte: str) -> int: """Compte les voyelles (accentuées ou non, y compris y) dans texte.""" sans_accents = retirer_accents(texte.lower()) return sum(1 for lettre in sans_accents if lettre in "aeiouy") def retirer_accents(texte: str) -> str: """Remplace les lettres accentuées par leur équivalent sans accent. >>> retirer_accents("Élève à l'école") "Eleve a l'ecole" """ decompose = unicodedata.normalize("NFD", texte) return "".join(c for c in decompose if unicodedata.category(c) != "Mn") def slugifier(titre: str) -> str: """Transforme un titre en « slug » utilisable dans une URL. >>> slugifier(" Bonjour, le Monde ! ") 'bonjour-le-monde' """ texte = retirer_accents(titre.lower()) mots = [] mot_courant = "" for caractere in texte: if caractere.isalnum(): mot_courant += caractere elif mot_courant: mots.append(mot_courant) mot_courant = "" if mot_courant: mots.append(mot_courant) return "-".join(mots) def tronquer(texte: str, longueur_max: int, suite: str = "…") -> str: """Coupe texte pour qu'il fasse au plus longueur_max caractères, suite comprise. Raises: ValueError: si longueur_max est plus petit que la longueur de suite. """ if longueur_max < len(suite): raise ValueError("longueur_max est trop petit.") if len(texte) <= longueur_max: return texte return texte[: longueur_max - len(suite)] + suite ``` **`tests/test_nombres.py`** : ```python import pytest from boite_a_outils.nombres import est_premier, factorielle, moyenne_ponderee, pgcd # --- est_premier ----------------------------------------------------------- @pytest.mark.parametrize("n", [2, 3, 5, 7, 11, 13, 97, 7919]) def test_est_premier_vrai(n): assert est_premier(n) is True @pytest.mark.parametrize("n", [-7, 0, 1, 4, 9, 15, 100, 7917]) def test_est_premier_faux(n): assert est_premier(n) is False def test_premiers_jusqua_30(): attendus = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] assert [n for n in range(31) if est_premier(n)] == attendus # --- pgcd ------------------------------------------------------------------ @pytest.mark.parametrize( "a, b, attendu", [ (12, 18, 6), (18, 12, 6), # l'ordre ne compte pas (7, 13, 1), # nombres premiers entre eux (0, 5, 5), (5, 0, 5), (-12, 18, 6), # le PGCD est toujours positif (42, 42, 42), ], ) def test_pgcd(a, b, attendu): assert pgcd(a, b) == attendu # --- factorielle ----------------------------------------------------------- @pytest.mark.parametrize("n, attendu", [(0, 1), (1, 1), (5, 120), (10, 3628800)]) def test_factorielle(n, attendu): assert factorielle(n) == attendu def test_factorielle_negative(): with pytest.raises(ValueError, match="négatif"): factorielle(-1) # --- moyenne_ponderee ------------------------------------------------------ def test_moyenne_ponderee_coefficients_egaux(): assert moyenne_ponderee([10, 20], [1, 1]) == 15 def test_moyenne_ponderee_bulletin(): # maths 16 (coef 4), français 12 (coef 3), sport 18 (coef 1) resultat = moyenne_ponderee([16, 12, 18], [4, 3, 1]) assert resultat == pytest.approx(14.75) @pytest.mark.parametrize( "valeurs, coefficients", [ ([], []), # vides ([10, 12], [1]), # tailles différentes ([10, 12], [0, 0]), # somme des coefficients nulle ], ) def test_moyenne_ponderee_erreurs(valeurs, coefficients): with pytest.raises(ValueError): moyenne_ponderee(valeurs, coefficients) ``` **`tests/test_texte.py`** : ```python import pytest from boite_a_outils.texte import ( compter_voyelles, retirer_accents, slugifier, tronquer, ) @pytest.fixture def phrase_accentuee(): return "L'été à Noël, où ça ?" # --- retirer_accents ------------------------------------------------------- def test_retirer_accents(phrase_accentuee): assert retirer_accents(phrase_accentuee) == "L'ete a Noel, ou ca ?" def test_retirer_accents_majuscules(): assert retirer_accents("ÉCOLE") == "ECOLE" def test_retirer_accents_sans_accent_inchange(): assert retirer_accents("Python") == "Python" # --- compter_voyelles ------------------------------------------------------ @pytest.mark.parametrize( "texte, attendu", [("bonjour", 3), ("Python", 2), ("", 0), ("xkcd", 0), ("Élève", 3)], ) def test_compter_voyelles(texte, attendu): assert compter_voyelles(texte) == attendu # --- slugifier ------------------------------------------------------------- @pytest.mark.parametrize( "titre, attendu", [ ("Bonjour le monde", "bonjour-le-monde"), (" Espaces multiples ", "espaces-multiples"), ("Été 2026 : c'est parti !", "ete-2026-c-est-parti"), ("!!!", ""), ], ) def test_slugifier(titre, attendu): assert slugifier(titre) == attendu def test_slugifier_resultat_sans_espace_ni_majuscule(phrase_accentuee): slug = slugifier(phrase_accentuee) assert " " not in slug assert slug == slug.lower() # --- tronquer -------------------------------------------------------------- def test_tronquer_texte_court_inchange(): assert tronquer("Salut", 10) == "Salut" def test_tronquer_texte_pile_a_la_limite(): assert tronquer("1234567890", 10) == "1234567890" def test_tronquer_texte_long(): resultat = tronquer("Bonjour tout le monde", 10) assert resultat == "Bonjour t…" assert len(resultat) == 10 def test_tronquer_suite_personnalisee(): assert tronquer("Bonjour tout le monde", 10, suite="...") == "Bonjour..." def test_tronquer_longueur_trop_petite(): with pytest.raises(ValueError): tronquer("Bonjour", 2, suite="...") ``` **Vérifications** : ```bash pytest ``` ```text tests/test_nombres.py .................................. [ 65%] tests/test_texte.py .................. [100%] ============================== 52 passed in 0.07s ============================== ``` ```bash pytest --doctest-modules src -q # 3 passed (les exemples >>> des docstrings) ruff check . # All checks passed! ruff format --check . # 5 files already formatted ``` **Points clés de la correction** : - **52 tests** pour 8 fonctions : `parametrize` permet d'être exhaustif sans répétition. - La règle ruff `B905` impose `zip(..., strict=True)` : si les deux listes n'ont pas la même longueur, `zip` lève une erreur au lieu de couper silencieusement. Ici, on a déjà vérifié les longueurs juste avant, mais c'est une sécurité gratuite. - `est_premier` ne teste les diviseurs que jusqu'à la racine carrée de `n` (`diviseur * diviseur <= n`) : c'est bien plus rapide, et les tests sur `7919` (premier) et `7917` (= 3 × 7 × 13 × 29) vérifient que l'optimisation n'a rien cassé. - `retirer_accents` utilise `unicodedata` (bibliothèque standard) : « é » est décomposé en « e » + « ◌́ » (catégorie `Mn`, *Mark, nonspacing*), qu'on filtre. - `slugifier` réutilise `retirer_accents` : on teste chaque brique séparément, puis leur combinaison. - Les commentaires `# --- ... ---` découpent les fichiers de tests en sections lisibles.
--- ## Récapitulatif **Points clés à retenir :** - Un **test automatique** est une fonction qui appelle ton code et vérifie le résultat avec `assert`. Il se relance à volonté et te protège des régressions. - **pytest** trouve les fichiers `test_*.py` et les fonctions `test_*`. On lance `pytest` (ou `python -m pytest`), avec `-v`, `-k`, `-x` au besoin. - Les rapports d'échec montrent la ligne et les valeurs comparées : **lis-les attentivement**. - `pytest.approx` pour les `float`, `pytest.raises` (avec `match=`) pour les exceptions, `@pytest.mark.parametrize` pour tester de nombreux cas, `@pytest.fixture` pour préparer des données neuves à chaque test, `tmp_path` pour les fichiers. - Teste les **cas limites** : vide, zéro, négatif, seuil exact, entrée invalide. - **TDD** : 🔴 test qui échoue → 🟢 code minimal → 🔵 refactor. - **PEP 8** : 4 espaces, `snake_case` pour variables et fonctions, `CapWords` pour les classes, `MAJUSCULES` pour les constantes, espaces autour des opérateurs, imports en haut. - **ruff** : `ruff check --fix` (lint) et `ruff format` (mise en forme), configurés dans `pyproject.toml`. - **Docstrings** : `"""Résumé."""` sous chaque fonction, détails `Args/Returns/Raises` si utile, exemples `>>>` testables. - **Structure** : `src/paquet/`, `tests/`, `README.md`, `pyproject.toml`, `.gitignore`. **Checklist :** - [ ] J'ai installé pytest et ruff dans mon venv. - [ ] Je nomme mes fichiers et fonctions de test avec le préfixe `test_`. - [ ] Je sais lire un rapport d'échec de pytest. - [ ] Je sais tester une exception avec `pytest.raises`. - [ ] J'utilise `parametrize` plutôt que de copier-coller des tests. - [ ] Je sais écrire une fixture et utiliser `tmp_path`. - [ ] J'ai essayé le cycle TDD rouge → vert → refactor au moins une fois. - [ ] Mon code respecte PEP 8 : `ruff check` et `ruff format --check` ne signalent rien. - [ ] Mes fonctions ont des docstrings. - [ ] Je sais organiser un projet avec `src/`, `tests/` et `pyproject.toml`. - [ ] Je lance `ruff` et `pytest` avant chaque commit. --- [← Chapitre précédent](12-outils-venv-pip-git.md) | [Sommaire](README.md) | [Chapitre suivant →](14-specialisations.md)