Coverage en CI — welke regels draaiden je tests nooit?
Coverage meten met pytest-cov, de kolom Missing lezen, branch coverage, een minimum afdwingen, tests parallel draaien met pytest-xdist en de hele suite bij elke push draaien met GitHub Actions. En waarom 100% coverage niet betekent dat de code klopt.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een kleine functie die een pakket prijst, en twee tests daarvoor.
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.07sGroen. En dat groen zegt niets over de delen van de functie die de tests nooit hebben aangeraakt. Kost een pakket naar India 90? Werpt een gewicht van nul echt een exception? Niemand heeft het gevraagd. Een typfout in de tak "IN" zou daar blijven zitten, groen, tot een klant hem vond.
Twaalf regels kun je met het oog nakijken. Twaalfduizend niet. Je hebt de computer nodig om één vraag te beantwoorden: welke regels hebben mijn tests nooit uitgevoerd? Die meting heet coverage, en dit hoofdstuk gaat over hoe je die krijgt, hoe je hem leest en hoe je er niet door in de maling wordt genomen — en daarna over het draaien van de hele suite, snel en automatisch, bij elke push.
Aan het einde van dit hoofdstuk kun je
- Meet coverage met
pytest-coven lees de kolomMissing - Open het HTML-rapport om niet-afgedekte regels in context te zien
- Leg uit waarom line coverage 100% kan zeggen terwijl een branch nooit is genomen, en zet branch coverage aan
- Laat de run falen wanneer coverage onder een drempel zakt, en sluit code bewust uit
- Bewaar dit alles in
pyproject.toml - Leg uit waarom 100% coverage niet betekent dat de code klopt
- Draai tests parallel met
pytest-xdist, en leg uit waarom tests niet van elkaar mogen afhangen - Schrijf een GitHub Actions-workflow die de suite op meerdere Python-versies draait
Voorkennis: configuratie en waarschuwingen.
Voordat je de test schrijft
De verkeerde manier om coverage te gebruiken is eerst het rapport openen en elke test te schrijven die de volgende rode regel groen maakt. Dat levert tests op die de vorm van de code hebben, niet de belofte die de code geeft — en, zoals je zult zien, het mist bugs die geen eigen regel hebben. De juiste volgorde is: plan vanuit het contract, schrijf de tests, en vraag dan pas daarna aan coverage wat het plan vergeten is.
Het contract. Lees shipping_cost als een belofte, niet als code:
- gewicht nul of lager →
ValueErrorwiens bericht "positive" zegt - Bangladesh kost 60, India 90, alle andere landen 500
- boven 5 kg telt elke extra kilogram voor 20 mee; bij precies 5 kg wordt er niets toegevoegd
- geen side-effects: hij geeft een getal terug en raakt niets anders aan
Wat er klaar moet staan. Een virtuele omgeving met daarin pytest en pytest-cov geïnstalleerd (en later pytest-xdist). Het pakket moet importeerbaar zijn vanaf de tests — hier shop, óf geïnstalleerd óf bereikt via pythonpath = ["src"] uit het vorige hoofdstuk. En je moet de importnaam weten van de code die je meet, want dat is wat er na --cov= komt.
Het plan. Eén rij per gedrag, met grenzen en met opzet ongeldige invoer:
| geval | invoer | verwacht | |---|---|---| | gewoon pad, eigen land | 1, "BD" | 60.0 | | tweede land | 1, "IN" | 90.0 | | alle andere landen | 1, "US" | 500.0 | | grens: precies 5 kg | 5, "BD" | 60.0 (geen toeslag) | | boven de limiet | 7, "BD" | 100.0 (60 + 2 × 20) | | ongeldig: nulgewicht | 0, "BD" | ValueError, "positive" |
Wat je niet test. Code die je niet zelf hebt geschreven (Pythons round, de bibliotheek coverage zelf). Een __main__-blok dat alleen draait wanneer een mens het bestand uitvoert. En nooit een test die alleen maar tot taak heeft om een regel te laten draaien — een test zonder betekenisvolle assert verhoogt het percentage en controleert niets.
Merk de twee grensrijen op. Een test op 5 kg voert geen regel uit die de test op 1 kg nog niet uitvoert, dus geen coverage-rapport zal daar ooit om vragen. En voor het ongeldige geval zou een test met -1 de raise-regel net zo goed afdekken als 0 — maar als iemand weight_kg < 0 typte in plaats van <= 0, zou alleen de test met 0 falen. Coverage kan die twee tests niet van elkaar onderscheiden; het contract wel. Dat is de hele samenhang: het plan bepaalt wat je test; coverage rapporteert wat het plan over het hoofd zag.
De twee tests uit de opening dekken rij 1 en 5. De rest van dit hoofdstuk meet dat gat, dicht het, en automatiseert daarna het meten.
Meten met pytest-cov
Coverage wordt gemeten door de bibliotheek coverage; pytest-cov is de plugin die het aanzet vanaf de pytest-opdrachtregel. Installeer het als ontwikkelafhankelijkheid:
$ uv add --dev pytest-covVertel het daarna welk pakket het in de gaten moet houden met --cov=:
$ pytest -q --cov=shop
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Cover
--------------------------------------
shop/__init__.py 0 0 100%
shop/shipping.py 11 4 64%
--------------------------------------
TOTAL 11 4 64%
2 passed in 0.09sStmts is het aantal uitvoerbare statements in het bestand. Miss is hoeveel daarvan nooit zijn gedraaid. Cover is het aandeel dat dat wel deed. Elf statements, vier nooit uitgevoerd: 64%.
De waarde na --cov= is de code die je gemeten wilt hebben, niet de tests. Je tests draaien altijd al hun eigen regels, dus die meetellen zou het totaal alleen maar gunstiger doen lijken.
De kolom Missing lezen
Een percentage zegt je hoeveel. Het zegt je niet waar. Voeg --cov-report=term-missing toe:
$ 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 zijn regelnummers in shipping.py. Tel het bestand na:
- Regel 3 is
raise ValueError(...)— geen test gaf een gewicht van nul of minder mee. - Regels 6 tot en met 9 zijn
elif country == "IN":,base = 90.0,else:enbase = 500.0— geen test stuurde een pakket ergens anders heen dan naar Bangladesh.
Regel 8, else:, is op zichzelf geen statement, dus die telt niet mee in de elf; coverage vouwt hem simpelweg op in het bereik 6-9 om de lijst kort te houden.
Die kolom is de to-do-lijst. Elk getal is een regel die volledig verkeerd kan zijn terwijl je suite toch groen blijft.
Het HTML-rapport
Bij een groot bestand wordt een lijst getallen lastig te volgen. --cov-report=html schrijft in plaats daarvan een 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.11sOpen htmlcov/index.html in een browser. Elk bestand staat er met zijn percentage; klik erop en je ziet de broncode, met gedraaide regels in het groen en gemiste regels in het rood. Het is dezelfde informatie als Missing, gelegd over de code zelf.
Zowel htmlcov/ als het gegevensbestand .coverage zijn gegenereerde output. Zet ze in .gitignore.
Branch coverage — waarom line coverage liegt
Hier is een functie waarvan line coverage zegt dat alles in orde is:
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%. Elke regel draaide. Maar niemand heeft final_price ooit zonder coupon aangeroepen. Een if zonder else heeft twee uitgangen — het blok in, of er rechtstreeks langs — en er is er maar één geprobeerd. Als iemand later het pad zonder coupon breekt, merkt dit rapport dat niet op.
Line coverage vraagt "is deze regel uitgevoerd?". Branch coverage vraagt "is elke beslissing beide kanten opgegaan?". Zet het aan met --cov-branch:
$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 4 0 2 1 83% 2->4
--------------------------------------------------------------
TOTAL 4 0 2 1 83%
1 passed in 0.10sTwee nieuwe kolommen. Branch is het aantal mogelijke sprongen (de if op regel 2 kan naar regel 3 of naar regel 4: twee). BrPart telt beslissingen die maar één kant op gingen. En Missing toont nu 2->4: de sprong van regel 2 rechtstreeks naar regel 4 — het pad "geen coupon" — is nooit gemaakt.
Branch coverage is strenger en dichter bij de waarheid. Er is weinig reden om het niet altijd aan te hebben staan.
Het getal een drempel maken: --cov-fail-under
Een rapport dat niemand leest verandert niets. --cov-fail-under maakt van het totaal een voorwaarde om te slagen of te falen:
$ 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.12sLees de laatste regel zorgvuldig: 1 passed. Elke test slaagde, en toch eindigt de opdracht met status 1. In CI is dat een rode build. Het punt is niet dat 90 een magisch getal is; het punt is dat coverage niet meer stilletjes kan wegzakken wanneer iemand code toevoegt zonder tests.
Kies een drempel net onder waar je nu staat en verhoog hem in de loop van de tijd. Hem op dag één op 100 zetten levert meestal op dat mensen zinloze tests schrijven om het getal tevreden te stellen.
Code bewust uitsluiten: # pragma: no cover
Sommige regels zijn het testen niet waard — bijvoorbeeld een blok dat alleen draait wanneer je het bestand met de hand uitvoert. Voeg een tweede test toe, assert final_price(200.0, None) == 200.0, zodat final_price volledig afgedekt is — en voeg daarna een __main__-blok toe aan de module:
def final_price(total: float, coupon: str | None) -> float:
if coupon == "SAVE10":
total = total * 0.9
return round(total, 2)
if __name__ == "__main__":
print(final_price(200.0, "SAVE10"))$ pytest -q --cov=shop --cov-branch --cov-report=term-missing
.. [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
--------------------------------------------------------------
shop/__init__.py 0 0 0 0 100%
shop/coupons.py 6 1 4 1 80% 8
--------------------------------------------------------------
TOTAL 6 1 4 1 80%
2 passed in 0.11sRegel 8, de print, draait nooit onder pytest — en dat zal hij ook nooit doen. Markeer de if met een commentaar dat coverage begrijpt:
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.08sWanneer de pragma op een regel staat die een blok opent, wordt het hele blok uitgesloten. Gebruik hem spaarzaam en eerlijk. Elke pragma is een regel die je hebt besloten niet te controleren; merk je dat je er steeds een toevoegt om een lastige branch te laten "verdwijnen", dan is die branch waarschijnlijk degene die het meest om een test vraagt.
In pyproject.toml zetten
Vier --cov-vlaggen elke keer intypen is de manier waarop ze vergeten worden. Coverage leest zijn eigen instellingen uit pyproject.toml, en de addopts die je in het vorige hoofdstuk tegenkwam zorgt dat pytest het aanzet:
[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]beheert meten:sourceis wat je in de gaten houdt (dus een kale--covzonder waarde is genoeg),branch = trueis--cov-branch.[tool.coverage.report]beheert rapporteren:fail_underis--cov-fail-under, enexclude_alsonoemt patronen die naast# pragma: no coverworden uitgesloten — hier elk__main__-blok, zonder commentaar in de code nodig.
Nu doet een gewone pytest alles: meten, rapporteren en afdwingen. Je ziet hem draaien in het complete voorbeeld hieronder.
100% coverage betekent niet dat het klopt
Coverage beantwoordt de vraag "is deze regel uitgevoerd?". Het beantwoordt niet de vraag "heeft iemand gecontroleerd of het resultaat klopt?". Een klein voorbeeld:
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.12sPerfecte score. En de functie is fout: een jaar dat deelbaar is door 100 is geen schrikkeljaar, tenzij het ook deelbaar is door 400. Nog één test, gekozen door over de regel na te denken en niet over regels code:
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.12sNog steeds 100% — en nu een mislukking. Coverage had deze bug niet kunnen aanwijzen, want er ontbrak geen regel; er ontbrak een geval. Gebruik coverage als een kaart van waar je zeker niet hebt gekeken, nooit als bewijs dat wat je bekeken hebt klopt.
Tests parallel draaien met pytest-xdist
Naarmate een suite groeit wordt hij traag, en een trage suite wordt minder vaak gedraaid. pytest-xdist verdeelt tests over meerdere processen. Acht tests die elk een halve seconde duren:
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 start één worker per CPU-core (vier op deze machine) en geeft elke worker tests om te draaien. Het werkt samen met --cov; de coverage van alle workers wordt gecombineerd in één rapport.
Er is een prijs. Elke worker is een apart proces, en de volgorde waarin tests draaien is niet meer de volgorde in het bestand. Elke test die er stilletjes op vertrouwde dat een andere test als eerste draaide, breekt:
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 ==========================De tweede test slaagde alleen omdat de eerste de gedeelde lijst al had gevuld. Op een andere worker was de lijst leeg. pytest test_cart.py::test_cart_has_one_item alleen uitvoeren faalt op dezelfde manier — xdist heeft de bug niet gemaakt, hij heeft hem blootgelegd.
De oplossing is dezelfde als in de hoofdstukken over fixtures: geef elke test zijn eigen toestand.
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 ===============================Een test moet alleen slaagen, in elke volgorde, op elke worker. Er is een plugin, pytest-randomly, die volledig op dat idee is gebouwd: hij schudt de volgorde van tests bij elke run, zodat een verborgen afhankelijkheid vroeg opduikt, op jouw machine, in plaats van maanden later in CI. Je hebt hem niet nodig om deze cursus te volgen; het is genoeg om te weten dat "de suite slaagt alleen in de volgorde van het bestand" een bug is.
Bij elke push draaien: GitHub Actions
De laatste stap is om er niet meer op te vertrouwen dat mensen onthouden de suite te draaien. Declareer de testtools als een ontwikkelgroep in pyproject.toml, zodat uv sync ze installeert:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]Voeg daarna .github/workflows/tests.yml toe:
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.xmlWat elk onderdeel doet:
on:— draai bij elke push naarmainen bij elke pull request.matrix:— de job draait drie keer, één keer per Python-versie, naast elkaar.fail-fast: falselaat alle drie afmaken, zodat je ziet of een mislukking specifiek is voor één versie.setup-uvinstalleert uv en zet de Python-versie vast voor dat exemplaar van de job.uv sync --lockedinstalleert precies watuv.lockzegt, en faalt wanneer het lockbestand verouderd is — dus CI test wat je hebt gecommit.uv run pytest -n auto --cov-report=xmldraait parallel; de instellingen voor coverage,fail_underinbegrepen, komen uitpyproject.toml, dus een daling van de coverage laat de build falen. Het XML-rapport staat naast het terminalrapport, klaar voor een coverage-dienst of om te downloaden.
Lokaal, na uv sync, geeft dezelfde opdracht hetzelfde resultaat (de laatste regels van de output):
$ 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 ===============================Een compleet voorbeeld
Een project met een src-indeling, beide modules uit dit hoofdstuk, en elke instelling op één plek.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml is degene uit de configuratiesectie hierboven. coupons.py houdt zijn __main__-blok, zonder pragma — exclude_also regelt dat. test_coupons.py heeft nog steeds alleen de SAVE10-test. En test_shipping.py is nu het testplan van het begin van het hoofdstuk, rij voor rij:
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 ===============================Drie dingen zijn het vermelden waard.
Ten eerste deed een gewone pytest alles — geen vlaggen. Het meten, de branchcontrole, de lijst met missende regels en de drempel kwamen allemaal uit pyproject.toml.
Ten tweede ging shipping.py van 64% naar 100% omdat het plan opgeschreven werd, niet omdat we rode regels najaagden. Coverage bevestigt daarna dat het plan elke regel en elke branch heeft bereikt. De rij (5, "BD", 60.0) en de keuze voor 0 in plaats van -1 voor het ongeldige gewicht voegen niets toe aan het percentage — ze staan er omdat het contract randen heeft, en juist daar zitten typfouten als < in plaats van <=.
Ten derde slaagt de build op 96% terwijl 2->4 nog steeds in de lijst staat. De drempel is een bodem, geen eindstreep. Het rapport zegt je nog steeds precies welke test je hierna schrijft: final_price zonder coupon.
Wanneer het misgaat
error: unrecognized arguments: --cov=shop pytest-cov is niet geïnstalleerd in de omgeving waarin pytest draait. Installeer het (uv add --dev pytest-cov) en voer uit via die omgeving, bijvoorbeeld met uv run pytest. Hetzelfde bericht met -n betekent dat pytest-xdist ontbreekt.
CoverageWarning: Module shopp was never imported. (module-not-imported) followed by No data was collected. De naam na --cov= komt niet overeen met een pakket dat je tests hebben geïmporteerd — hier een typfout. Controleer de spelling, en geef bij een src-indeling de importnaam (shop), niet de map (src).
WARNING: Failed to generate report: No data to report. Dezelfde oorzaak als hierboven: er is niets gemeten, dus er is niets om te rapporteren. De tests slaagden nog steeds, en dat is precies waarom dit makkelijk over het hoofd wordt gezien.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% Geen kapotte test — de drempel. Kijk naar de kolom Missing en schrijf de test waar hij naar wijst. fail_under verlagen om de build groen te maken ondermijnt het nut ervan.
Een test slaagt alleen maar faalt onder -n auto (of omgekeerd) De tests delen state: een lijst of dict op moduleniveau, een bestand op een vast pad, een omgevingsvariabele, een rij in een database. Zoek uit wat de falende test vooronderstelt er al te zijn, en geef dat in plaats daarvan aan de test via een fixture, tmp_path of monkeypatch.
Coverage is 100% maar gebruikers lopen nog steeds tegen bugs aan Verwacht. Coverage toont wat er draaide, niet wat er is gecontroleerd. Zoek naar gevallen — grenzen, lege invoer, het jaar 1900 — niet naar regels.
Stap 4 van 6 — Voorspellen
Test je begrip
Er is maar één test, en branch coverage staat aan. Wat toont de kolom Missing op de rij 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- ANiets — alles is afgedekt
- B4
- C2->4
- D3
Met pytest slaaggen beide tests, maar met pytest -n 2 faalt test_cart_has_one_item. Wat is het echte probleem?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdist heeft een bug; de tests zijn in orde
- BDe tweede test hangt ervan af dat de eerste heeft gedraaid — de twee tests delen state
- C`-n 2` voert elke test twee keer uit
- D`CART` staat op moduleniveau, dus hij wordt op de workers nooit aangemaakt
Een functie heeft 100% line- en branch coverage, en elke test slaagt. Wat kun je hieruit concluderen?
- AAlleen dat elke regel en elke kant van elke beslissing minstens één keer draaide — niet dat de resultaten kloppen
- BMet `fail_under = 100` kan er nooit meer een bug in de functie komen
- CDe functie kan geen bug aan de grens hebben
- DDe functie klopt en heeft geen verdere tests nodig
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
Begin een klein project met deze module, src/shop/loyalty.py:
def loyalty_points(total: float, is_member: bool) -> int:
if total < 0:
raise ValueError(f"total cannot be negative: {total}")
points = int(total // 100)
if is_member:
points *= 2
return pointsHet contract: één punt voor elke volle 100 die wordt uitgegeven, verdubbeld voor leden; een negatief totaal is een fout; nul is toegestaan. Het komt met één test, tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4En een pyproject.toml die bij elke run branch coverage meet:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- Voer
pytest -quit en lees de kolomMissing. Zeg met woorden wat elke vermelding betekent. - Voordat je een test schrijft, maak je een tabel met een testplan voor het contract — inclusief de grens bij precies 100 en de rand net eronder.
- Zet het plan om in één test met
parametrizeplus éénpytest.raises-test. - Voeg
fail_under = 100toe onder[tool.coverage.report]en bevestig dat de run slaagt. - Verwijder de rijen voor niet-leden en bevestig dat de run faalt, ook al slaagt elke resterende test.
- Draai de suite met
pytest -n autoen bevestig dat het resultaat identiek is.
Oplossing
Stap 1 — het rapport lezen.
$ 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 is de raise-regel: geen test gaf een negatief totaal mee. 5->7 is een sprong van de if is_member: rechtstreeks naar return points — het pad voor niet-leden, nooit genomen. Zonder branch coverage zou 5->7 helemaal niet verschijnen, want regel 6 is wel uitgevoerd (voor het lid).
Stap 2 — het plan.
| geval | invoer | verwacht | |---|---|---| | lid, verdubbeld | 250, True | 4 | | geen lid | 250, False | 2 | | net onder de eerste 100 | 99.99, False | 0 | | grens: precies 100 | 100, False | 1 | | nul is toegestaan | 0, True | 0 | | ongeldig: negatief | -1, True | ValueError, "cannot be negative" |
Stap 3 — de 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)Stap 4 — de drempel. Voeg toe aan pyproject.toml:
[tool.coverage.report]
fail_under = 100$ pytest -q
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.3-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
src/shop/__init__.py 0 0 0 0 100%
src/shop/loyalty.py 7 0 4 0 100%
------------------------------------------------------------------
TOTAL 7 0 4 0 100%
Required test coverage of 100.0% reached. Total coverage: 100.00%
6 passed in 0.09sStap 5 — bewijs dat de drempel werkt. Met de drie False-rijen verwijderd:
$ 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. Elke regel draait nog steeds — alleen de branch is weg — en de drempel vangt het op. Zet de rijen terug.
Stap 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 ===============================Dezelfde zes tests, dezelfde 100%: de coverage van vier workers werd samengevoegd in één rapport.
Waarom elke beslissing.
- Het plan kwam vóór de tests. Van de zes rijen eiste het rapport er slechts twee op (
3en5->7).99.99en100staan er omdat het contract een rand heeft bij 100, en0, Trueomdat het contract zegt dat nul is toegestaan — een test dat hij niet opwerpt. Coverage zou zonder al die rijen tevreden zijn geweest. - Waarom `-1` hier goed is. De guard is
total < 0, dus de rand zelf is0, en de rij0, Truelegt die al vast als geldig. Als iemand de guard zou veranderen intotal <= 0, zou die rij falen. Elk negatief getal doet dienst voor de ongeldige kant. - Waarom `fail_under = 100` hier en niet in een echt project. Zo'n kleine module kan gerust volledig afgedekt zijn. Op een grote codebase begin je net onder het huidige getal en verhoog je het.
- Waarom de run met `-n auto` iets bewijst. Elk geval krijgt zijn eigen argumenten en er wordt niets gedeeld tussen tests, dus volgorde en worker maken niet uit. Als het resultaat onder
-n autowas veranderd, dan was dat de bug geweest die je eerst moest repareren.
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