Discovery en tests uitvoeren — wat pytest uitvoert en hoe je kiest
Welke bestanden, functies en klassen pytest als tests beschouwt, je code importeren vanuit een src/- en tests/-structuur, node-ID's, en precies uitvoeren wat je nodig hebt met -k, -x, --lf, --ff en --co.
Hoe wil je leren?
Het probleem dat we oplossen
Tot nu toe stond elke test in één bestand, naast de code die hij test, en was gewoon pytest voldoende. Echte projecten blijven niet zo klein. De code verhuist naar een package, de tests verhuizen naar een eigen map, en de allereerstvolgende run ziet er zo uit:
$ 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.01sEr werd niet één test uitgevoerd. Met de code is niets mis; pytest kon hem simpelweg niet vinden.
Zodra dat is opgelost, dient zich een tweede probleem aan: er zijn nu tientallen tests, een ervan faalt, en elke nieuwe run voert ze allemaal opnieuw uit. Je wilt alleen die ene uitvoeren — of alleen de tests die de vorige keer faalden.
Beide problemen komen voort uit dezelfde oorzaak. Voordat pytest ook maar iets uitvoert, vindt er collecting (verzamelen) plaats: het doorloopt de mappen, importeert bestanden en bouwt een lijst van tests op. Dit hoofdstuk gaat over hoe die lijst wordt opgebouwd en hoe je daaruit kiest.
Aan het einde van dit hoofdstuk kun je
- De regels benoemen die
pytestgebruikt om te bepalen wat een test is - Een project inrichten met
src/entests/, en zorgen dat de tests de code kunnen importeren - Tests groeperen in klassen en een node-ID zoals
tests/test_cart.py::TestCart::test_total_adds_taxlezen - Eén bestand, één klasse, één test of een selectie met
-kuitvoeren -x,--maxfail,--lf,--ff,--coen--durationsgebruiken in je dagelijkse werk- De exitcode van
pytestaflezen en uitleggen wat deze betekent
Voorkennis: assert en foutrapporten.
Voordat je de test schrijft
In dit hoofdstuk is de vraag vóór de eerste regel testcode niet alleen wat je test, maar ook waar de test komt te staan en hoe `pytest` hem vindt. Een test die nooit wordt verzameld, is erger dan helemaal geen test: het lijkt op bescherming, maar is het niet.
1. Ken de belofte. De code die we gaan testen is een klein shop-package:
apply_discount(price, percent)retourneert de prijs verminderd metpercent, afgerond op 2 decimalen. Een percentage buiten 0–100 veroorzaakt eenValueError.add_tax(price, rate=0.15)retourneert de prijs met belasting erbij opgeteld, afgerond op 2 decimalen.Cartverzamelt(name, price, quantity)-artikelen;subtotal()vermenigvuldigt met het aantal;total()is het subtotaal inclusief belasting.
2. Zorg dat de opzet klaarstaat. Een virtuele omgeving met pytest geïnstalleerd; de code ondergebracht in src/shop/; en — het deel waar dit hoofdstuk over gaat — een manier waarop de tests import shop kunnen doen. Als dat laatste deel ontbreekt, doet niets anders ertoe.
3. Plan de gevallen en bepaal waar elk geval komt te staan. Eén testbestand per module (tests/test_pricing.py weerspiegelt src/shop/pricing.py), en de cart-tests gegroepeerd in één klasse. Door het node-ID in het plan op te nemen, dwing je jezelf om dat vooraf te bepalen:
| Geval | Invoer | Verwacht | Node-ID | |---|---|---|---| | Normale korting | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | Grens: geen korting | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | Standaardbelasting | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | Randgeval: lege winkelwagen | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | Aantal telt mee | 3 pennen van 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | Totaal inclusief belasting | 1 tas van 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | Ongeldig percentage | apply_discount(200.0, 150) | ValueError | later — exceptions testen heeft een eigen hoofdstuk |
4. Bepaal wat je niet test. Test round() of list.append niet — het eigen gedrag van Python is niet jouw belofte. Test niet dat Cart.items een lijst van tuples is; dat is een intern detail dat je morgen kunt wijzigen. Test wat een aanroeper ziet: subtotal() en total().
Nu bouwt dit hoofdstuk exact dat plan op, en elk probleem onderweg is een discovery-probleem.
De regels voor discovery
pytest voert niet zomaar alles uit wat het tegenkomt. Het volgt vier regels, en alles wat daarbuiten valt wordt zonder een woord genegeerd:
- Bestanden met de naam
test_*.pyof*_test.py - Functies in die bestanden waarvan de naam begint met
test - Klassen waarvan de naam begint met
Test— en die geen__init__-methode hebben - Methoden in zo'n klasse waarvan de naam begint met
test
Waarom eigenlijk regels, in plaats van "voer elke functie uit"? Omdat je project vol staat met functies die geen tests zijn — helpers, de te testen code zelf — en pytest een manier nodig heeft om ze uit elkaar te houden zonder dat je iets hoeft te registreren.
De snelste manier om de regels te doorgronden, is door ze met opzet te breken. 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 == 1Daarnaast bevat rules_test.py één geslaagde test_suffix_style, en bevat helpers.py een test_in_helpers met assert False. Nu 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 =========================Er staan vijf regels met assert False in die map, en toch is de run groen. Wat werd er overgeslagen en waarom:
check_not_found— de naam begint niet mettest.TestGroup.helper— staat in een testklasse, maar de eigen naam begint niet mettest. Helper-methoden zijn toegestaan; het zijn alleen geen tests.CartTests— de klassenaam moet beginnen metTest.TestWithInit—pytestmoet de klasse zelf aanmaken, zonder argumenten, één keer per test. Van een klasse met een eigen__init__kan niet worden gegarandeerd dat dit kan, dus wordt deze overgeslagen — met een waarschuwing, het enige geluid in de hele run.helpers.py— de bestandsnaam past in geen van beide patronen, dus het bestand wordt niet eens geïmporteerd.
Het stille deel is het gevaarlijke deel. Als je per ongelukdef tset_total():schrijft, blijft de testsuite voor altijd groen. Wanneer een nieuwe test verdacht gemakkelijk "slaagt", controleer dan of hij überhaupt is verzameld — hieronder zie je hoe dat met--cokan.
Een echte structuur: src/ en tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyDe code staat in src/shop/; de tests staan in tests/ en worden nooit meegeleverd met het package. Dit is de src-structuur (src layout). Het doel ervan is dat de code niet "per ongeluk" kan worden geïmporteerd puur omdat je toevallig in de projectmap staat — de tests importeren de code dus op dezelfde manier als een echte gebruiker dat zou doen.
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 — de eerste drie rijen van het 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.0Die regel from shop.pricing import ... is precies wat bovenaan het hoofdstuk de ModuleNotFoundError opleverde.
Waarom shop niet kan worden gevonden
import shop slaagt alleen als een map op sys.path een map shop bevat. Het package staat in src/, en src/ staat niet op sys.path. pytest voegt de map van elk testbestand toe (hier tests/), zodat een test zijn buren kan importeren — maar niets voegt src/ toe.
De verkeerde manier is om sys.path handmatig aan te passen in het testbestand:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0Vanaf de projectmap slaagt dit. Vervolgens voert iemand het uit vanuit 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" is een relatief pad ten opzichte van waar je je toevallig bevindt, waardoor de test nu afhankelijk is van de map waarin je het commando typt. Bovendien heeft elk testbestand dezelfde drie regels nodig. Er zijn twee goede manieren.
Goede manier 1: vertel pytest waar de code staat. In pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpath voegt src/ toe aan sys.path voordat er ook maar een test wordt geïmporteerd — opgelost vanaf de projectroot, niet vanaf de plek waar je staat. testpaths geeft aan waar gezocht moet worden wanneer je gewoon pytest typt. Nu:
$ 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 in de kopregel is het bewijs dat pytest je instellingen heeft gelezen. Configuratiebestanden krijgen later een eigen hoofdstuk; deze twee regels zijn alles wat je nu nodig hebt.
Goede manier 2: installeer het package. Als pyproject.toml een echt package beschrijft (een [build-system] en een [project]-tabel), voer dan eenmalig pip install -e . uit. Daarmee installeer je het package in editable modus: import shop werkt nu overal — in tests, de REPL en scripts — en wijzigingen in src/ worden direct meegenomen zonder opnieuw te installeren. Daarna levert pytest -q dezelfde 6 passed op zonder dat er überhaupt een pythonpath nodig is. De meeste gepackagete projecten doen dit; pythonpath is de snellere keuze wanneer het project geen package is.
Tests groeperen in klassen
tests/test_cart.py plaatst de cart-rijen uit het plan in één klasse:
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.01sEr is geen basisklasse om van te overerven — TestCart is een gewone klasse, en self is slechts de verplichte eerste parameter. Waarom zou je de moeite nemen? Omdat de klasse gerelateerde tests één naam geeft, en die naam iets wordt wat je kunt selecteren: voer "alle cart-tests" uit met één enkel argument.
Waar de klasse niet voor bedoeld is, is het delen van status (state). pytest maakt een nieuwe instantie aan voor elke testmethode, dus een waarde die in de ene test op self is opgeslagen, is in de volgende verdwenen. Dat is met opzet zo gedaan — tests die afhangen van overblijfselen van elkaar, slagen of falen afhankelijk van de volgorde waarin ze worden uitgevoerd. Elke bovenstaande test bouwt zijn eigen Cart() op. Fixtures, in een later hoofdstuk, zijn het juiste gereedschap voor een gedeelde opzet.
Node-ID's: het adres van een test
Vraag pytest om een overzicht te tonen van wat het heeft verzameld zonder iets uit te voeren — --collect-only, of kortweg --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.01sVergelijk dit met de laatste kolom van het plan: elke rij staat er, op het adres dat je hebt gekozen. Elke regel is een node-ID — het bestandspad, dan ::, daarna de klasse als die er is, en vervolgens :: en de testnaam. Het is dezelfde string die na FAILED verschijnt in een foutoverzicht, zodat je hem kunt kopiëren uit een mislukking en direct weer in de commandoregel kunt plakken.
Kiezen wat je uitvoert
Op pad of node-ID
De verkeerde manier om één test uit te voeren is door de andere uit te commentariëren, of door ze te hernoemen zodat pytest ze overslaat — en vervolgens te vergeten dat terug te draaien. De juiste manier is om pytest een adres te geven:
$ 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.01sEen map, een bestand, een klasse, een afzonderlijke test — elk niveau van het node-ID verfijnt de selectie, en de code blijft onaangeroerd.
Op naam: -k
-k accepteert een expressie en behoudt de tests waarvan de naam ermee overeenkomt:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sDe vergelijking zoekt hoofdletterongevoelig naar substrings, en woorden kunnen worden gecombineerd met and, or, not en haakjes. Zet de expressie tussen aanhalingstekens zodat de shell deze als één argument doorgeeft:
$ 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.01sDit is waarom het de moeite waard is om testnamen zorgvuldig te kiezen: test_discount_zero kan worden gevonden via discount, via zero, of via beide. Een naam als test_2 kan door niets worden gevonden.
Eén detail overvalt iedereen wel een keer. -k matcht de naam van de test én de namen erboven — de klasse en het bestand:
$ 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.01sGeen van de drie TestCart-tests heeft cart in zijn eigen naam staan, en toch zijn ze alle drie afgevallen — ze staan immers in test_cart.py. Wanneer een selectie met -k je verrast, voeg dan --co toe en kijk eerst even voordat je uitvoert.
Selecteren op basis van een label dat je zelf toevoegt in plaats van op naam, is waar markers en -m voor bedoeld zijn, in hoofdstuk twaalf.
Wanneer er iets faalt
Breek de code met opzet: verander de standaardwaarde van rate in add_tax van 0.15 naar 0.18. Nu falen er twee tests. (--tb=no verbergt de foutrapporten zodat het verloop van de run zichtbaar is; die rapporten heb je in het vorige hoofdstuk leren lezen.)
$ 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 stopt bij de eerste fout. Wanneer één kapotte functie ervoor zorgt dat tien tests falen, is het eerste rapport degene die de moeite waard is om te lezen; de overige negen zijn slechts echo's:
$ 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 stopt na N mislukkingen. -x is exact hetzelfde als --maxfail=1.
--lf (last failed) voert alleen opnieuw uit wat de vorige keer faalde. pytest onthoudt mislukkingen in een .pytest_cache-map in het project:
$ 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.01sAlleen de twee mislukte tests werden uitgevoerd. Zet het tarief terug, en --lf voert die twee opnieuw uit en ze slagen. Voer --lf nog een keer uit wanneer er niets meer faalt, en pytest print run-last-failure: no previously failed tests, not deselecting items. en voert alles uit.
--ff (failed first) voert de mislukkingen eerst uit, en daarna de rest. Het snelle antwoord staat bovenaan, de volledige controle volgt erna:
$ 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.... — de twee mislukkingen werden eerst uitgevoerd, vóór de vier die slaagden.
Een handige werkwijze tijdens het oplossen van een bug: pytest --lf -x totdat alles groen is, en daarna één keer gewoon pytest. Waarom die laatste stap? Omdat --lf alleen herhaalt wat rood was — het kan je niet vertellen of je oplossing iets heeft gebroken wat voorheen groen was.
Uitvoer van je tests: capturing en -s
Een print binnen een test lijkt niets te doen. (--tb=short hieronder maakt het foutrapport simpelweg korter.) pytest vangt alles op wat een test naar het scherm schrijft (capturing) en toont dit alleen als die test faalt:
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.01sDe uitvoer van de geslaagde test is verdwenen; de uitvoer van de falende test is aan het rapport toegevoegd onder Captured stdout call. Bij honderden tests is dat precies wat je wilt — stilte wanneer alles in orde is, bewijsmateriaal wanneer dat niet zo is. Om alles direct te zien terwijl het gebeurt, schakelt -s capturing uit:
$ 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.01sExitcodes
Wanneer pytest klaar is, geeft het een getal terug aan de shell. CI-systemen bepalen "groen" of "rood" uitsluitend op basis van dat getal:
| Code | Betekenis | |---|---| | 0 | Alle verzamelde tests zijn geslaagd | | 1 | Tests zijn uitgevoerd en ten minste één test is mislukt | | 2 | De run werd onderbroken — een fout tijdens de verzameling (collection), of Ctrl+C | | 3 | Een interne fout binnen pytest of een plugin | | 4 | Een gebruiksfout — een onbekende optie, of een pad of node-ID dat niet bestaat | | 5 | Er zijn geen tests verzameld |
5 verdient extra aandacht. Een -k die met niets overeenkomt, of een map zonder correct benoemde bestanden, is niet geslaagd — en CI behandelt dit als een mislukking, wat volkomen terecht is:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5De ModuleNotFoundError aan het begin van het hoofdstuk eindigde in Interrupted: 1 error during collection — exitcode 2. Een verzamelfout is geen falende test; pytest is nooit zover gekomen dat er tests werden uitgevoerd.
Een compleet voorbeeld
Het plan is geïmplementeerd en staat op groen. Nu wordt er een trage test toegevoegd aan tests/test_cart.py, in een eigen klasse:
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.0Het bestand bevat boven deze klasse nog steeds TestCart. Controleer eerst wat pytest ziet, voordat je ook maar iets uitvoert:
$ 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.01sZeven tests, elk met een adres. Voer ze allemaal uit en vraag naar de traagste drie:
$ 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.12sSlechts één test was traag genoeg om te worden vermeld. Bij een echt project is --durations=10 de manier om het handjevol tests te vinden dat de hele suite traag doet aanvoelen. Terwijl je aan de cart werkt, laat je de trage test buiten beschouwing:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sDrie dingen zorgden ervoor dat dit werkte. Dankzij pythonpath = ["src"] kan from shop.cart import Cart überhaupt slagen. TestBigCart wordt verzameld omdat de naam begint met Test en er geen __init__ in zit. En -k "cart and not many" behield exact de tests van TestCart: cart matchte via de bestandsnaam met alles in test_cart.py, en not many verwijderde de trage test.
Wanneer het misgaat
ModuleNotFoundError: No module named 'shop' tijdens collection De te testen code staat niet op sys.path. Voeg bij een src/-structuur pythonpath = ["src"] toe onder [tool.pytest.ini_options], of installeer het package met pip install -e .. De run eindigt met Interrupted: 1 error during collection en exitcode 2.
Het werkt wel met python -m pytest, maar niet met pytest python -m pytest zet ook de huidige map op sys.path; gewoon pytest doet dat niet. Daardoor is een module in de projectroot op de ene manier wel importeerbaar en op de andere niet. Vertrouw niet op dat verschil — stel pythonpath in, of installeer het package.
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor Testklassen mogen geen __init__ definiëren. Verwijder deze en bouw wat elke test nodig heeft op binnen de test zelf.
no tests ran, exitcode 5 Niets kwam overeen met de discovery-regels. Controleer de bestandsnaam (test_*.py of *_test.py), de functienamen (test...) en de klassenamen (Test...). Als je -k hebt gebruikt, matcht dit mogelijk met niets.
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax Het node-ID klopt niet. Die test bevindt zich binnen TestCart, dus het adres is tests/test_cart.py::TestCart::test_total_adds_tax. Kopieer node-ID's uit pytest --co -q in plaats van ze handmatig te typen. Exitcode 4.
ERROR: file or directory not found: tests/test_carts.py Een typefout in het pad. Exitcode 4.
pytest: error: unrecognized arguments: --maxfial=2 Een verkeerd gespelde optie; pytest --help toont de geldige opties. Exitcode 4.
Stap 4 van 6 — Voorspellen
Test je begrip
Dit bestand is opgeslagen als test_rules.py. Wanneer je pytest -q uitvoert, hoeveel geslaagde tests meldt de laatste regel dan?
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
De code staat in src/shop/pricing.py. In pyproject.toml staat pythonpath = ["shop"] onder [tool.pytest.ini_options]. Toch meldt pytest de fout ModuleNotFoundError: No module named 'shop'. Wat is er mis?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- ADe functienaam moet beginnen met `Test`
- B`pythonpath` moet verwijzen naar de map die het package `shop` *bevat*: `["src"]`
- CDe naam van het testbestand moet eindigen op `_test.py`
- D`tests/` heeft een `__init__.py` nodig voordat er iets kan worden geïmporteerd
Twee tests faalden en je hebt de bug opgelost. Welk commando voert alleen die twee tests opnieuw uit?
- Apytest --co
- Bpytest -x
- Cpytest --ff
- Dpytest --lf
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Een bibliotheek rekent boetes voor te laat inleveren. Bouw dit project:
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)- Schrijf eerst een testplan: op tijd, een paar dagen te laat, exact op de cap (het maximum), voorbij de cap, een aangepast dagtarief.
- Schrijf
tests/test_fees.pymet die gevallen als methoden van één klasse,TestLateFee. Voerpytestuit zonder eenpyproject.toml, lees de foutmelding en controleerecho $?. - Voeg
pyproject.tomltoe zodat de testslibrarykunnen importeren. Zorg dat alles groen wordt. - Toon de node-ID's met
--co -q. Voer alleen de test voor "voorbij de cap" uit via zijn node-ID. - Gebruik
-kom alleen de tests over de cap uit te voeren, en daarna alles behalve die tests. - Verander de cap in
fees.pyvan100naar90. Voer de testsuite uit, en daarna--lf. Zet hem terug op100en voer--lfuit, en vervolgens alles.
Oplossing
Het plan:
| Geval | Invoer | Verwacht | |---|---|---| | Op tijd | late_fee(0) | 0 | | Een paar dagen te laat | late_fee(3) | 15 | | Grens: exact op de cap | late_fee(20) | 100 | | Voorbij de cap | late_fee(30) | 100 | | Aangepast tarief | late_fee(3, daily_rate=2) | 6 | | Negatieve dagen | late_fee(-1) | ValueError — later, met 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) == 6Zonder configuratie stopt de run bij collection met ModuleNotFoundError: No module named 'library', Interrupted: 1 error during collection, en print echo $? het getal 2. Vervolgens 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.01sMet het maximumbedrag gewijzigd naar 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.01sMet het maximumbedrag weer teruggezet op 100 geeft pytest -q --lf het resultaat 2 passed, 3 deselected, en een volledige pytest -q geeft 5 passed.
Waarom deze keuzes:
- Twee cap-tests, niet één.
late_fee(20)bevindt zich exact op de grens, de eerste dag dat de boete de cap bereikt;late_fee(30)zit er ruim voorbij. Off-by-one-fouten komen voor bij grenzen, dus de grens krijgt zijn eigen test — en de tweede bewijst dat de cap ook daarbuiten standhoudt. - Het woord
capstaat in de namen van beide cap-tests, en alleen daar. Dat zorgde ervoor dat-k capen-k "not cap"zuiver selecteerden — de namen zijn gekozen met selectie in het achterhoofd. - Eén klasse, geen
__init__, geen gedeelde status. Elke methode roept zelflate_feeaan; de klasse geeft de groep enkel één adres:tests/test_fees.py::TestLateFee. pythonpathin plaats van eensys.path-regel in het testbestand, zodat de tests slagen vanuit elke map waarin je ze uitvoert.- De volledige run na
--lf.--lfbewijst dat de twee rode tests weer groen zijn; alleen de volledige run bewijst dat de andere drie nog steeds groen zijn.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken