Coverage und CI — welche Zeilen haben deine Tests nie ausgeführt?
Coverage mit pytest-cov messen, die Spalte Missing lesen, Branch-Coverage, ein Minimum erzwingen, Tests parallel mit pytest-xdist ausführen und die ganze Suite bei jedem Push mit GitHub Actions laufen lassen. Und warum Coverage von 100 % nicht bedeutet, dass der Code stimmt.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine kleine Funktion, die einen Paketpreis berechnet, und zwei Tests dafür.
shop/shipping.py:
def shipping_cost(weight_kg: float, country: str) -> float:
if weight_kg <= 0:
raise ValueError(f"weight must be positive: {weight_kg}")
if country == "BD":
base = 60.0
elif country == "IN":
base = 90.0
else:
base = 500.0
if weight_kg > 5:
base += (weight_kg - 5) * 20
return basetests/test_shipping.py:
from shop.shipping import shipping_cost
def test_light_parcel_to_bangladesh():
assert shipping_cost(1, "BD") == 60.0
def test_heavy_parcel_to_bangladesh():
assert shipping_cost(7, "BD") == 100.0$ pytest -q
.. [100%]
2 passed in 0.07sGrün. Und das Grün sagt nichts über die Teile der Funktion, die die Tests nie berührt haben. Kostet ein Paket nach Indien 90? Löst ein Gewicht von null wirklich eine Exception aus? Das hat niemand gefragt. Ein Tippfehler im "IN"-Zweig würde dort sitzen, bestehen, bis ein Kunde ihn findet.
Bei zwölf Zeilen kannst du das mit den Augen prüfen. Bei zwölftausend nicht. Du brauchst den Computer, der eine Frage beantwortet: Welche Zeilen haben meine Tests nie ausgeführt? Diese Messung nennt man Coverage, und in diesem Kapitel geht es darum, sie zu erheben, zu lesen und nicht von ihr in die Irre geführt zu werden — und danach darum, die ganze Suite bei jedem Push schnell und automatisch auszuführen.
Am Ende dieses Kapitels kannst du
- Coverage mit
pytest-covmessen und die SpalteMissinglesen - Den HTML-Bericht öffnen, um nicht abgedeckte Zeilen im Zusammenhang zu sehen
- Erklären, warum die Zeilen-Coverage 100 % zeigen kann, obwohl ein Zweig nie genommen wurde, und die Branch-Coverage einschalten
- Den Durchlauf fehlschlagen lassen, wenn die Coverage unter eine Schwelle fällt, und Code bewusst ausschließen
- Alles davon in
pyproject.tomlablegen - Sagen, warum Coverage von 100 % nicht bedeutet, dass der Code richtig ist
- Tests parallel mit
pytest-xdistausführen und erklären, warum Tests nicht voneinander abhängen dürfen - Einen GitHub-Actions-Workflow schreiben, der die Suite auf mehreren Python-Versionen ausführt
Voraussetzungen: Konfiguration und Warnungen.
Bevor du den Test schreibst
Der falsche Weg, Coverage zu benutzen, ist, zuerst den Bericht zu öffnen und dann jeden Test zu schreiben, der die nächste rote Zeile grün macht. Das erzeugt Tests in der Form des Codes, nicht in der Form des Versprechens, das der Code gibt — und, wie du sehen wirst, übersieht es Fehler, die keine eigene Zeile haben. Die richtige Reihenfolge ist: Plane aus dem Vertrag heraus, schreibe die Tests und frag dann erst die Coverage, was der Plan vergessen hat.
Der Vertrag. Lies shipping_cost als Versprechen, nicht als Code:
- Gewicht null oder weniger →
ValueError, dessen Meldung „positive“ sagt - Bangladesch kostet 60, Indien 90, überall sonst 500
- über 5 kg kostet jedes zusätzliche Kilo 20; bei genau 5 kg wird nichts aufgeschlagen
- keine Nebenwirkungen: Die Funktion gibt eine Zahl zurück und berührt sonst nichts
Was vorbereitet sein muss. Eine virtuelle Umgebung, in der pytest und pytest-cov installiert sind (und später pytest-xdist). Das Paket muss aus den Tests importierbar sein — hier shop, entweder installiert oder erreicht über pythonpath = ["src"] aus dem letzten Kapitel. Und du musst den Importnamen des Codes kennen, den du misst, denn der kommt hinter --cov=.
Der Plan. Eine Zeile pro Verhalten, mit Grenzen und absichtlich ungültiger Eingabe:
| Fall | Eingabe | Erwartet | |---|---|---| | Normalfall, Heimatland | 1, "BD" | 60.0 | | zweites Land | 1, "IN" | 90.0 | | überall sonst | 1, "US" | 500.0 | | Grenze: genau 5 kg | 5, "BD" | 60.0 (kein Aufschlag) | | über der Grenze | 7, "BD" | 100.0 (60 + 2 × 20) | | ungültig: Gewicht null | 0, "BD" | ValueError, „positive“ |
Was nicht zu testen ist. Code, den du nicht geschrieben hast (Pythons round, die coverage-Bibliothek selbst). Ein __main__-Block, der nur läuft, wenn ein Mensch die Datei ausführt. Und nie ein Test, dessen einzige Aufgabe ist, eine Zeile laufen zu lassen — ein Test ohne bedeutsames assert erhöht den Prozentwert und prüft nichts.
Beachte die beiden Grenzzeilen. Ein Test mit 5 kg führt keine Zeile aus, die der Test mit 1 kg nicht schon ausführt, also wird ein Coverage-Bericht sie nie verlangen. Und für den ungültigen Fall würde ein Test mit -1 die raise-Zeile genau so gut abdecken wie 0 — aber wenn jemand weight_kg < 0 statt <= 0 getippt hätte, würde nur der 0-Test fehlschlagen. Coverage kann diese beiden Tests nicht unterscheiden; der Vertrag kann es. Das ist die ganze Beziehung: Der Plan entscheidet, was zu testen ist; Coverage meldet, was der Plan vergessen hat.
Die beiden Tests vom Anfang decken die Zeilen 1 und 5 des Plans. Der Rest des Kapitels misst diese Lücke, schließt sie und automatisiert danach das Messen.
Mit pytest-cov messen
Die Coverage misst die coverage-Bibliothek; pytest-cov ist das Plugin, das es von der pytest-Kommandozeile aus einschaltet. Installiere es als Entwicklungsabhängigkeit:
$ uv add --dev pytest-covSag ihm dann mit --cov=, welches Paket es beobachten soll:
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmts ist die Anzahl der ausführbaren Anweisungen in der Datei. Miss ist, wie viele davon nie liefen. Cover ist der Anteil, der lief. Elf Anweisungen, vier nie ausgeführt: 64 %.
Der Wert hinter --cov= ist der Code, den du messen willst, nicht die Tests. Deine Tests führen immer alle eigenen Zeilen aus, also würde ihr Mitrechnen den Gesamtwert nur schönfärben.
Die Spalte Missing lesen
Ein Prozentwert sagt dir, wie viel. Er sagt nicht, wo. Füge --cov-report=term-missing hinzu:
$ pytest -q --cov=shop --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64% 3, 6-9
------------------------------------------------
TOTAL 11 4 64%
2 passed in 0.09s3, 6-9 sind Zeilennummern in shipping.py. Zähle die Datei Zeile für Zeile durch:
- Zeile 3 ist
raise ValueError(...)— kein Test hat ein Gewicht von null oder weniger übergeben. - Die Zeilen 6 bis 9 sind
elif country == "IN":,base = 90.0,else:undbase = 500.0— kein Test hat ein Paket irgendwohin außer nach Bangladesch geschickt.
Zeile 8, else:, ist keine eigene Anweisung, also zählt sie nicht zu den elf; Coverage faltet sie einfach in den Bereich 6-9 ein, damit die Liste kurz bleibt.
Diese Spalte ist die To-do-Liste. Jede Zahl ist eine Zeile, die völlig falsch sein könnte, und deine Suite wäre trotzdem grün.
Der HTML-Bericht
Bei einer großen Datei wird eine Liste von Zahlen schwer zu verfolgen. --cov-report=html schreibt stattdessen eine kleine Website:
$ pytest -q --cov=shop --cov-report=html
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Coverage HTML written to dir htmlcov
2 passed in 0.11sÖffne htmlcov/index.html im Browser. Jede Datei ist mit ihrem Prozentwert aufgelistet; klicke eine an, dann siehst du den Quellcode, mit grün markierten ausgeführten und rot markierten nicht ausgeführten Zeilen. Das ist dieselbe Information wie Missing, nur über den Code selbst gelegt.
Beides, htmlcov/ und die Datendatei .coverage, ist erzeugte Ausgabe. Pack sie in .gitignore.
Branch-Coverage — warum die Zeilen-Coverage lügt
Hier ist eine Funktion, bei der die Zeilen-Coverage sagt, alles ist in Ordnung:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)from shop.coupons import final_price
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0$ pytest -q --cov=shop --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------
shop/__init__.py 0 0 100%
shop/coupons.py 4 0 100%
------------------------------------------------
TOTAL 4 0 100%
1 passed in 0.09s100 %. Jede Zeile lief. Aber niemand hat final_price je ohne Gutschein aufgerufen. Ein if ohne else hat zwei Auswege — in den Block hinein oder gerade an ihm vorbei —, und nur einer davon wurde ausprobiert. Wenn später jemand den Pfad ohne Gutschein kaputt macht, merkt dieser Bericht es nicht.
Die Zeilen-Coverage fragt: „Lief diese Zeile?“ Die Branch-Coverage fragt: „Ist jede Entscheidung in beide Richtungen gegangen?“ Schalte sie mit --cov-branch ein:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10sZwei neue Spalten. Branch ist die Anzahl möglicher Sprünge (das if in Zeile 2 kann in Zeile 3 oder in Zeile 4 gehen: zwei). BrPart zählt Entscheidungen, die nur in eine Richtung gegangen sind. Und Missing zeigt jetzt 2->4: der Sprung von Zeile 2 direkt in Zeile 4 — der Pfad „ohne Gutschein“ — ist nie passiert.
Die Branch-Coverage ist strenger und näher an der Wahrheit. Es gibt wenig Grund, sie nicht dauerhaft anzuhaben.
Aus der Zahl ein Gate machen: --cov-fail-under
Ein Bericht, den niemand liest, verändert nichts. --cov-fail-under macht aus dem Gesamtwert eine Bestehens-/Fehlschlags-Bedingung:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing --cov-fail-under=90
.
ERROR: Coverage failure: total of 83 is less than fail-under=90
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
FAIL Required test coverage of 90% not reached. Total coverage: 83.33%
1 passed in 0.12sLies die letzte Zeile genau: 1 passed. Jeder Test ist bestanden, und trotzdem endet der Befehl mit dem Exit-Status 1. In CI ist das ein roter Build. Der Punkt ist nicht, dass 90 eine magische Zahl ist; der Punkt ist, dass die Coverage nicht mehr stillschweigend verrutschen kann, wenn jemand Code ohne Tests hinzufügt.
Wähle eine Schwelle, die ein wenig unter dem heutigen Stand liegt, und erhöhe sie mit der Zeit. Sie am ersten Tag auf 100 zu setzen, endet normalerweise damit, dass Leute sinnlose Tests schreiben, um die Zahl zu erreichen.
Code bewusst ausschließen: # pragma: no cover
Manche Zeilen lohnen den Test nicht — ein Block etwa, der nur läuft, wenn du die Datei von Hand ausführst. Füge einen zweiten Test hinzu, assert final_price(200.0, None) == 200.0, sodass final_price vollständig abgedeckt ist — und dann einen __main__-Block ins Modul:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11sZeile 8, das print, läuft unter pytest nie — und wird nie laufen. Markiere das if mit einem Kommentar, den Coverage versteht:
if __name__ == "__main__": # pragma: no cover
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 0 100%
--------------------------------------------------------------
TOTAL 4 0 2 0 100%
2 passed in 0.08sWenn das Pragma auf einer Zeile steht, die einen Block eröffnet, wird der ganze Block ausgeschlossen. Benutze es sparsam und ehrlich. Jedes Pragma ist eine Zeile, die du beschlossen hast, nicht zu prüfen; wenn du dabei erwischt wirst, einen hinzuzufügen, um einen schwierigen Zweig „zum Verschwinden“ zu bringen, braucht dieser Zweig vermutlich am meisten einen Test.
In pyproject.toml ablegen
Vier --cov-Flags jedes Mal zu tippen ist der Grund, warum man sie vergisst. Coverage liest seine eigenen Einstellungen aus pyproject.toml, und die addopts aus dem letzten Kapitel schalten es bei pytest ein:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true
[tool.coverage.report]
fail_under = 90
exclude_also = [
'if __name__ == "__main__":',
][tool.coverage.run]steuert das Messen:sourceist, was beobachtet wird (ein reines--covohne Wert reicht also),branch = trueist--cov-branch.[tool.coverage.report]steuert das Berichten:fail_underist--cov-fail-under, undexclude_alsolistet Muster auf, die zusätzlich zu# pragma: no coverausgeschlossen werden — hier jeder__main__-Block, ganz ohne Kommentar im Code.
Jetzt misst, berichtet und erzwingt ein einfaches pytest. Im vollständigen Beispiel unten siehst du es laufen.
100 % Coverage heißt nicht, dass der Code stimmt
Coverage beantwortet: „Lief diese Zeile?“ Es beantwortet nicht: „Hat jemand geprüft, ob das Ergebnis stimmt?“ Ein kleines Beispiel:
def is_leap_year(year: int) -> bool:
return year % 4 == 0from shop.calendar_rules import is_leap_year
def test_2024_is_a_leap_year():
assert is_leap_year(2024)
def test_2023_is_not():
assert not is_leap_year(2023)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
2 passed in 0.12sPerfekte Punktzahl. Und die Funktion ist falsch: Ein Jahr, das durch 100 teilbar ist, ist nur dann ein Schaltjahr, wenn es auch durch 400 teilbar ist. Noch ein Test, ausgewählt, indem man über die Regel nachdenkt statt über Zeilen:
def test_1900_is_not():
assert not is_leap_year(1900)$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
..F [100%]
=================================== FAILURES ===================================
_______________________________ test_1900_is_not _______________________________
def test_1900_is_not():
> assert not is_leap_year(1900)
E assert not True
E + where True = is_leap_year(1900)
tests/test_calendar_rules.py:13: AssertionError
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/calendar_rules.py 2 0 0 0 100%
--------------------------------------------------------------------
TOTAL 2 0 0 0 100%
=========================== short test summary info ============================
FAILED tests/test_calendar_rules.py::test_1900_is_not - assert not True
1 failed, 2 passed in 0.12sImmer noch 100 % — und jetzt ein Fehlschlag. Coverage hätte auf diesen Fehler nicht zeigen können, weil es keine fehlende Zeile gab; was fehlte, war ein Fall. Behandle Coverage als eine Karte davon, wo du sicher nicht hingeschaut hast, niemals als Beweis dafür, dass das, was du dir angesehen hast, richtig ist.
Tests parallel ausführen mit pytest-xdist
Wenn eine Suite wächst, wird sie langsam, und eine langsame Suite wird seltener ausgeführt. pytest-xdist verteilt Tests auf mehrere Prozesse. Acht Tests, die jeder eine halbe Sekunde dauern:
import time
import pytest
@pytest.mark.parametrize("n", range(8))
def test_slow_check(n):
time.sleep(0.5)
assert n >= 0$ pytest -q test_slow.py
........ [100%]
8 passed in 4.10s
$ pytest -n auto test_slow.py
============================= test session starts ==============================
created: 4/4 workers
4 workers [8 items]
........ [100%]
============================== 8 passed in 1.52s ===============================-n auto startet einen Worker pro CPU-Kern (hier vier) und gibt jedem davon Tests zum Ausführen. Es funktioniert zusammen mit --cov; die Coverage-Daten aller Worker werden zu einem Bericht zusammengeführt.
Es gibt einen Preis. Jeder Worker ist ein eigener Prozess, und die Reihenfolge, in der die Tests laufen, ist nicht mehr die Reihenfolge in der Datei. Jeder Test, der sich still darauf verlassen hat, dass ein anderer zuerst lief, wird brechen:
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1$ pytest -q test_cart.py
.. [100%]
2 passed in 0.07s
$ pytest -n 2 test_cart.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.F [100%]
=================================== FAILURES ===================================
____________________________ test_cart_has_one_item ____________________________
[gw1] linux -- Python 3.12.3 /path/to/venv/bin/python
def test_cart_has_one_item():
> assert len(CART) == 1
E assert 0 == 1
E + where 0 = len([])
test_cart.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_cart.py::test_cart_has_one_item - assert 0 == 1
========================= 1 failed, 1 passed in 0.37s ==========================Der zweite Test bestand nur, weil der erste die gemeinsame Liste schon gefüllt hatte. Auf einem anderen Worker war die Liste leer. pytest test_cart.py::test_cart_has_one_item allein scheitert genauso — xdist hat den Fehler nicht erzeugt, es hat ihn aufgedeckt.
Die Reparatur ist dieselbe wie in den Fixture-Kapiteln: Gib jedem Test seinen eigenen Zustand.
import pytest
@pytest.fixture
def cart():
return ["pen"]
def test_add_item(cart):
cart.append("bag")
assert cart == ["pen", "bag"]
def test_cart_has_one_item(cart):
assert len(cart) == 1$ pytest -n 2 test_cart_fixed.py
============================= test session starts ==============================
created: 2/2 workers
2 workers [2 items]
.. [100%]
============================== 2 passed in 0.39s ===============================Ein Test sollte allein bestehen, in beliebiger Reihenfolge, auf jedem Worker. Es gibt ein Plugin, pytest-randomly, das vollständig auf dieser Idee aufbaut: Es mischt die Reihenfolge der Tests bei jedem Durchlauf, sodass eine versteckte Abhängigkeit früh auf deinem Rechner auffällt statt monatelang später in CI. Du brauchst es nicht, um diesem Kurs zu folgen; es reicht zu wissen, dass „die Suite besteht nur in der Reihenfolge der Datei“ ein Fehler ist.
Bei jedem Push ausführen: GitHub Actions
Der letzte Schritt ist, nicht mehr darauf zu verlassen, dass Leute daran denken, die Suite auszuführen. Deklariere die Testwerkzeuge als Entwicklungsgruppe in pyproject.toml, damit uv sync sie installiert:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]Füge dann .github/workflows/tests.yml hinzu:
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked
- name: Run tests
run: uv run pytest -n auto --cov-report=xml
- name: Keep the coverage report
if: matrix.python-version == '3.12'
uses: actions/upload-artifact@v7
with:
name: coverage-xml
path: coverage.xmlWas jeder Teil tut:
on:— läuft bei jedem Push nachmainund bei jedem Pull Request.matrix:— der Job läuft dreimal, einmal pro Python-Version, nebeneinander.fail-fast: falselässt alle drei fertig werden, sodass du siehst, ob ein Fehlschlag auf eine bestimmte Version zutrifft.setup-uvinstalliert uv und legt die Python-Version für diese Ausführung des Jobs fest.uv sync --lockedinstalliert genau das, wasuv.locksagt, und scheitert, wenn die Lock-Datei veraltet ist — also testet CI das, was du committet hast.uv run pytest -n auto --cov-report=xmlläuft parallel; die Coverage-Einstellungen,fail_undereingeschlossen, kommen auspyproject.toml, also lässt ein Abfallen der Coverage den Build fehlschlagen. Der XML-Bericht liegt neben dem Terminal-Bericht, bereit für einen Coverage-Dienst oder zum Download.
Lokal liefert derselbe Befehl nach uv sync dasselbe Ergebnis (die letzten Zeilen der Ausgabe):
$ uv run pytest -n auto --cov-report=xml
TOTAL 15 0 10 1 96%
Coverage XML written to file coverage.xml
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.43s ===============================Ein vollständiges Beispiel
Ein Projekt mit src-Layout, beiden Modulen aus diesem Kapitel und jeder Einstellung an einem Ort.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml ist dasselbe wie im Konfigurationsabschnitt oben. coupons.py behält seinen __main__-Block, ganz ohne Pragma — exclude_also kümmert sich darum. test_coupons.py hat weiter nur den SAVE10-Test. Und test_shipping.py ist jetzt der Testplan vom Anfang des Kapitels, Zeile für Zeile:
import pytest
from shop.shipping import shipping_cost
@pytest.mark.parametrize(
("weight", "country", "expected"),
[
(1, "BD", 60.0),
(1, "IN", 90.0),
(1, "US", 500.0),
(5, "BD", 60.0),
(7, "BD", 100.0),
],
)
def test_shipping_cost(weight, country, expected):
assert shipping_cost(weight, country) == expected
def test_zero_weight_is_rejected():
with pytest.raises(ValueError, match="positive"):
shipping_cost(0, "BD")$ pytest
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
collected 7 items
tests/test_coupons.py . [ 14%]
tests/test_shipping.py ...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/coupons.py 4 0 2 1 83% 2->4
src/shop/shipping.py 11 0 8 0 100%
------------------------------------------------------------------
TOTAL 15 0 10 1 96%
Required test coverage of 90.0% reached. Total coverage: 96.00%
============================== 7 passed in 0.12s ===============================Drei Dinge fallen auf.
Erstens: Reines pytest hat alles gemacht — ohne Flags. Messen, Branch-Prüfung, die Missing-Liste und die Schwelle kamen alle aus pyproject.toml.
Zweitens: shipping.py ging von 64 % auf 100 %, weil der Plan aufgeschrieben wurde, nicht weil wir roten Zeilen hinterhergejagt sind. Coverage bestätigt dann, dass der Plan jede Zeile und jeden Zweig erreicht hat. Die Zeile (5, "BD", 60.0) und die Wahl von 0 statt -1 für das ungültige Gewicht tragen nichts zum Prozentwert bei — sie sind da, weil der Vertrag Kanten hat, und an den Kanten leben Tippfehler wie < statt <=.
Drittens: Der Build besteht bei 96 %, während 2->4 noch in der Liste steht. Die Schwelle ist eine Untergrenze, keine Ziellinie. Der Bericht sagt dir immer noch genau, welchen Test du als Nächsten schreibst: final_price ohne Gutschein.
Wenn etwas schiefgeht
error: unrecognized arguments: --cov=shop pytest-cov ist nicht in der Umgebung installiert, aus der pytest läuft. Installiere es (uv add --dev pytest-cov) und führe pytest über diese Umgebung aus, zum Beispiel mit uv run pytest. Dieselbe Meldung mit -n heißt, dass pytest-xdist fehlt.
CoverageWarning: Module shopp was never imported. (module-not-imported) gefolgt von No data was collected. Der Name hinter --cov= passt zu keinem Paket, das deine Tests importiert haben — hier ein Tippfehler. Prüfe die Schreibweise und gib bei einem src-Layout den Importnamen (shop) an, nicht den Ordner (src).
WARNING: Failed to generate report: No data to report. Dieselbe Ursache wie oben: Es wurde nichts gemessen, also gibt es nichts zu berichten. Die Tests sind trotzdem bestanden, und genau deshalb fällt das leicht übersehen.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% Kein kaputter Test — die Schwelle. Sieh dir die Spalte Missing an und schreibe den Test, den sie zeigt. fail_under zu senken, damit der Build grün wird, macht den Zweck der Einstellung zunichte.
Ein Test besteht allein, scheitert aber unter -n auto (oder umgekehrt) Die Tests teilen sich Zustand: eine Liste oder ein Dict auf Modulebene, eine Datei an einem festen Pfad, eine Umgebungsvariable, eine Datenbankzeile. Finde heraus, was der fehlgeschlagene Test voraussetzt, und gib es dem Test stattdessen über eine Fixture, tmp_path oder monkeypatch.
Coverage ist 100 %, aber Nutzer finden weiter Fehler Erwartet. Coverage zeigt, was lief, nicht, was geprüft wurde. Suche nach Fällen — Grenzen, leere Eingaben, das Jahr 1900 —, nicht nach Zeilen.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Es gibt nur einen Test, und die Branch-Coverage ist an. Was zeigt die Spalte Missing in der Zeile coupons.py?
# shop/coupons.py
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
# tests/test_coupons.py
def test_coupon_takes_ten_percent_off():
assert final_price(200.0, "SAVE10") == 180.0
# command: pytest -q --cov=shop --cov-branch --cov-report=term-missing- ANichts — zu 100 % abgedeckt
- B4
- C2->4
- D3
Mit pytest bestehen beide Tests, aber mit pytest -n 2 scheitert test_cart_has_one_item. Was ist das eigentliche Problem?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdist hat einen Fehler; die Tests sind in Ordnung
- BDer zweite Test hängt davon ab, dass der erste gelaufen ist — die beiden Tests teilen sich Zustand
- C`-n 2` führt jeden Test zweimal aus
- D`CART` liegt auf Modulebene, wird auf den Workern also nie angelegt
Eine Funktion hat 100 % Zeilen- und Branch-Coverage, und jeder Test besteht. Was kannst du daraus schließen?
- ANur, dass jede Zeile und jede Seite jeder Entscheidung mindestens einmal lief — nicht, dass die Ergebnisse stimmen
- BMit `fail_under = 100` kann kein Fehler mehr in die Funktion gelangen
- CDie Funktion kann keinen Fehler an einer Grenze haben
- DDie Funktion ist richtig und braucht keine weiteren Tests
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
Starte ein kleines Projekt mit diesem Modul, src/shop/loyalty.py:
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return pointsDer Vertrag: ein Punkt für jede vollen 100, die ausgegeben werden, für Mitglieder verdoppelt; ein negativer Gesamtbetrag ist ein Fehler; null ist erlaubt. Es wird mit einem einzigen Test geliefert, tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4Und ein pyproject.toml, das bei jedem Durchlauf die Branch-Coverage misst:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- Führe
pytest -qaus und lies die SpalteMissing. Sag mit Worten, was jeder Eintrag bedeutet. - Bevor du irgendeinen Test schreibst, schreibe eine Testplan-Tabelle für den Vertrag — mit der Grenze bei genau 100 und der Kante direkt darunter.
- Verwandle den Plan in einen parametrisierten Test plus einen
pytest.raises-Test. - Füge
fail_under = 100unter[tool.coverage.report]hinzu und bestätige, dass der Durchlauf besteht. - Lösche die Zeilen für Nicht-Mitglieder und bestätige, dass der Durchlauf fehlschlägt, obwohl jeder verbleibende Test besteht.
- Führe die Suite mit
pytest -n autoaus und bestätige, dass das Ergebnis identisch ist.
Lösung
Schritt 1 — den Bericht lesen.
$ pytest -q
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 1 4 2 73% 3, 5->7
------------------------------------------------------------------
TOTAL 7 1 4 2 73%
1 passed in 0.09s3 ist die raise-Zeile: Kein Test hat einen negativen Gesamtbetrag übergeben. 5->7 ist ein Sprung vom if is_member: direkt zu return points — der Pfad für Nicht-Mitglieder, der nie genommen wurde. Ohne Branch-Coverage würde 5->7 gar nicht erscheinen, weil Zeile 6 gelaufen ist (für das Mitglied).
Schritt 2 — der Plan.
| Fall | Eingabe | Erwartet | |---|---|---| | Mitglied, verdoppelt | 250, True | 4 | | Nicht-Mitglied | 250, False | 2 | | knapp unter den ersten 100 | 99.99, False | 0 | | Grenze: genau 100 | 100, False | 1 | | null ist erlaubt | 0, True | 0 | | ungültig: negativ | -1, True | ValueError, „cannot be negative“ |
Schritt 3 — die Tests. tests/test_loyalty.py:
import pytest
from shop.loyalty import loyalty_points
@pytest.mark.parametrize(
("total", "is_member", "expected"),
[
(250, True, 4), # member: points doubled
(250, False, 2), # non-member: the `if` is skipped
(99.99, False, 0), # just under the first 100
(100, False, 1), # exactly on the boundary
(0, True, 0), # zero is allowed, not an error
],
)
def test_loyalty_points(total, is_member, expected):
assert loyalty_points(total, is_member) == expected
def test_negative_total_is_rejected():
with pytest.raises(ValueError, match="cannot be negative"):
loyalty_points(-1, True)Schritt 4 — das Gate. In pyproject.toml eintragen:
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sSchritt 5 — beweisen, dass das Gate funktioniert. Mit den drei False-Zeilen gelöscht:
$ pytest -q
...
ERROR: Coverage failure: total of 91 is less than fail-under=100
[100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 1 91% 5->7
------------------------------------------------------------------
TOTAL 7 0 4 1 91%
FAIL Required test coverage of 100.0% not reached. Total coverage: 90.91%
3 passed in 0.12s3 passed, Exit-Status 1. Jede Zeile läuft weiter — nur der Zweig ist weg —, und das Gate fängt es. Setze die Zeilen wieder zurück.
Schritt 6 — parallel.
$ pytest -n auto
============================= test session starts ==============================
configfile: pyproject.toml
testpaths: tests
created: 4/4 workers
4 workers [6 items]
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
============================== 6 passed in 0.63s ===============================Dieselben sechs Tests, dieselben 100 %: Die Coverage von vier Workern wurde zu einem Bericht zusammengeführt.
Warum jede Entscheidung.
- Der Plan kam vor den Tests. Von den sechs Zeilen wurden nur zwei vom Bericht verlangt (
3und5->7).99.99und100sind da, weil der Vertrag eine Kante bei 100 hat, und0, True, weil der Vertrag sagt, dass null erlaubt ist — ein Test darauf, dass dabei keine Exception ausgelöst wird. Coverage wäre auch ohne alle zufrieden gewesen. - Warum `-1` hier in Ordnung ist. Die Abbruchbedingung ist
total < 0, also ist die Kante selbst0, und die Zeile0, Truelegt sie bereits als gültig fest. Wenn jemand die Bedingung auftotal <= 0änderte, würde diese Zeile fehlschlagen. Jede negative Zahl genügt dann für die ungültige Seite. - Warum `fail_under = 100` hier, aber nicht in einem echten Projekt. Ein so kleines Modul kann man vernünftigerweise vollständig abdecken. In einer großen Codebasis startest du knapp unter der heutigen Zahl und erhöhst sie.
- Warum der Durchlauf mit `-n auto` etwas beweist. Jeder Fall bekommt seine eigenen Argumente und zwischen den Tests wird nichts geteilt, also sind Reihenfolge und Worker egal. Wenn sich das Ergebnis unter
-n autogeändert hätte, wäre das der Fehler gewesen, den man zuerst hätte beheben müssen.
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