Test-Discovery und Tests ausführen – was pytest ausführt und wie du auswählst
Welche Dateien, Funktionen und Klassen pytest als Tests einstuft, wie du deinen Code aus einem src/- und tests/-Layout importierst, Node-IDs und wie du mit -k, -x, --lf, --ff und --co genau das ausführst, was du brauchst.
Wie möchtest du lernen?
Das Problem, das wir lösen
Bislang lag jeder Test in einer einzigen Datei, direkt neben dem Code, den er testet, und einfaches pytest hat völlig ausgereicht. Echte Projekte bleiben nicht so klein. Der Code wandert in ein Paket, die Tests wandern in einen eigenen Ordner, und der nächste Durchlauf sieht so aus:
$ 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.01sKein einziger Test lief. Der Code ist völlig in Ordnung; pytest konnte ihn schlichtweg nicht finden.
Sobald das behoben ist, taucht ein zweites Problem auf: Es gibt inzwischen Dutzende von Tests, einer davon schlägt fehl, und jeder erneute Durchlauf führt sie alle wieder aus. Du möchtest aber nur diesen einen ausführen — oder nur diejenigen, die beim letzten Mal fehlgeschlagen sind.
Beide Probleme haben dieselbe Ursache. Bevor pytest irgendetwas ausführt, sammelt es die Tests ein (Collection): Es durchsucht die Ordner, importiert Dateien und baut eine Liste von Tests auf. In diesem Kapitel geht es darum, wie diese Liste entsteht und wie du gezielt daraus auswählst.
Am Ende dieses Kapitels kannst du
- Die Regeln nennen, nach denen
pytestentscheidet, was ein Test ist - Ein Projekt mit
src/undtests/strukturieren und dafür sorgen, dass die Tests den Code importieren können - Tests in Klassen gruppieren und eine Node-ID wie
tests/test_cart.py::TestCart::test_total_adds_taxlesen - Eine Datei, eine Klasse, einen einzelnen Test oder eine mit
-kgetroffene Auswahl ausführen -x,--maxfail,--lf,--ff,--cound--durationsim Arbeitsalltag einsetzen- Den Exit-Code von
pytestlesen und erklären, was er bedeutet
Voraussetzungen: assert und Fehlerberichte.
Bevor du den Test schreibst
In diesem Kapitel lautet die Frage vor der ersten Zeile Testcode nicht nur, was getestet werden soll, sondern wo der Test liegen wird und wie `pytest` ihn findet. Ein Test, der nie erfasst wird, ist schlimmer als gar kein Test: Er wirkt wie eine Absicherung, ist aber keine.
1. Das Versprechen kennen. Der Code, den wir testen werden, ist ein kleines Shop-Paket:
apply_discount(price, percent)gibt den umpercentreduzierten Preis zurück, gerundet auf 2 Nachkommastellen. Ein Prozentwert außerhalb von 0–100 löst eine Exception (Ausnahme) aus, genauer einenValueError.add_tax(price, rate=0.15)gibt den Preis inklusive Steuern zurück, gerundet auf 2 Nachkommastellen.Cartsammelt(name, price, quantity)-Elemente;subtotal()multipliziert mit der Anzahl;total()ist die Zwischensumme mit Steuer.
2. Die Umgebung bereitstellen. Eine virtuelle Umgebung mit installiertem pytest; der Code strukturiert in src/shop/; und — worum es in diesem Kapitel geht — ein Weg für die Tests, import shop auszuführen. Fehlt dieser letzte Teil, spielt alles andere keine Rolle mehr.
3. Die Fälle planen und entscheiden, wo jeder einzelne hingehört. Eine Testdatei pro Modul (tests/test_pricing.py spiegelt src/shop/pricing.py wider), und die Warenkorb-Tests in einer gemeinsamen Klasse gruppiert. Wenn du die Node-ID direkt in den Plan schreibst, zwingt dich das, diese Entscheidung von vornherein zu treffen:
| Fall | Eingabe | Erwartet | Node-ID | |---|---|---|---| | Normaler Rabatt | apply_discount(200.0, 10) | 180.0 | tests/test_pricing.py::test_discount_ten_percent | | Grenze: kein Rabatt | apply_discount(200.0, 0) | 200.0 | tests/test_pricing.py::test_discount_zero | | Standard-Steuer | add_tax(100.0) | 115.0 | tests/test_pricing.py::test_add_tax_default_rate | | Randfall: leerer Warenkorb | Cart().subtotal() | 0 | tests/test_cart.py::TestCart::test_empty_cart_subtotal | | Menge zählt | 3 Stifte zu 15.0 | 45.0 | tests/test_cart.py::TestCart::test_subtotal_counts_quantity | | Gesamtbetrag enthält Steuer | 1 Tasche zu 100.0 | 115.0 | tests/test_cart.py::TestCart::test_total_adds_tax | | Ungültiger Prozentwert | apply_discount(200.0, 150) | ValueError | später — das Testen von Exceptions hat ein eigenes Kapitel |
4. Entscheiden, was nicht getestet wird. Teste nicht round() oder list.append — Pythons eigenes Verhalten ist nicht dein Versprechen. Teste nicht, dass Cart.items eine Liste von Tupeln ist; das ist ein internes Detail, das du morgen vielleicht änderst. Teste das, was ein Aufrufer sieht: subtotal() und total().
Jetzt setzt das Kapitel genau diesen Plan um, und jedes Problem auf dem Weg dorthin ist ein Discovery-Problem.
Die Regeln der Discovery
pytest führt nicht einfach alles aus, was es findet. Es folgt vier Regeln, und alles außerhalb dieser Regeln wird wortlos ignoriert:
- Dateien mit dem Namen
test_*.pyoder*_test.py - Funktionen in diesen Dateien, deren Namen mit
testbeginnen - Klassen, deren Namen mit
Testbeginnen — und die keine__init__-Methode besitzen - Methoden innerhalb einer solchen Klasse, deren Namen mit
testbeginnen
Warum überhaupt Regeln statt „führe jede Funktion aus“? Weil dein Projekt voll von Funktionen ist, die keine Tests sind — Hilfsfunktionen, der getestete Code selbst — und pytest eine Möglichkeit braucht, sie voneinander zu unterscheiden, ohne dass du irgendetwas registrieren musst.
Der schnellste Weg, den Regeln zu glauben, besteht darin, sie absichtlich zu verletzen. 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 == 1Daneben enthält rules_test.py ein test_suffix_style, das besteht, und helpers.py enthält ein test_in_helpers, das assert False ausführt. Nun 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 =========================In diesem Ordner stehen fünf assert False-Zeilen, und der Durchlauf ist grün. Was wurde übersprungen und warum:
check_not_found— der Name beginnt nicht mittest.TestGroup.helper— liegt innerhalb einer Testklasse, aber sein eigener Name beginnt nicht mittest. Hilfsmethoden sind erlaubt; sie sind nur eben keine Tests.CartTests— der Klassenname muss mitTestbeginnen.TestWithInit—pytestmuss die Klasse selbst instanziieren, ohne Argumente, einmal pro Test. Bei einer Klasse mit eigenem__init__ist nicht sichergestellt, dass das funktioniert, daher wird sie übersprungen — mit einer Warnung, dem einzigen Störgeräusch im gesamten Durchlauf.helpers.py— der Dateiname passt auf keines der Muster, die Datei wird also nicht einmal importiert.
Der lautlose Teil ist der gefährliche Teil. Wenn du versehentlichdef tset_total():schreibst, bleibt die Test-Suite für immer grün. Wenn ein neuer Test verdächtig leicht „besteht“, überprüfe, ob er überhaupt erfasst wurde — weiter unten erfährst du, wie das mit--cogeht.
Ein reales Layout: src/ und tests/
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── cart.py
│ └── pricing.py
└── tests/
├── test_cart.py
└── test_pricing.pyDer Code liegt in src/shop/; die Tests liegen in tests/ und werden niemals mit dem Paket ausgeliefert. Das ist das src-Layout. Sein Sinn liegt darin, dass der Code nicht „versehentlich“ importiert werden kann, bloß weil du dich gerade im Projektordner befindest — die Tests importieren ihn also genauso, wie es ein echter Benutzer tun würde.
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 — die ersten drei Zeilen des Plans:
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.0Genau dieses from shop.pricing import ... hat den ModuleNotFoundError am Anfang des Kapitels erzeugt.
Warum shop nicht gefunden werden kann
import shop gelingt nur dann, wenn irgendein Ordner auf sys.path einen shop-Ordner enthält. Das Paket liegt in src/, und src/ befindet sich nicht auf sys.path. pytest fügt den Ordner jeder Testdatei (hier tests/) hinzu, damit ein Test seine Nachbarn importieren kann — aber nichts fügt src/ hinzu.
Der falsche Weg besteht darin, sys.path innerhalb der Testdatei zu manipulieren:
import sys
sys.path.insert(0, "src")
from shop.cart import Cart
def test_empty_cart_subtotal():
assert Cart().subtotal() == 0Aus dem Projektordner heraus besteht der Test. Dann führt ihn jemand aus dem Inneren von tests/ aus:
$ 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" ist ein Pfad relativ zu dem Ort, an dem du dich gerade befindest. Der Test hängt nun also von dem Ordner ab, in dem du den Befehl tippst. Und jede Testdatei bräuchte dieselben drei Zeilen. Es gibt zwei richtige Wege.
Richtiger Weg 1: pytest mitteilen, wo sich der Code befindet. In pyproject.toml:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]pythonpath fügt src/ zu sys.path hinzu, bevor irgendein Test importiert wird — aufgelöst vom Projekt-Root aus, nicht von dem Ort, an dem du gerade stehst. testpaths legt fest, wo gesucht werden soll, wenn du einfach nur pytest tippst. Nun:
$ 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 im Kopfbereich beweist, dass pytest deine Einstellungen gelesen hat. Konfigurationsdateien erhalten später ein eigenes Kapitel; diese beiden Zeilen sind alles, was du jetzt brauchst.
Richtiger Weg 2: Das Paket installieren. Wenn pyproject.toml ein echtes Paket beschreibt (mit einer [build-system]- und einer [project]-Tabelle), führe einmalig pip install -e . aus. Das installiert das Paket im Editable-Modus: import shop funktioniert nun überall — in Tests, in der REPL, in Skripten — und Änderungen an src/ werden sofort übernommen, ohne dass du neu installieren musst. Danach liefert pytest -q dasselbe 6 passed ganz ohne pythonpath. Die meisten paketierten Projekte machen das so; pythonpath ist die schnellere Wahl, wenn das Projekt kein Paket ist.
Tests in Klassen gruppieren
tests/test_cart.py fasst die Warenkorb-Zeilen des Plans in einer Klasse zusammen:
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.01sEs gibt keine Basisklasse, von der geerbt werden müsste — TestCart ist eine ganz normale Klasse, und self ist lediglich der erforderliche erste Parameter. Wozu der Aufwand? Weil die Klasse zusammengehörigen Tests einen gemeinsamen Namen gibt und dieser Name zu etwas wird, das du gezielt auswählen kannst: Führe „alle Warenkorb-Tests“ mit einem einzigen Argument aus.
Wozu die Klasse nicht da ist: um Zustand zu teilen. pytest erzeugt eine frische Instanz für jede einzelne Testmethode, sodass ein auf self gespeicherter Wert im nächsten Test verschwunden ist. Das ist Absicht — Tests, die auf den Überbleibseln anderer Tests aufbauen, bestehen oder scheitern je nach Ausführungsreihenfolge. Jeder Test oben baut sein eigenes Cart() auf. Fixtures in einem späteren Kapitel sind das richtige Werkzeug für ein gemeinsames Setup.
Node-IDs: Die Adresse eines Tests
Bitte pytest, alles aufzulisten, was es erfasst hat, ohne irgendetwas auszuführen — mit --collect-only oder kurz --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.01sVergleiche das mit der letzten Spalte des Plans: Jede Zeile ist vorhanden, genau an der Adresse, die du gewählt hast. Jede Zeile ist eine Node-ID — der Dateipfad, dann ::, dann die Klasse (falls vorhanden), gefolgt von :: und dem Testnamen. Es ist derselbe String, der in einer Fehlerzusammenfassung nach FAILED erscheint; du kannst ihn also direkt aus einem Fehlschlag kopieren und wieder in die Kommandozeile einfügen.
Auswählen, was ausgeführt werden soll
Nach Pfad oder Node-ID
Der falsche Weg, einen einzelnen Test auszuführen, besteht darin, die anderen auszukommentieren oder umzubenennen, damit pytest sie überspringt — und dann zu vergessen, das rückgängig zu machen. Der richtige Weg ist, pytest eine Adresse zu übergeben:
$ 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.01sEin Ordner, eine Datei, eine Klasse, ein einzelner Test — jede Ebene der Node-ID grenzt die Auswahl weiter ein, und der Code wird dabei nicht angetastet.
Nach Name: -k
-k nimmt einen Ausdruck entgegen und behält nur die Tests, deren Namen darauf passen:
$ pytest -q -k discount
.. [100%]
2 passed, 4 deselected in 0.01sDer Abgleich ist eine Suche nach Teilstrings ohne Berücksichtigung von Groß- und Kleinschreibung. Wörter lassen sich mit and, or, not und Klammern kombinieren. Setze den Ausdruck in Anführungszeichen, damit die Shell ihn als ein einziges Argument übergibt:
$ 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.01sGenau deshalb lohnt es sich, Testnamen mit Bedacht zu wählen: test_discount_zero lässt sich über discount, über zero oder über beides finden. Ein Name wie test_2 lässt sich über überhaupt nichts finden.
Ein Detail erwischt jeden früher oder später einmal. -k prüft den Namen des Tests und die Namen darüber — seine Klasse und seine Datei:
$ 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.01sKeiner der drei TestCart-Tests enthält das Wort cart im eigenen Namen, und dennoch wurden alle drei verworfen — denn sie liegen in test_cart.py. Wenn dich eine -k-Auswahl überrascht, füge --co hinzu und sieh nach, bevor du die Tests ausführst.
Die Auswahl anhand eines selbst vergebenen Labels statt über den Namen ist die Aufgabe von Markern und -m, die in Kapitel zwölf behandelt werden.
Wenn etwas fehlschlägt
Mache den Code absichtlich kaputt: Ändere die Standard-rate in add_tax von 0.15 auf 0.18. Jetzt schlagen zwei Tests fehl. (--tb=no blendet die Fehlerberichte aus, damit der Verlauf des Durchlaufs sichtbar bleibt; diese Berichte hast du im letzten Kapitel kennengelernt.)
$ 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 stoppt beim ersten Fehlschlag. Wenn eine kaputte Funktion zehn Tests fehlschlagen lässt, lohnt es sich, den ersten Bericht zu lesen; die anderen neun sind nur Echos:
$ 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 stoppt nach N Fehlschlägen. -x entspricht genau --maxfail=1.
--lf (last failed) führt nur das erneut aus, was beim letzten Mal fehlgeschlagen ist. pytest merkt sich Fehlschläge in einem .pytest_cache-Ordner im Projekt:
$ 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.01sNur die beiden Fehlschläge wurden ausgeführt. Setze den Steuersatz zurück, und --lf führt genau diese beiden wieder aus und sie bestehen. Führe --lf noch einmal aus, wenn kein Test mehr fehlschlägt, und pytest gibt run-last-failure: no previously failed tests, not deselecting items. aus und führt alles aus.
--ff (failed first) führt die Fehlschläge zuerst aus, danach den ganzen Rest. Das schnelle Ergebnis erscheint ganz oben, die vollständige Sicherheitsprüfung danach:
$ 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.... — die beiden Fehlschläge liefen zuerst, vor den vier bestandenen Tests.
Ein bewährter Ablauf bei der Fehlerbehebung: pytest --lf -x, bis alles grün ist, und danach einmal normales pytest. Warum dieser letzte Schritt? Weil --lf nur das erneut ausführt, was rot war — es kann dir nicht sagen, ob dein Fix etwas kaputtgemacht hat, das vorher grün war.
Ausgabe deiner Tests: Capturing und -s
print innerhalb eines Tests scheint nichts zu bewirken. (--tb=short unten verkürzt lediglich den Fehlerbericht.) pytest fängt alles ab (capturing), was ein Test auf den Bildschirm schreibt, und zeigt es nur dann an, wenn dieser Test fehlschlägt:
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.01sDie Ausgabe des bestandenen Tests ist verschwunden; die Ausgabe des fehlgeschlagenen Tests ist seinem Bericht unter Captured stdout call angehängt. Bei Hunderten von Tests ist genau das gewünscht — Stille, wenn alles in Ordnung ist, Beweise, wenn etwas schiefgeht. Um alles direkt bei der Entstehung zu sehen, schaltet -s das Abfangen (Capturing) ab:
$ 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.01sExit-Codes
Wenn pytest fertig ist, übergibt es der Shell eine Zahl. CI-Systeme entscheiden allein anhand dieser Zahl über „grün“ oder „rot“:
| Code | Bedeutung | |---|---| | 0 | Alle erfassten Tests haben bestanden | | 1 | Tests wurden ausgeführt und mindestens einer ist fehlgeschlagen | | 2 | Der Durchlauf wurde unterbrochen — ein Fehler während der Collection oder Strg+C | | 3 | Ein interner Fehler in pytest oder einem Plugin | | 4 | Ein Bedienfehler — eine unbekannte Option oder ein Pfad bzw. eine Node-ID, die nicht existiert | | 5 | Es wurden keine Tests erfasst |
5 verdient besondere Aufmerksamkeit. Ein -k, das auf nichts passt, oder ein Ordner ohne korrekt benannte Dateien ist kein Bestehen — und CI behandelt das als Fehlschlag, was völlig richtig ist:
$ pytest -q -k refund
6 deselected in 0.01s
$ echo $?
5Der ModuleNotFoundError zu Beginn des Kapitels endete mit Interrupted: 1 error during collection — Exit-Code 2. Ein Collection-Fehler ist kein fehlschlagender Test; pytest kam gar nicht erst dazu, Tests auszuführen.
Ein vollständiges Beispiel
Der Plan ist umgesetzt und die Tests sind grün. Nun kommt ein langsamer Test in einer eigenen Klasse zu tests/test_cart.py hinzu:
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.0Die Datei enthält über dieser Klasse nach wie vor TestCart. Prüfe zuerst, was pytest sieht, bevor du irgendetwas ausführst:
$ 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.01sSieben Tests, jeder mit einer eigenen Adresse. Führe alle aus und lass dir die drei langsamsten anzeigen:
$ 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.12sNur ein Test war langsam genug, um aufgeführt zu werden. In einem echten Projekt findest du mit --durations=10 jene Handvoll Tests, die die gesamte Suite zäh wirken lassen. Wenn du am Warenkorb arbeitest, lasse den langsamen Test einfach außen vor:
$ pytest -q -k "cart and not many"
... [100%]
3 passed, 4 deselected in 0.01sDrei Dinge haben dafür gesorgt, dass das funktioniert: pythonpath = ["src"] sorgt überhaupt erst dafür, dass from shop.cart import Cart gelingt. TestBigCart wird erfasst, weil sein Name mit Test beginnt und die Klasse kein __init__ besitzt. Und -k "cart and not many" behielt genau die TestCart-Tests bei: cart passte über den Dateinamen auf alles in test_cart.py, und not many filterte den langsamen Test heraus.
Wenn etwas schiefgeht
ModuleNotFoundError: No module named 'shop' während der Collection Der zu testende Code befindet sich nicht auf sys.path. Füge bei einem src/-Layout pythonpath = ["src"] unter [tool.pytest.ini_options] ein oder installiere das Paket mit pip install -e .. Der Durchlauf bricht mit Interrupted: 1 error during collection und Exit-Code 2 ab.
Es funktioniert mit python -m pytest, aber nicht mit pytest python -m pytest fügt den aktuellen Ordner ebenfalls zu sys.path hinzu; einfaches pytest tut das nicht. Daher ist ein Modul im Projekt-Root auf dem einen Weg importierbar und auf dem anderen nicht. Verlasse dich nicht auf diesen Unterschied — setze pythonpath oder installiere das Paket.
PytestCollectionWarning: cannot collect test class 'TestCart' because it has a __init__ constructor Testklassen dürfen kein __init__ definieren. Entferne es und baue das, was jeder Test benötigt, direkt im Test auf.
no tests ran, Exit-Code 5 Nichts passte auf die Discovery-Regeln. Überprüfe den Dateinamen (test_*.py oder *_test.py), die Funktionsnamen (test...) und die Klassennamen (Test...). Wenn du -k verwendet hast, trifft es möglicherweise auf nichts zu.
ERROR: not found: .../tests/test_cart.py::test_total_adds_tax Die Node-ID ist falsch. Dieser Test liegt innerhalb von TestCart, seine Adresse lautet also tests/test_cart.py::TestCart::test_total_adds_tax. Kopiere Node-IDs aus pytest --co -q, anstatt sie von Hand zu tippen. Exit-Code 4.
ERROR: file or directory not found: tests/test_carts.py Ein Tippfehler im Pfad. Exit-Code 4.
pytest: error: unrecognized arguments: --maxfial=2 Eine falsch geschriebene Option; pytest --help listet die echten Optionen auf. Exit-Code 4.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Diese Datei ist als test_rules.py gespeichert. Wenn du pytest -q ausführst, wie viele bestandene Tests meldet die letzte Zeile?
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
Der Code liegt in src/shop/pricing.py. In pyproject.toml steht pythonpath = ["shop"] unter [tool.pytest.ini_options]. Dennoch meldet pytest den Fehler ModuleNotFoundError: No module named 'shop'. Was stimmt nicht?
# tests/test_pricing.py
from shop.pricing import add_tax
def test_add_tax_default_rate():
assert add_tax(100.0) == 115.0- ADer Funktionsname muss mit `Test` beginnen
- B`pythonpath` muss auf den Ordner zeigen, der das Paket `shop` *enthält*: `["src"]`
- CDer Name der Testdatei muss auf `_test.py` enden
- D`tests/` benötigt eine `__init__.py`, bevor etwas importiert werden kann
Zwei Tests sind fehlgeschlagen und du hast den Bug behoben. Welcher Befehl führt nur diese beiden Tests erneut aus?
- Apytest --co
- Bpytest -x
- Cpytest --ff
- Dpytest --lf
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Eine Bibliothek verlangt Verzugsgebühren. Baue dieses Projekt auf:
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)- Schreibe zuerst einen Testplan: pünktlich, ein paar Tage zu spät, genau an der Obergrenze, über der Obergrenze, ein individueller Tagessatz.
- Schreibe
tests/test_fees.pymit diesen Fällen als Methoden einer einzelnen Klasse,TestLateFee. Führepytestohne einepyproject.tomlaus, lies die Fehlermeldung und prüfeecho $?. - Füge
pyproject.tomlhinzu, damit die Testslibraryimportieren können. Sorge dafür, dass alles grün wird. - Liste die Node-IDs mit
--co -qauf. Führe nur den Test „über der Obergrenze“ über seine Node-ID aus. - Verwende
-k, um nur die Tests zur Obergrenze auszuführen, und danach alles außer ihnen. - Ändere die Obergrenze in
fees.pyvon100auf90. Führe die Suite aus, danach--lf. Setze den Wert zurück, führe--lfaus und anschließend alle Tests.
Lösung
Der Plan:
| Fall | Eingabe | Erwartet | |---|---|---| | Pünktlich | late_fee(0) | 0 | | Ein paar Tage zu spät | late_fee(3) | 15 | | Grenze: genau an der Obergrenze | late_fee(20) | 100 | | Über der Obergrenze | late_fee(30) | 100 | | Individueller Tagessatz | late_fee(3, daily_rate=2) | 6 | | Negative Tage | late_fee(-1) | ValueError — später, mit 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) == 6Ohne Konfiguration stoppt der Durchlauf bei der Collection mit ModuleNotFoundError: No module named 'library', Interrupted: 1 error during collection, und echo $? gibt 2 aus. Dann 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.01sMit der auf 90 geänderten Obergrenze:
$ 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.01sMit der Obergrenze wieder bei 100 liefert pytest -q --lf 2 passed, 3 deselected, und ein vollständiges pytest -q liefert 5 passed.
Warum diese Entscheidungen:
- Zwei Obergrenzen-Tests, nicht nur einer.
late_fee(20)liegt genau auf der Grenze, dem ersten Tag, an dem die Gebühr die Obergrenze erreicht;late_fee(30)liegt deutlich darüber. Off-by-one-Fehler lauern an Grenzen, daher bekommt die Grenze ihren eigenen Test — und der zweite beweist, dass die Obergrenze auch darüber hinaus greift. - Das Wort
capsteht in den Namen beider Obergrenzen-Tests, und nur dort. Genau das ermöglichte es-k capund-k "not cap", sauber zu selektieren — die Namen wurden bereits im Hinblick auf die Auswahl gewählt. - Eine Klasse, kein
__init__, kein geteilter Zustand. Jede Methode ruftlate_feeselbst auf; die Klasse verleiht der Gruppe lediglich eine gemeinsame Adresse:tests/test_fees.py::TestLateFee. pythonpathanstelle einersys.path-Zeile in der Testdatei, damit die Tests aus jedem beliebigen Ordner heraus bestehen, in dem du sie aufrufst.- Der vollständige Durchlauf nach
--lf.--lfbeweist, dass die beiden roten Tests wieder grün sind; erst der vollständige Durchlauf beweist, dass die anderen drei ebenfalls noch grün sind.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen