Couverture et CI — quelles lignes vos tests n'ont-elles jamais exécutées ?
Mesurer la couverture avec pytest-cov, lire la colonne Missing, la couverture des branches, faire respecter un minimum, exécuter les tests en parallèle avec pytest-xdist et faire tourner toute la suite à chaque push avec GitHub Actions. Et pourquoi 100 % de couverture ne veut pas dire du code correct.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une petite fonction qui calcule le prix d'un colis, et deux tests pour elle.
shop/shipping.py :
def shipping_cost(weight_kg: float, country: str) -> float:
if weight_kg <= 0:
raise ValueError(f"weight must be positive: {weight_kg}")
if country == "BD":
base = 60.0
elif country == "IN":
base = 90.0
else:
base = 500.0
if weight_kg > 5:
base += (weight_kg - 5) * 20
return basetests/test_shipping.py :
from shop.shipping import shipping_cost
def test_light_parcel_to_bangladesh():
assert shipping_cost(1, "BD") == 60.0
def test_heavy_parcel_to_bangladesh():
assert shipping_cost(7, "BD") == 100.0$ pytest -q
.. [100%]
2 passed in 0.07sVert. Et le vert ne dit rien des parties de la fonction que les tests n'ont jamais touchées. Un colis vers l'Inde coûte-t-il 90 ? Un poids de zéro lève-t-il vraiment une exception ? Personne ne l'a demandé. Une faute de frappe dans la branche "IN" resterait là, en continuant de passer, jusqu'à ce qu'un client la trouve.
Avec douze lignes, vous pouvez vérifier à l'œil. Avec douze mille, non. Vous avez besoin que l'ordinateur réponde à une question : quelles lignes mes tests n'ont-elles jamais exécutées ? Cette mesure s'appelle la couverture, et ce chapitre est consacré à l'obtenir, à la lire, et à ne pas se laisser berner par elle — puis à exécuter toute la suite vite et automatiquement à chaque push.
À la fin de ce chapitre, vous saurez
- Mesurer la couverture avec
pytest-covet lire la colonneMissing - Ouvrir le rapport HTML pour voir les lignes non couvertes dans leur contexte
- Expliquer pourquoi la couverture des lignes peut dire 100 % alors qu'une branche n'a jamais été empruntée, et activer la couverture des branches
- Faire échouer l'exécution quand la couverture descend sous un seuil, et exclure du code à dessein
- Garder tout cela dans
pyproject.toml - Dire pourquoi 100 % de couverture ne veut pas dire que le code est correct
- Exécuter les tests en parallèle avec
pytest-xdist, et expliquer pourquoi les tests ne doivent pas dépendre les uns des autres - Écrire un workflow GitHub Actions qui exécute la suite sur plusieurs versions de Python
Prérequis : configuration et avertissements.
Avant d'écrire le test
La mauvaise façon d'utiliser la couverture est d'ouvrir le rapport en premier et d'écrire n'importe quel test qui fait passer la prochaine ligne rouge au vert. Ça produit des tests en forme du code, et non de la promesse que le code fait — et, comme vous le verrez, cela manque des bugs qui n'ont pas de ligne à eux. Le bon ordre est : planifier à partir du contrat, écrire les tests, et seulement ensuite demander à la couverture ce que le plan a oublié.
Le contrat. Lisez shipping_cost comme une promesse, et non comme du code :
- poids nul ou en dessous →
ValueErrordont le message dit « positive » - le Bangladesh coûte 60, l'Inde 90, n'importe où ailleurs 500
- au-dessus de 5 kg, chaque kilogramme supplémentaire ajoute 20 ; à exactement 5 kg, rien n'est ajouté
- aucun effet de bord : elle renvoie un nombre et ne touche à rien d'autre
Ce qui doit être en place. Un environnement virtuel avec pytest et pytest-cov installés dedans (et pytest-xdist plus tard). Le package doit être importable depuis les tests — ici shop, soit installé, soit accessible via pythonpath = ["src"] du chapitre précédent. Et vous devez connaître le nom d'import du code que vous mesurez, car c'est ce qui va après --cov=.
Le plan. Une ligne par comportement, avec les frontières et les entrées invalides à dessein :
| cas | entrée | attendu | | --- | --- | --- | | chemin nominal, pays d'origine | 1, "BD" | 60.0 | | second pays | 1, "IN" | 90.0 | | n'importe où ailleurs | 1, "US" | 500.0 | | frontière : exactement 5 kg | 5, "BD" | 60.0 (aucun supplément) | | au-dessus de la limite | 7, "BD" | 100.0 (60 + 2 × 20) | | invalide : poids nul | 0, "BD" | ValueError, « positive » |
Ce qu'il ne faut pas tester. Le code que vous n'avez pas écrit (le round de Python, la bibliothèque coverage elle-même). Un bloc __main__ qui ne s'exécute que lorsqu'un humain lance le fichier. Et jamais un test dont le seul rôle est de faire tourner une ligne — un test sans assert significatif gonfle le pourcentage et ne vérifie rien.
Remarquez les deux lignes de frontière. Un test à 5 kg n'exécute aucune ligne que le test à 1 kg n'exécute déjà, donc un rapport de couverture ne le demandera jamais. Et pour le cas invalide, un test avec -1 couvrirait la ligne raise tout aussi bien que 0 — mais si quelqu'un avait écrit weight_kg < 0 au lieu de <= 0, seul le test avec 0 échouerait. La couverture ne peut pas distinguer ces deux tests ; le contrat, si. C'est toute la relation : le plan décide ce qu'il faut tester ; la couverture rend compte de ce que le plan a manqué.
Les deux tests du début couvrent les lignes 1 et 5 du tableau. Le reste du chapitre mesure cet écart, le comble, puis automatise la mesure.
Mesurer avec pytest-cov
La couverture est mesurée par la bibliothèque coverage ; pytest-cov est le plugin qui l'active depuis la ligne de commande de pytest. Installez-le comme dépendance de développement :
$ uv add --dev pytest-covPuis dites-lui quel package surveiller avec --cov= :
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmts est le nombre d'instructions exécutables dans le fichier. Miss est le nombre de celles qui ne se sont jamais exécutées. Cover est la part qui s'est exécutée. Onze instructions, quatre jamais exécutées : 64 %.
La valeur après --cov= est le code que vous voulez mesurer, pas les tests. Vos tests exécutent toujours toutes leurs propres lignes, donc les compter ne ferait que gonfler le total.
Lire la colonne Missing
Un pourcentage vous dit combien. Il ne vous dit pas où. Ajoutez --cov-report=term-missing :
$ pytest -q --cov=shop --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64% 3, 6-9
------------------------------------------------
TOTAL 11 4 64%
2 passed in 0.09s3, 6-9 sont des numéros de lignes dans shipping.py. Comptez dans le fichier :
- La ligne 3 est
raise ValueError(...)— aucun test n'a passé un poids nul ou négatif. - Les lignes 6 à 9 sont
elif country == "IN":,base = 90.0,else:etbase = 500.0— aucun test n'a envoyé de colis ailleurs qu'au Bangladesh.
La ligne 8, else:, n'est pas une instruction à elle seule, donc elle n'entre pas dans le compte de onze ; coverage l'inclut simplement dans l'intervalle 6-9 pour garder la liste courte.
Cette colonne est la liste de choses à faire. Chaque nombre est une ligne qui pourrait être complètement fausse et votre suite resterait verte.
Le rapport HTML
Pour un grand fichier, une liste de numéros devient difficile à suivre. --cov-report=html écrit à la place un petit site web :
$ pytest -q --cov=shop --cov-report=html
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Coverage HTML written to dir htmlcov
2 passed in 0.11sOuvrez htmlcov/index.html dans un navigateur. Chaque fichier est listé avec son pourcentage ; cliquez-en un et vous voyez la source, avec les lignes exécutées marquées en vert et les lignes manquées en rouge. C'est la même information que Missing, posée sur le code lui-même.
Et htmlcov/ et le fichier de données .coverage sont tous deux des sorties générées. Mettez-les dans .gitignore.
Couverture des branches — pourquoi la couverture des lignes ment
Voici une fonction où la couverture des lignes dit que tout va bien :
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)from shop.coupons import final_price
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0$ pytest -q --cov=shop --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/coupons.py 4 0 100%
------------------------------------------------
TOTAL 4 0 100%
1 passed in 0.09s100 %. Chaque ligne s'est exécutée. Mais personne n'a jamais appelé final_price sans un coupon. Un if sans else a deux façons de sortir — dans le corps, ou directement au-delà — et une seule des deux a été essayée. Si quelqu'un casse plus tard le chemin sans coupon, ce rapport ne le remarquera pas.
La couverture des lignes demande « est-ce que cette ligne s'est exécutée ? ». La couverture des branches demande « est-ce que chaque décision est allée dans les deux sens ? ». Activez-la avec --cov-branch :
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10sDeux nouvelles colonnes. Branch est le nombre de sauts possibles (le if de la ligne 2 peut aller à la ligne 3 ou à la ligne 4 : deux). BrPart compte les décisions qui ne sont allées que dans un seul sens. Et Missing montre désormais 2->4 : le saut de la ligne 2 directement à la ligne 4 — le chemin « sans coupon » — n'a jamais eu lieu.
La couverture des branches est plus stricte et plus proche de la vérité. Il y a peu de raisons de ne pas l'avoir active en permanence.
Faire du chiffre un verrou : --cov-fail-under
Un rapport que personne ne lit ne change rien. --cov-fail-under transforme le total en condition de réussite ou d'échec :
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing --cov-fail-under=90
.
ERROR: Coverage failure: total of 83 is less than fail-under=90
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
FAIL Required test coverage of 90% not reached. Total coverage: 83.33%
1 passed in 0.12sLisez la dernière ligne attentivement : 1 passed. Chaque test a réussi, et pourtant la commande se termine avec le statut 1. Dans l'intégration continue, c'est un build rouge. Le point n'est pas que 90 soit un nombre magique ; c'est que la couverture ne peut plus glisser silencieusement lorsque quelqu'un ajoute du code sans tests.
Choisissez un seuil un peu en dessous de votre niveau actuel et élevez-le avec le temps. Le fixer à 100 dès le premier jour se termine généralement par des gens qui écrivent des tests sans le moindre intérêt pour satisfaire le chiffre.
Exclure du code à dessein : # pragma: no cover
Certaines lignes ne valent pas la peine d'être testées — un bloc qui ne s'exécute que lorsque vous lancez le fichier à la main, par exemple. Ajoutez un second test, assert final_price(200.0, None) == 200.0, pour que final_price soit entièrement couvert — puis un bloc __main__ au module :
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11sLa ligne 8, le print, ne s'exécute jamais sous pytest — et ne s'exécutera jamais. Marquez le if avec un commentaire que coverage comprend :
if __name__ == "__main__": # pragma: no cover
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 0 100%
--------------------------------------------------------------
TOTAL 4 0 2 0 100%
2 passed in 0.08sLorsque le pragma se trouve sur une ligne qui ouvre un bloc, tout le bloc est exclu. Utilisez-le avec parcimonie et honnêteté. Chaque pragma est une ligne que vous avez décidé de ne pas vérifier ; si vous vous surprenez à en ajouter un pour faire « disparaître » une branche difficile, cette branche est probablement celle qui a le plus besoin d'un test.
Le mettre dans pyproject.toml
Taper quatre drapeaux --cov à chaque fois, c'est comme ça qu'on les oublie. coverage lit ses propres réglages depuis pyproject.toml, et le addopts que vous avez rencontré au chapitre précédent fait que pytest l'active :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true
[tool.coverage.report]
fail_under = 90
exclude_also = [
'if __name__ == "__main__":',
][tool.coverage.run]pilote la mesure :sourceest ce qu'il faut surveiller (donc un--covnu sans valeur suffit),branch = truevaut--cov-branch.[tool.coverage.report]pilote le rapport :fail_undervaut--cov-fail-under, etexclude_alsoliste des motifs à exclure en plus de# pragma: no cover— ici chaque bloc__main__, sans aucun commentaire nécessaire dans le code.
Désormais, un simple pytest mesure, rapporte et applique le verrou. Vous le verrez à l'œuvre dans l'exemple complet ci-dessous.
100 % de couverture ne veut pas dire correct
La couverture répond « est-ce que cette ligne s'est exécutée ? ». Elle ne répond pas « est-ce que quelqu'un a vérifié que le résultat était bon ? ». Un petit exemple :
def is_leap_year(year: int) -> bool:
return year % 4 == 0from shop.calendar_rules import is_leap_year
def test_2024_is_a_leap_year():
assert is_leap_year(2024)
def test_2023_is_not():
assert not is_leap_year(2023)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
2 passed in 0.12sScore parfait. Et la fonction est fausse : une année divisible par 100 n'est pas une année bissextile, sauf si elle est aussi divisible par 400. Un test de plus, choisi en pensant à la règle plutôt qu'aux lignes :
def test_1900_is_not():
assert not is_leap_year(1900)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
..F [100%]
=================================== FAILURES ===================================
_______________________________ test_1900_is_not _______________________________
def test_1900_is_not():
> assert not is_leap_year(1900)
E assert not True
E + where True = is_leap_year(1900)
tests/test_calendar_rules.py:13: AssertionError
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
=========================== short test summary info ============================
FAILED tests/test_calendar_rules.py::test_1900_is_not - assert not True
1 failed, 2 passed in 0.12sToujours 100 % — et désormais un échec. La couverture n'aurait jamais pu pointer ce bug, parce qu'il n'y avait pas de ligne manquante ; ce qui manquait, c'était un cas. Traitez la couverture comme une carte de là où vous n'avez certainement pas regardé, jamais comme une preuve que ce que vous avez regardé est juste.
Exécuter les tests en parallèle avec pytest-xdist
À mesure qu'une suite grossit, elle devient lente, et une suite lente est exécutée moins souvent. pytest-xdist répartit les tests sur plusieurs processus. Huit tests qui prennent chacun une demi-seconde :
import time
import pytest
@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
time.sleep(0.5)
assert n >= 0$ pytest -q test_slow.py
........ [100%]
8 passed in 4.10s
$ pytest -n auto test_slow.py
============================= test session starts ==============================
created: 4/4 workers
4 workers [8 items]
........ [100%]
============================== 8 passed in 1.52s ===============================-n auto démarre un worker par cœur CPU (quatre sur cette machine) et confie à chacun des tests à exécuter. Cela fonctionne avec --cov ; les données de couverture de tous les workers sont combinées en un seul rapport.
Il y a un prix à payer. Chaque worker est un processus séparé, et l'ordre dans lequel les tests s'exécutent n'est plus l'ordre du fichier. Tout test qui comptait silencieusement sur le fait qu'un autre s'était exécuté en premier cassera :
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1$ pytest -q test_cart.py
.. [100%]
2 passed in 0.07s
$ pytest -n 2 test_cart.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.F [100%]
=================================== FAILURES ===================================
____________________________ test_cart_has_one_item ____________________________
[gw1] linux -- Python 3.12.3 /path/to/venv/bin/python
def test_cart_has_one_item():
> assert len(CART) == 1
E assert 0 == 1
E + where 0 = len([])
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_cart_has_one_item - assert 0 == 1
========================= 1 failed, 1 passed in 0.37s ==========================Le second test ne passait que parce que le premier avait déjà rempli la liste partagée. Sur un worker différent, la liste était vide. Exécuter pytest test_cart.py::test_cart_has_one_item tout seul échoue de la même façon — xdist n'a pas créé le bug, il l'a révélé.
Le correctif est celui des chapitres sur les fixtures : donner à chaque test son propre état.
import pytest
@pytest.fixture
def cart():
return ["pen"]
def test_add_item(cart):
cart.append("bag")
assert cart == ["pen", "bag"]
def test_cart_has_one_item(cart):
assert len(cart) == 1$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.. [100%]
============================== 2 passed in 0.39s ===============================Un test doit passer seul, dans n'importe quel ordre, sur n'importe quel worker. Il existe un plugin, pytest-randomly, construit entièrement sur cette idée : il mélange l'ordre des tests à chaque exécution, donc une dépendance cachée apparaît tôt, sur votre machine, plutôt que des mois plus tard dans l'intégration continue. Vous n'en avez pas besoin pour suivre ce cours ; il suffit de savoir que « la suite ne passe que dans l'ordre du fichier » est un bug.
L'exécuter à chaque push : GitHub Actions
La dernière étape est d'arrêter de compter sur le fait que les gens se souviennent d'exécuter la suite. Déclarez les outils de test comme un groupe de développement dans pyproject.toml, pour que uv sync les installe :
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]Puis ajoutez .github/workflows/tests.yml :
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest -n auto --cov-report=xml
- name: Keep the coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-xml
path: coverage.xmlCe que fait chaque partie :
on:— s'exécute à chaque push surmainet à chaque pull request.matrix:— le job s'exécute trois fois, une par version de Python, côte à côte.fail-fast: falselaisse les trois se terminer, pour que vous voyiez si un échec est spécifique à une version.setup-uvinstalle uv et fixe la version de Python pour cette copie du job.uv sync --lockedinstalle exactement ce que dituv.lock, et échoue si le fichier de verrouillage est périmé — ainsi l'intégration continue teste ce que vous avez commité.uv run pytest -n auto --cov-report=xmls'exécute en parallèle ; les réglages de couverture,fail_undercompris, viennent depyproject.toml, donc une baisse de couverture fait échouer le build. Le rapport XML se place à côté de celui du terminal, prêt pour un service de couverture ou pour le téléchargement.
En local, après uv sync, la même commande donne le même résultat (les dernières lignes de la sortie) :
$ uv run pytest -n auto --cov-report=xml
TOTAL 15 0 10 1 96%
Coverage XML written to file coverage.xml
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.43s ===============================Un exemple complet
Un projet avec une disposition src, les deux modules de ce chapitre, et chaque réglage au même endroit.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml est celui de la section configuration ci-dessus. coupons.py garde son bloc __main__, sans pragma — exclude_also s'en occupe. test_coupons.py n'a toujours que le test SAVE10. Et test_shipping.py est désormais le plan de tests du début du chapitre, ligne pour ligne :
import pytest
from shop.shipping import shipping_cost
@pytest.mark.parametrize(
("weight", "country", "expected"),
[
(1, "BD", 60.0),
(1, "IN", 90.0),
(1, "US", 500.0),
(5, "BD", 60.0),
(7, "BD", 100.0),
],
)
def test_shipping_cost(weight, country, expected):
assert shipping_cost(weight, country) == expected
def test_zero_weight_is_rejected():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "BD")$ pytest
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
collected 7 items
tests/test_coupons.py . [ 14%]
tests/test_shipping.py ...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/coupons.py 4 0 2 1 83% 2->4
src/shop/shipping.py 11 0 8 0 100%
------------------------------------------------------------------
TOTAL 15 0 10 1 96%
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.12s ===============================Trois choses méritent d'être remarquées.
Premièrement, un simple pytest a tout fait — aucun drapeau. La mesure, la vérification des branches, la liste des lignes manquantes et le seuil venaient tous de pyproject.toml.
Deuxièmement, shipping.py est passé de 64 % à 100 % parce que le plan a été écrit, non parce que nous avons couru après les lignes rouges. La couverture confirme ensuite que le plan a atteint chaque ligne et chaque branche. La ligne (5, "BD", 60.0) et le choix de 0 plutôt que -1 pour le poids invalide n'ajoutent rien au pourcentage — elles sont là parce que le contrat a des bords, et les bords sont l'endroit où vivent les fautes de frappe comme < pour <=.
Troisièmement, le build passe à 96 % alors que 2->4 est toujours listé. Le seuil est un plancher, pas une ligne d'arrivée. Le rapport vous dit toujours exactement quel test écrire ensuite : final_price sans coupon.
En cas de problème
error: unrecognized arguments: --cov=shop pytest-cov n'est pas installé dans l'environnement depuis lequel pytest s'exécute. Installez-le (uv add --dev pytest-cov) et exécutez à travers cet environnement, par exemple avec uv run pytest. Le même message avec -n signifie que pytest-xdist manque.
CoverageWarning: Module shopp was never imported. (module-not-imported) suivi de No data was collected. Le nom après --cov= ne correspond à aucun package que vos tests ont importé — ici une faute de frappe. Vérifiez l'orthographe, et avec une disposition src donnez le nom d'import (shop), pas le dossier (src).
WARNING: Failed to generate report: No data to report. Même cause que ci-dessus : rien n'a été mesuré, donc rien à rapporter. Les tests ont quand même réussi, et c'est exactement pourquoi celui-ci est facile à manquer.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% Pas un test cassé — le seuil. Regardez la colonne Missing et écrivez le test qu'elle désigne. Baisser fail_under pour rendre le build vert annule tout l'intérêt de l'avoir.
Un test passe tout seul mais échoue sous -n auto (ou l'inverse) Les tests partagent un état : une liste ou un dict au niveau module, un fichier à un chemin fixe, une variable d'environnement, une ligne de base de données. Trouvez ce que le test en échec suppose déjà présent, et donnez-le plutôt au test via une fixture, tmp_path ou monkeypatch.
La couverture est à 100 % mais les utilisateurs rencontrent quand même des bugs Compte attendu. La couverture montre ce qui s'est exécuté, pas ce qui a été vérifié. Cherchez des cas — frontières, entrées vides, l'année 1900 —, pas des lignes.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Il n'y a qu'un seul test, et la couverture des branches est active. Que montre la colonne Missing sur la ligne coupons.py ?
# shop/coupons.py
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
# tests/test_coupons.py
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0
# command: pytest -q --cov=shop --cov-branch --cov-report=term-missing- ARien — 100 % couvert
- B4
- C2->4
- D3
Avec pytest, les deux tests passent, mais avec pytest -n 2, test_cart_has_one_item échoue. Quel est le vrai problème ?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdist a un bug ; les tests vont bien
- BLe second test dépend du fait que le premier s'est exécuté — les deux tests partagent un état
- C`-n 2` exécute chaque test deux fois
- D`CART` est au niveau du module, il n'est donc jamais créé sur les workers
Une fonction a 100 % de couverture des lignes et des branches, et chaque test passe. Que pouvez-vous en conclure ?
- ASeulement que chaque ligne et chaque côté de chaque décision s'est exécuté au moins une fois — pas que les résultats sont justes
- BAvec `fail_under = 100`, aucun bug ne peut plus entrer dans la fonction
- CLa fonction ne peut pas avoir de bug de frontière
- DLa fonction est correcte et n'a pas besoin de plus de tests
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Démarrez un petit projet avec ce module, src/shop/loyalty.py :
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return pointsLe contrat : un point pour chaque tranche complète de 100 dépensés, doublé pour les membres ; un total négatif est une erreur ; zéro est autorisé. Il arrive avec un seul test, tests/test_loyalty.py :
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4Et un pyproject.toml qui mesure la couverture des branches à chaque exécution :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- Exécutez
pytest -qet lisez la colonneMissing. Dites en mots ce que signifie chaque entrée. - Avant d'écrire le moindre test, écrivez une table de plan de tests pour le contrat — y compris la frontière à exactement 100 et le bord juste en dessous.
- Transformez le plan en un test paramétré plus un test
pytest.raises. - Ajoutez
fail_under = 100sous[tool.coverage.report]et confirmez que l'exécution passe. - Supprimez les lignes non-membre et confirmez que l'exécution échoue bien que chaque test restant passe.
- Exécutez la suite avec
pytest -n autoet confirmez que le résultat est identique.
Solution
Étape 1 — lire le rapport.
$ pytest -q
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 1 4 2 73% 3, 5->7
------------------------------------------------------------------
TOTAL 7 1 4 2 73%
1 passed in 0.09s3 est la ligne du raise : aucun test n'a passé un total négatif. 5->7 est un saut du if is_member: directement à return points — le chemin non-membre, jamais emprunté. Sans couverture des branches, 5->7 n'apparaîtrait pas du tout, parce que la ligne 6 s'est exécutée (pour le membre).
Étape 2 — le plan.
| cas | entrée | attendu | | --- | --- | --- | | membre, doublé | 250, True | 4 | | non-membre | 250, False | 2 | | juste en dessous du premier 100 | 99.99, False | 0 | | frontière : exactement 100 | 100, False | 1 | | zéro est autorisé | 0, True | 0 | | invalide : négatif | -1, True | ValueError, « cannot be negative » |
Étape 3 — les tests. tests/test_loyalty.py :
import pytest
from shop.loyalty import loyalty_points
@pytest.mark.parametrize(
("total", "is_member", "expected"),
[
(250, True, 4), # member: points doubled
(250, False, 2), # non-member: the `if` is skipped
(99.99, False, 0), # just under the first 100
(100, False, 1), # exactly on the boundary
(0, True, 0), # zero is allowed, not an error
],
)
def test_loyalty_points(total, is_member, expected):
assert loyalty_points(total, is_member) == expected
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="cannot be negative"):
loyalty_points(-1, True)Étape 4 — le verrou. Ajoutez à pyproject.toml :
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sÉtape 5 — prouver que le verrou fonctionne. Avec les trois lignes False supprimées :
$ pytest -q
...
ERROR: Coverage failure: total of 91 is less than fail-under=100
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 1 91% 5->7
------------------------------------------------------------------
TOTAL 7 0 4 1 91%
FAIL Required test coverage of 100.0% not reached. Total coverage: 90.91%
3 passed in 0.12s3 passed, statut de sortie 1. Chaque ligne s'exécute toujours — seule la branche a disparu — et le verrou l'attrape. Remettez les lignes.
Étape 6 — en parallèle.
$ pytest -n auto
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
created: 4/4 workers
4 workers [6 items]
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
============================== 6 passed in 0.63s ===============================Les mêmes six tests, les mêmes 100 % : la couverture des quatre workers a été fusionnée en un seul rapport.
Pourquoi chaque décision.
- Le plan est venu avant les tests. Des six lignes, seules deux étaient exigées par le rapport (
3et5->7).99.99et100sont là parce que le contrat a un bord à 100, et0, Trueparce que le contrat dit que zéro est autorisé — un test qu'il ne lève pas d'exception. La couverture aurait été satisfaite sans aucune d'elles. - Pourquoi `-1` convient ici. La garde est
total < 0, donc le bord lui-même est0, et la ligne0, Truele fixe déjà comme valide. Si quelqu'un changeait la garde entotal <= 0, cette ligne échouerait. N'importe quel négatif convient alors pour le côté invalide. - Pourquoi `fail_under = 100` ici mais pas sur un vrai projet. Un module aussi petit peut raisonnablement être entièrement couvert. Sur une grosse base de code, commencez juste en dessous du chiffre du jour et élevez-le.
- Pourquoi l'exécution `-n auto` prouve quelque chose. Chaque cas reçoit ses propres arguments et rien n'est partagé entre les tests, donc l'ordre et le worker n'ont pas d'importance. Si le résultat avait changé sous
-n auto, c'aurait été le bug à corriger en premier.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz