Découverte et exécution des tests — ce que pytest exécute et comment choisir
Quels fichiers, fonctions et classes pytest traite comme des tests, importer votre code depuis une arborescence src/ et tests/, node IDs, et exécuter exactement ce dont vous avez besoin avec -k, -x, --lf, --ff et --co.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Jusqu'ici, chaque test résidait dans un seul fichier, à côté du code qu'il teste, et un simple pytest suffisait. Les projets réels ne restent pas aussi modestes. Le code se déplace dans un package, les tests se déplacent dans leur propre dossier, et l'exécution suivante ressemble à ceci :
$ pytest -q
==================================== ERRORS ====================================
_____________________ ERROR collecting tests/test_cart.py ______________________
ImportError while importing test module '/home/you/shop/tests/test_cart.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_cart.py:1: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sPas un seul test ne s'est exécuté. Le code est tout à fait correct ; pytest ne pouvait simplement pas le trouver.
Une fois ce problème résolu, un second problème survient : il y a désormais des dizaines de tests, l'un d'eux échoue, et chaque nouvelle exécution les relance tous. Vous souhaitez exécuter uniquement celui-là — ou seulement ceux qui ont échoué la dernière fois.
Ces deux problèmes proviennent de la même source. Avant que pytest n'exécute quoi que ce soit, il collecte : il parcourt les dossiers, importe les fichiers et construit une liste de tests. Ce chapitre traite de la manière dont cette liste est construite, et de la façon d'y faire son choix.
À la fin de ce chapitre, vous saurez
- Énoncer les règles que
pytestutilise pour déterminer ce qui constitue un test - Organiser un projet avec
src/ettests/, et permettre aux tests d'importer le code - Regrouper des tests dans des classes, et lire un node ID tel que
tests/test_cart.py::TestCart::test_total_adds_tax - Exécuter un fichier, une classe, un test, ou une sélection de tests avec
-k - Utiliser
-x,--maxfail,--lf,--ff,--coet--durationsau quotidien - Lire le code de sortie de
pytestet expliquer sa signification
Prérequis : assert et lecture des rapports d'échec.
Avant d'écrire le test
Dans ce chapitre, la question qui se pose avant même d'écrire la première ligne de code de test n'est pas seulement quoi tester, mais où le test va se trouver et comment `pytest` va le trouver. Un test qui n'est jamais collecté est pire que pas de test du tout : il a l'apparence d'une protection alors qu'il n'en est rien.
1. Connaître la promesse. Le code que nous allons tester est un petit package pour une boutique :
apply_discount(price, percent)renvoie le prix réduit depercentpour cent, arrondi à 2 décimales. Un pourcentage en dehors de 0–100 lève uneValueError.add_tax(price, rate=0.15)renvoie le prix avec la taxe ajoutée, arrondi à 2 décimales.Cartrassemble des articles sous la forme(name, price, quantity);subtotal()multiplie par la quantité ;total()correspond au sous-total avec la taxe.
2. Préparer l'environnement. Un environnement virtuel avec pytest installé ; le code organisé dans src/shop/ ; et — ce sur quoi porte ce chapitre — un moyen pour les tests d'effectuer import shop. Si cette dernière partie manque, rien d'autre n'a d'importance.
3. Planifier les cas, et décider où chacun réside. Un fichier de test par module (tests/test_pricing.py reflète src/shop/pricing.py), et les tests du panier regroupés dans une classe. Inscrire le node ID dans le plan vous oblige à trancher cela dès le départ :
| Cas | Entrée | Attendu | Node ID | |---|---|---|---| | Remise normale | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | Limite : aucune remise | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | Taxe par défaut | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | Cas limite : panier vide | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | Prise en compte de la quantité | 3 stylos à 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | Le total inclut la taxe | 1 sac à 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | Pourcentage invalide | apply_discount(200.0, 150) | ValueError | plus tard — tester les exceptions fait l'objet d'un chapitre dédié |
4. Décider de ce qu'il ne faut pas tester. Ne testez pas round() ni list.append — le comportement propre de Python ne constitue pas votre promesse. Ne testez pas le fait que Cart.items soit une liste de tuples ; c'est un détail interne que vous pourriez modifier demain. Testez ce qu'un appelant voit : subtotal() et total().
Le présent chapitre construit précisément ce plan, et chaque difficulté rencontrée en chemin est un problème de découverte.
Les règles de la découverte
pytest n'exécute pas tout ce qu'il trouve au hasard. Il applique quatre règles, et tout ce qui ne s'y conforme pas est ignoré sans dire un mot :
- Les fichiers nommés
test_*.pyou*_test.py - Les fonctions situées dans ces fichiers dont le nom commence par
test - Les classes dont le nom commence par
Test— et qui ne possèdent pas de méthode__init__ - Les méthodes au sein d'une telle classe dont le nom commence par
test
Pourquoi imposer des règles, au lieu de « exécuter chaque fonction » ? Parce que votre projet regorge de fonctions qui ne sont pas des tests — des fonctions d'aide (helpers), le code testé lui-même — et pytest a besoin d'un moyen de les distinguer sans que vous ayez à enregistrer quoi que ce soit.
La façon la plus rapide de constater ces règles est de les enfreindre délibérément. test_rules.py :
def test_found():
assert True
def check_not_found():
assert False
class TestGroup:
def test_method_found(self):
assert True
def helper(self):
assert False
class CartTests:
def test_ignored(self):
assert False
class TestWithInit:
def __init__(self):
self.value = 1
def test_never_runs(self):
assert self.value == 1À côté, rules_test.py contient un test réussi nommé test_suffix_style, et helpers.py contient un test_in_helpers qui effectue assert False. Lancez maintenant pytest -v :
collecting ... collected 3 items
rules_test.py::test_suffix_style PASSED [ 33%]
test_rules.py::test_found PASSED [ 66%]
test_rules.py::TestGroup::test_method_found PASSED [100%]
=============================== warnings summary ===============================
test_rules.py:22
/home/you/disc/test_rules.py:22: PytestCollectionWarning: cannot collect test class 'TestWithInit' because it has a __init__ constructor (from: test_rules.py)
class TestWithInit:
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================= 3 passed, 1 warning in 0.01s =========================Il y a cinq lignes avec assert False dans ce dossier, et pourtant l'exécution est au vert. Qu'est-ce qui a été ignoré, et pourquoi :
check_not_found— son nom ne commence pas partest.TestGroup.helper— elle se trouve dans une classe de test, mais son propre nom ne commence pas partest. Les méthodes utilitaires sont autorisées ; ce ne sont tout simplement pas des tests.CartTests— le nom de la classe doit commencer parTest.TestWithInit—pytestdoit instancier la classe lui-même, sans aucun argument, une fois par test. On ne peut pas être certain qu'une classe ayant son propre__init__le permette, elle est donc ignorée — avec un avertissement, le seul signal sonore de toute l'exécution.helpers.py— le nom du fichier ne correspond à aucun des deux motifs, il n'est donc même jamais importé.
Ce qui est silencieux est ce qui est dangereux. Si vous écrivezdef tset_total():par mégarde, la suite de tests reste verte pour toujours. Lorsqu'un nouveau test « réussit » avec une facilité suspecte, vérifiez qu'il a bien été collecté — l'option--co, détaillée plus bas, sert justement à cela.
Une structure réelle : src/ et tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyLe code réside dans src/shop/ ; les tests se trouvent dans tests/ et ne sont jamais distribués avec le package. C'est la structure src (src layout). Son intérêt est que le code ne peut pas être importé « par accident » simplement parce que vous vous trouvez dans le dossier du projet — ainsi, les tests l'importent exactement de la même manière qu'un véritable utilisateur le ferait.
src/shop/pricing.py :
def apply_discount(price, percent):
if not 0 <= percent <= 100:
raise ValueError(f"percent must be between 0 and 100: {percent}")
return round(price * (100 - percent) / 100, 2)
def add_tax(price, rate=0.15):
return round(price * (1 + rate), 2)src/shop/cart.py :
from shop.pricing import add_tax
class Cart:
def __init__(self):
self.items = []
def add(self, name, price, quantity=1):
self.items.append((name, price, quantity))
def subtotal(self):
return sum(price * quantity for _, price, quantity in self.items)
def total(self):
return add_tax(self.subtotal())tests/test_pricing.py — les trois premières lignes du plan :
from shop.pricing import add_tax, apply_discount
def test_discount_ten_percent():
assert apply_discount(200.0, 10) == 180.0
def test_discount_zero():
assert apply_discount(200.0, 0) == 200.0
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0Cette ligne from shop.pricing import ... est précisément ce qui a provoqué l'erreur ModuleNotFoundError au tout début du chapitre.
Pourquoi shop est introuvable
import shop ne réussit que si un dossier figurant dans sys.path contient un dossier shop. Le package réside dans src/, et src/ ne figure pas dans sys.path. pytest ajoute le dossier de chaque fichier de test (ici tests/) pour qu'un test puisse importer ses voisins — mais rien n'ajoute src/.
La mauvaise manière consiste à modifier sys.path à l'intérieur du fichier de test :
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0Depuis le dossier du projet, le test réussit. Puis quelqu'un l'exécute depuis l'intérieur de tests/ :
$ cd tests
$ pytest -q
==================================== ERRORS ====================================
________________________ ERROR collecting test_cart.py _________________________
ImportError while importing test module '/home/you/shop/tests/test_cart.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_cart.py:5: in <module>
from shop.cart import Cart
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_cart.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" est un chemin relatif à l'endroit exact où vous vous trouvez, de sorte que le test dépend désormais du dossier dans lequel vous tapez la commande. De plus, chaque fichier de test nécessiterait les trois mêmes lignes. Il existe deux bonnes manières de procéder.
Bonne manière 1 : indiquer à pytest où se trouve le code. Dans pyproject.toml :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpath ajoute src/ à sys.path avant que le moindre test ne soit importé — résolu depuis la racine du projet, et non depuis l'endroit où vous vous tenez. testpaths indique où chercher lorsque vous tapez simplement pytest. À présent :
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_cart.py ... [ 50%]
tests/test_pricing.py ... [100%]
============================== 6 passed in 0.01s ===============================configfile: pyproject.toml dans l'en-tête est la preuve que pytest a bien pris en compte vos paramètres. Les fichiers de configuration feront l'objet d'un chapitre dédié plus tard ; ces deux lignes sont tout ce dont vous avez besoin pour le moment.
Bonne manière 2 : installer le package. Si pyproject.toml décrit un véritable package (une table [build-system] et une table [project]), exécutez pip install -e . une fois. Cela installe le package en mode éditable (editable mode) : import shop fonctionne désormais partout — tests, REPL, scripts — et les modifications apportées à src/ sont prises en compte sans réinstallation. Ensuite, pytest -q donne le même 6 passed sans aucun pythonpath. La plupart des projets distribués sous forme de package font cela ; pythonpath est l'option la plus rapide lorsque le projet n'est pas un package.
Regrouper les tests dans des classes
tests/test_cart.py place les lignes du panier prévues dans le plan au sein d'une seule classe :
from shop.cart import Cart
class TestCart:
def test_empty_cart_subtotal(self):
assert Cart().subtotal() == 0
def test_subtotal_counts_quantity(self):
cart = Cart()
cart.add("pen", 15.0, quantity=3)
assert cart.subtotal() == 45.0
def test_total_adds_tax(self):
cart = Cart()
cart.add("bag", 100.0)
assert cart.total() == 115.0$ pytest -q tests/test_cart.py
... [100%]
3 passed in 0.01sIl n'y a pas de classe de base dont hériter — TestCart est une classe ordinaire, et self est simplement le premier paramètre obligatoire. Pourquoi s'embêter ? Parce que la classe donne un nom unique à des tests liés, et ce nom devient un élément que vous pouvez sélectionner : exécuter « tous les tests du panier » avec un seul argument.
Ce à quoi la classe ne sert pas, en revanche, c'est au partage d'état. pytest crée une nouvelle instance pour chaque méthode de test, de sorte qu'une valeur enregistrée sur self dans un test a disparu dans le suivant. C'est délibéré — des tests qui dépendent des restes d'autres tests réussissent ou échouent selon l'ordre dans lequel ils s'exécutent. Chaque test ci-dessus construit son propre Cart(). Les fixtures, abordées dans un chapitre ultérieur, sont l'outil approprié pour une configuration partagée.
Node IDs : l'adresse d'un test
Demandez à pytest de lister ce qu'il a collecté sans rien exécuter — --collect-only, ou en abrégé --co :
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
6 tests collected in 0.01sComparez cette sortie avec la dernière colonne du plan : chaque ligne s'y trouve, à l'adresse que vous aviez choisie. Chaque ligne est un node ID — le chemin du fichier, puis ::, puis la classe s'il y en a une, puis :: et le nom du test. Il s'agit de la même chaîne de caractères qui apparaît après FAILED dans un résumé d'échec, vous pouvez donc la copier depuis un échec et la coller directement dans votre ligne de commande.
Choisir ce qu'il faut exécuter
Par chemin ou par node ID
La mauvaise façon d'exécuter un seul test est de commenter les autres, ou de les renommer pour que pytest les ignore — et d'oublier ensuite d'annuler vos modifications. La bonne méthode consiste à donner une adresse à pytest :
$ pytest -q tests/test_pricing.py
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart
... [100%]
3 passed in 0.01s
$ pytest -q tests/test_cart.py::TestCart::test_total_adds_tax
. [100%]
1 passed in 0.01sUn dossier, un fichier, une classe, un test isolé — chaque niveau du node ID restreint la sélection, et le code n'est jamais modifié.
Par nom : -k
-k prend une expression et ne conserve que les tests dont le nom y correspond :
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sLa correspondance s'effectue par recherche de sous-chaîne insensible à la casse, et les termes se combinent avec and, or, not ainsi que des parenthèses. Entourez l'expression de guillemets afin que le shell la transmette comme un seul argument :
$ pytest -q -k "discount or tax"
.... [100%]
4 passed, 2 deselected in 0.01s
$ pytest -q -k "TestCart and not tax"
.. [100%]
2 passed, 4 deselected in 0.01sC'est pourquoi les noms de tests méritent d'être choisis avec soin : test_discount_zero peut être retrouvé avec discount, avec zero, ou avec les deux. Un nom comme test_2 ne peut être retrouvé par rien.
Un détail surprend tout le monde au moins une fois. -k effectue la correspondance sur le nom du test ainsi que sur les noms situés au-dessus de lui — sa classe et son fichier :
$ pytest --co -q -k "not cart"
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
3/6 tests collected (3 deselected) in 0.01sAucun des trois tests de TestCart ne comporte cart dans son propre nom, et pourtant tous les trois ont été éliminés — ils se trouvent dans test_cart.py. Lorsqu'une sélection avec -k vous surprend, ajoutez --co et observez avant de lancer l'exécution.
Sélectionner des tests au moyen d'une étiquette que vous attribuez vous-même, plutôt que par leur nom, est précisément le rôle des marqueurs et de l'option -m, abordés au chapitre douze.
Quand un test échoue
Cassez le code volontairement : changez la valeur par défaut de rate dans add_tax de 0.15 à 0.18. Deux tests échouent à présent. (--tb=no masque les rapports d'échec pour rendre l'allure générale de l'exécution bien visible ; vous avez appris à lire ces rapports au chapitre précédent.)
$ pytest -q --tb=no
..F..F [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01s-x s'arrête au premier échec. Quand une fonction défaillante fait échouer dix tests, le premier rapport est le seul qui vaille la peine d'être lu ; les neuf autres ne sont que des échos :
$ pytest -q -x --tb=no
..F
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 2 passed in 0.01s--maxfail=N s'arrête après N échecs. -x équivaut exactement à --maxfail=1.
--lf (last failed) réexécute uniquement ce qui a échoué la dernière fois. pytest mémorise les échecs dans un dossier .pytest_cache au sein du projet :
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 2 deselected in 0.01sSeuls les deux échecs se sont exécutés. Rétablissez le bon taux, et --lf réexécute ces deux-là qui réussissent désormais. Relancez --lf une nouvelle fois alors que plus rien n'échoue, et pytest affiche run-last-failure: no previously failed tests, not deselecting items. puis exécute l'ensemble des tests.
--ff (failed first) exécute les échecs en premier, puis tout le reste. Le verdict rapide arrive en tête, la vérification complète de sécurité ensuite :
$ pytest -q --ff --tb=no
FF.... [100%]
=========================== short test summary info ============================
FAILED tests/test_cart.py::TestCart::test_total_adds_tax - assert 118.0 == 115.0
FAILED tests/test_pricing.py::test_add_tax_default_rate - assert 118.0 == 115.0
2 failed, 4 passed in 0.01sFF.... — les deux échecs se sont exécutés en premier, avant les quatre tests qui ont réussi.
Une boucle de travail efficace pendant la correction d'un bug : pytest --lf -x jusqu'à obtenir du vert, puis un simple pytest une fois. Pourquoi cette dernière étape ? Parce que --lf ne réexécute que ce qui était rouge — il ne peut pas vous avertir si votre correction a cassé quelque chose qui était auparavant au vert.
La sortie de vos tests : capture et -s
Un print à l'intérieur d'un test semble ne rien faire. (--tb=short ci-dessous sert simplement à raccourcir le rapport d'échec.) pytest capture tout ce qu'un test écrit à l'écran et ne l'affiche que si ce test échoue :
def test_quiet():
print("subtotal is", 45.0)
assert 45.0 == 45.0
def test_loud():
print("subtotal is", 40.0)
assert 40.0 == 45.0$ pytest -q --tb=short
.F [100%]
=================================== FAILURES ===================================
__________________________________ test_loud ___________________________________
test_print.py:8: in test_loud
assert 40.0 == 45.0
E assert 40.0 == 45.0
----------------------------- Captured stdout call -----------------------------
subtotal is 40.0
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sLa sortie du test qui a réussi a disparu ; la sortie du test qui a échoué est jointe à son rapport sous Captured stdout call. Avec des centaines de tests, c'est exactement ce que vous souhaitez — le silence quand tout va bien, des preuves quand ce n'est pas le cas. Pour tout voir au fil de l'exécution, -s désactive la capture :
$ pytest -q -s --tb=no
subtotal is 45.0
.subtotal is 40.0
F
=========================== short test summary info ============================
FAILED test_print.py::test_loud - assert 40.0 == 45.0
1 failed, 1 passed in 0.01sCodes de sortie
Lorsque pytest se termine, il renvoie un nombre au shell. Les systèmes de CI décident du « vert » ou du « rouge » uniquement d'après ce nombre :
| Code | Signification | |---|---| | 0 | Tous les tests collectés ont réussi | | 1 | Des tests ont été exécutés, et au moins un a échoué | | 2 | L'exécution a été interrompue — une erreur pendant la collecte, ou Ctrl+C | | 3 | Une erreur interne à pytest ou à un plugin | | 4 | Une erreur d'utilisation — une option inconnue, ou un chemin ou node ID inexistant | | 5 | Aucun test n'a été collecté |
Le code 5 mérite toute votre attention. Un -k qui ne correspond à rien, ou un dossier sans aucun fichier correctement nommé, n'est pas une réussite — et la CI le traite comme un échec, ce qui est parfaitement justifié :
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5L'erreur ModuleNotFoundError du début de chapitre s'était achevée par Interrupted: 1 error during collection — code de sortie 2. Une erreur de collecte n'est pas un test qui échoue ; pytest n'est jamais parvenu jusqu'à la phase d'exécution des tests.
Un exemple complet
Le plan est mis en œuvre et tout est au vert. À présent, un test lent rejoint tests/test_cart.py, dans sa propre classe :
from shop.cart import Cart
class TestBigCart:
def test_many_items(self):
cart = Cart()
for _ in range(300_000):
cart.add("pen", 1.0)
assert cart.subtotal() == 300_000.0Le fichier conserve toujours TestCart au-dessus de cette classe. Vérifiez d'abord ce que pytest voit, avant d'exécuter quoi que ce soit :
$ pytest --co -q
tests/test_cart.py::TestCart::test_empty_cart_subtotal
tests/test_cart.py::TestCart::test_subtotal_counts_quantity
tests/test_cart.py::TestCart::test_total_adds_tax
tests/test_cart.py::TestBigCart::test_many_items
tests/test_pricing.py::test_discount_ten_percent
tests/test_pricing.py::test_discount_zero
tests/test_pricing.py::test_add_tax_default_rate
7 tests collected in 0.01sSept tests, chacun doté d'une adresse. Exécutez-les tous en demandant les trois plus lents :
$ pytest -q --durations=3
....... [100%]
============================= slowest 3 durations ==============================
0.04s call tests/test_cart.py::TestBigCart::test_many_items
(2 durations < 0.005s hidden. Use -vv to show these durations.)
7 passed in 0.12sUn seul test était suffisamment lent pour être listé. Sur un projet réel, --durations=10 est la méthode pour identifier la poignée de tests qui ralentissent l'ensemble de la suite. Pendant que vous travaillez sur le panier, laissez de côté le test lent :
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sTrois éléments ont rendu cela possible. pythonpath = ["src"] permet à from shop.cart import Cart de réussir tout court. TestBigCart est collectée parce que son nom commence par Test et qu'elle n'a pas de __init__. Et -k "cart and not many" a conservé exactement les tests de TestCart : cart a trouvé une correspondance avec tout ce qui se trouve dans test_cart.py via le nom de fichier, et not many a retiré le test lent.
En cas de problème
ModuleNotFoundError: No module named 'shop' pendant la collecte Le code testé n'est pas sur sys.path. Avec une structure en src/, ajoutez pythonpath = ["src"] sous [tool.pytest.ini_options], ou installez le package avec pip install -e .. L'exécution s'achève par Interrupted: 1 error during collection et le code de sortie 2.
Cela fonctionne avec python -m pytest mais pas avec pytest python -m pytest ajoute également le dossier courant à sys.path ; un simple pytest ne le fait pas. Ainsi, un module à la racine du projet est importable d'une façon et pas de l'autre. Ne comptez pas sur cette différence — configurez pythonpath, ou installez le package.
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor Les classes de test ne doivent pas définir de __init__. Supprimez-le et construisez ce dont chaque test a besoin à l'intérieur du test lui-même.
no tests ran, code de sortie 5 Rien ne correspondait aux règles de découverte. Vérifiez le nom de fichier (test_*.py ou *_test.py), les noms des fonctions (test...), les noms des classes (Test...). Si vous avez utilisé -k, il se peut qu'il ne corresponde à rien.
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax Le node ID est erroné. Ce test se trouve à l'intérieur de TestCart, son adresse est donc tests/test_cart.py::TestCart::test_total_adds_tax. Copiez les node IDs depuis pytest --co -q plutôt que de les taper à la main. Code de sortie 4.
ERROR: file or directory not found: tests/test_carts.py Une faute de frappe dans le chemin. Code de sortie 4.
pytest: error: unrecognized arguments: --maxfial=2 Une option mal orthographiée ; pytest --help affiche la liste des véritables options. Code de sortie 4.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Ce fichier est enregistré sous le nom test_rules.py. Lorsque vous exécutez pytest -q, combien de tests réussis la dernière ligne indique-t-elle ?
def test_found():
assert True
def check_total():
assert True
class TestGroup:
def test_method(self):
assert True
def helper(self):
assert True
class CartTests:
def test_ignored(self):
assert True- A2 passed
- B1 passed
- C3 passed
- D4 passed
Le code se trouve dans src/shop/pricing.py. pyproject.toml contient pythonpath = ["shop"] sous [tool.pytest.ini_options]. Pourtant, pytest signale ModuleNotFoundError: No module named 'shop'. Quel est le problème ?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- ALe nom de la fonction doit commencer par `Test`
- B`pythonpath` doit pointer vers le dossier qui *contient* le package `shop` : `["src"]`
- CLe nom du fichier de test doit se terminer par `_test.py`
- D`tests/` a besoin d'un `__init__.py` avant de pouvoir importer quoi que ce soit
Deux tests échouaient et vous avez corrigé le bug. Quelle commande réexécute uniquement ces deux tests ?
- Apytest --co
- Bpytest -x
- Cpytest --ff
- Dpytest --lf
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
Une bibliothèque facture des pénalités de retard. Construisez ce projet :
library/
├── src/
│ └── library/
│ ├── __init__.py
│ └── fees.py
└── tests/
└── test_fees.pysrc/library/fees.py :
def late_fee(days_late, daily_rate=5):
if days_late < 0:
raise ValueError(f"days_late cannot be negative: {days_late}")
return min(days_late * daily_rate, 100)- Rédigez d'abord un plan de test : à l'heure, quelques jours de retard, exactement au plafond, au-delà du plafond, un tarif journalier personnalisé.
- Écrivez
tests/test_fees.pyavec ces cas sous forme de méthodes d'une seule classe,TestLateFee. Exécutezpytestsans fichierpyproject.toml, lisez l'erreur, et vérifiezecho $?. - Ajoutez
pyproject.tomlpour que les tests puissent importerlibrary. Passez au vert. - Listez les node IDs avec
--co -q. Exécutez uniquement le test « au-delà du plafond » par son node ID. - Utilisez
-kpour n'exécuter que les tests relatifs au plafond, puis tout sauf eux. - Modifiez le plafond dans
fees.pyde100à90. Lancez la suite, puis--lf. Rétablissez la valeur initiale et lancez--lf, puis l'ensemble des tests.
Solution
Le plan :
| Cas | Entrée | Attendu | |---|---|---| | À l'heure | late_fee(0) | 0 | | Quelques jours de retard | late_fee(3) | 15 | | Limite : exactement au plafond | late_fee(20) | 100 | | Au-delà du plafond | late_fee(30) | 100 | | Tarif personnalisé | late_fee(3, daily_rate=2) | 6 | | Nombre de jours négatif | late_fee(-1) | ValueError — plus tard, avec pytest.raises |
tests/test_fees.py :
from library.fees import late_fee
class TestLateFee:
def test_on_time_costs_nothing(self):
assert late_fee(0) == 0
def test_three_days_late(self):
assert late_fee(3) == 15
def test_fee_reaches_cap_exactly(self):
assert late_fee(20) == 100
def test_fee_never_exceeds_cap(self):
assert late_fee(30) == 100
def test_custom_daily_rate(self):
assert late_fee(3, daily_rate=2) == 6Sans configuration, l'exécution s'interrompt lors de la collecte avec ModuleNotFoundError: No module named 'library', Interrupted: 1 error during collection, et echo $? affiche 2. Puis pyproject.toml :
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
..... [100%]
5 passed in 0.01s
$ pytest --co -q
tests/test_fees.py::TestLateFee::test_on_time_costs_nothing
tests/test_fees.py::TestLateFee::test_three_days_late
tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly
tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
tests/test_fees.py::TestLateFee::test_custom_daily_rate
5 tests collected in 0.01s
$ pytest -q tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap
. [100%]
1 passed in 0.01s
$ pytest -q -k cap
.. [100%]
2 passed, 3 deselected in 0.01s
$ pytest -q -k "not cap"
... [100%]
3 passed, 2 deselected in 0.01sAvec le plafond modifié à 90 :
$ pytest -q --tb=no
..FF. [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 passed in 0.01s
$ pytest -q --lf --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED tests/test_fees.py::TestLateFee::test_fee_reaches_cap_exactly - assert...
FAILED tests/test_fees.py::TestLateFee::test_fee_never_exceeds_cap - assert 9...
2 failed, 3 deselected in 0.01sAvec le plafond rétabli à 100, pytest -q --lf donne 2 passed, 3 deselected, et un pytest -q complet donne 5 passed.
Pourquoi ces décisions :
- Deux tests pour le plafond, pas un seul.
late_fee(20)se situe exactement sur la limite, le premier jour où la pénalité atteint le plafond ;late_fee(30)est largement au-delà. Les erreurs de décalage d'un (« off-by-one ») surviennent aux limites, la limite a donc droit à son propre test — et le second prouve que le plafond tient toujours au-delà. - Le mot
capfigure dans les noms des deux tests de plafond, et uniquement là. C'est ce qui a permis à-k capet-k "not cap"d'effectuer une sélection nette — les noms ont été choisis en pensant à la sélection. - Une classe, pas de
__init__, pas d'état partagé. Chaque méthode appellelate_feeelle-même ; la classe se contente de donner au groupe une adresse unique,tests/test_fees.py::TestLateFee. pythonpathplutôt qu'une lignesys.pathdans le fichier de test, afin que les tests réussissent quel que soit le dossier depuis lequel vous les lancez.- L'exécution complète après
--lf.--lfprouve que les deux tests rouges sont redevenus verts ; seule l'exécution complète prouve que les trois autres sont toujours au vert.
É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