Coverage e CI — quali righe i tuoi test non hanno mai eseguito?
Misurare la coverage con pytest-cov, leggere la colonna Missing, coverage per rami, imporre un minimo, eseguire i test in parallelo con pytest-xdist, ed eseguire tutta la suite a ogni push con GitHub Actions. E perché il 100% di coverage non significa codice corretto.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una piccola funzione che calcola il costo di una spedizione, e due test per essa.
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.07sVerde. E quel verde non dice nulla delle parti della funzione che i test non hanno mai toccato. Una spedizione in India costa 90? Un peso zero alza davvero l'eccezione? Nessuno l'ha chiesto. Un errore di battitura nel ramo "IN" resterebbe lì, passando, finché un cliente non lo trova.
Con dodici righe controlli a occhio. Con dodicimila non puoi. Ti serve che il computer risponda a una domanda: quali righe i miei test non hanno mai eseguito? Quella misura si chiama coverage, e questo capitolo parla di ottenerla, leggerla, e non farti ingannare — e poi di eseguire tutta la suite velocemente e automaticamente a ogni push.
Alla fine di questo capitolo sarai in grado di
- Misurare la coverage con
pytest-cove leggere la colonnaMissing - Aprire il report HTML per vedere le righe non coperte nel contesto
- Spiegare perché la coverage per righe può dire 100% quando un ramo non è mai stato preso, e attivare la coverage per rami
- Far fallire l'esecuzione quando la coverage scende sotto una soglia, ed escludere codice di proposito
- Tenere tutto in
pyproject.toml - Dire perché il 100% di coverage non significa che il codice è corretto
- Eseguire i test in parallelo con
pytest-xdist, e spiegare perché i test non devono dipendere l'uno dall'altro - Scrivere un workflow di GitHub Actions che esegue la suite su diverse versioni di Python
Prerequisiti: configurazione e avvisi.
Prima di scrivere il test
Il modo sbagliato di usare la coverage è aprire il report per primo e scrivere qualunque test trasformi la prossima riga rossa in verde. Questo produce test modellati sul codice, non sulla promessa che il codice fa — e, come vedrai, manca bug che non hanno una riga loro. L'ordine giusto è: pianifica dal contratto, scrivi i test, e solo poi chiedi alla coverage cosa il piano ha dimenticato.
Il contratto. Leggi shipping_cost come una promessa, non come codice:
- peso zero o sotto →
ValueErroril cui messaggio dice "positive" - Bangladesh costa 60, India 90, qualsiasi altro posto 500
- sopra 5 kg, ogni chilo in più aggiunge 20; a esattamente 5 kg non si aggiunge nulla
- niente effetti collaterali: restituisce un numero e non tocca nient'altro
Cosa deve essere pronto. Un ambiente virtuale con pytest e pytest-cov installati (e pytest-xdist più avanti). Il pacchetto deve essere importabile dai test — qui shop, o installato o raggiunto attraverso pythonpath = ["src"] dal capitolo precedente. E devi conoscere l'import name del codice che stai misurando, perché è quello che va dopo --cov=.
Il piano. Una riga per comportamento, con confini e input non validi di proposito:
| Caso | Input | Atteso | |---|---|---| | caso ideale, paese di casa | 1, "BD" | 60.0 | | secondo paese | 1, "IN" | 90.0 | | qualsiasi altro posto | 1, "US" | 500.0 | | confine: esattamente 5 kg | 5, "BD" | 60.0 (nessun supplemento) | | oltre il limite | 7, "BD" | 100.0 (60 + 2 × 20) | | non valido: peso zero | 0, "BD" | ValueError, "positive" |
Cosa non testare. Codice che non hai scritto tu (round di Python, la libreria coverage stessa). Un blocco __main__ che viene eseguito solo quando un umano lancia il file. E mai un test il cui unico lavoro è far eseguire una riga — un test senza assert significativo alza la percentuale e non controlla nulla.
Nota le due righe del confine. Un test a 5 kg non esegue nessuna riga che il test a 1 kg non esegua già, quindi nessun report di coverage chiederà mai per esso. E per il caso non valido, un test con -1 coprirebbe la riga del raise esattamente come 0 — ma se qualcuno scrivesse weight_kg < 0 invece di <= 0, solo il test con 0 fallirebbe. La coverage non sa distinguere quei due test; il contratto sì. È tutta la relazione: il piano decide cosa testare; la coverage riporta cosa il piano ha mancato.
I due test dell'inizio coprono le righe 1 e 5. Il resto del capitolo misura quel divario, lo chiude, e poi automatizza la misura.
Misurare con pytest-cov
La coverage è misurata dalla libreria coverage; pytest-cov è il plugin che la accende dalla riga di comando di pytest. Installalo come dipendenza di sviluppo:
$ uv add --dev pytest-covPoi digli quale pacchetto guardare con --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 è il numero di statement eseguibili nel file. Miss è quanti non sono mai stati eseguiti. Cover è la quota che è stata eseguita. Undici statement, quattro mai eseguiti: 64%.
Il valore dopo --cov= è il codice che vuoi misurare, non i test. I tuoi test girano sempre tutte le loro righe, quindi contarli gonfierebbe solo il totale.
Leggere la colonna Missing
Una percentuale ti dice quanto. Non ti dice dove. Aggiungi --cov-report=term-missing:
$ 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 sono numeri di riga in shipping.py. Conta giù nel file:
- La riga 3 è
raise ValueError(...)— nessun test ha passato un peso zero o negativo. - Le righe da 6 a 9 sono
elif country == "IN":,base = 90.0,else:ebase = 500.0— nessun test ha mandato una spedizione da qualche parte che non fosse Bangladesh.
La riga 8, else:, non è uno statement a sé, quindi non è nel conto degli undici; la coverage la piega solo nell'intervallo 6-9 per tenere la lista corta.
Quella colonna è la lista delle cose da fare. Ogni numero è una riga che potrebbe essere completamente sbagliata e la tua suite sarebbe comunque verde.
Il report HTML
Per un file grande una lista di numeri diventa difficile da seguire. --cov-report=html scrive un piccolo sito:
$ 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.11sApri htmlcov/index.html in un browser. Ogni file è elencato con la sua percentuale; cliccane uno e vedi il sorgente, con le righe eseguite in verde e quelle mancate in rosso. Sono le stesse informazioni di Missing, sovrapposte al codice stesso.
Sia htmlcov/ che il file dati .coverage sono output generati. Mettili in .gitignore.
Coverage per rami — perché la coverage per righe mente
Ecco una funzione dove la coverage per righe dice che tutto va bene:
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%. Ogni riga è girata. Ma nessuno ha mai chiamato final_price senza un coupon. Un if senza else ha due vie d'uscita — dentro il corpo, o dritto oltre — e solo una è stata provata. Se qualcuno rompe il percorso senza coupon, questo report non se ne accorgerà.
La coverage per righe chiede "questa riga è stata eseguita?". La coverage per rami chiede "ogni decisione è andata in entrambi i modi?". Accendila con --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.10sDue colonne nuove. Branch è il numero di salti possibili (l'if alla riga 2 può andare alla riga 3 o alla riga 4: due). BrPart conta le decisioni che sono andate in un solo verso. E Missing ora mostra 2->4: il salto dalla riga 2 dritto alla riga 4 — il percorso "nessun coupon" — non è mai successo.
La coverage per rami è più severa e più vicina alla verità. C'è poco motivo per non tenerla accesa sempre.
Rendere il numero una soglia: --cov-fail-under
Un report che nessuno legge non cambia nulla. --cov-fail-under trasforma il totale in una condizione passa/fallisci:
$ 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.12sLeggi bene l'ultima riga: 1 passed. Ogni test è passato, eppure il comando esce con stato 1. In CI è una build rossa. Il punto non è che 90 sia un numero magico; è che la coverage non può più scivolare giù in silenzio quando qualcuno aggiunge codice senza test.
Scegli una soglia un po' sotto dove sei oggi e alzala nel tempo. Metterla a 100 dal primo giorno finisce di solito con la gente che scrive test inutili per accontentare il numero.
Escludere codice di proposito: # pragma: no cover
Alcune righe non valgono la pena di essere testate — un blocco che viene eseguito solo quando lanci il file a mano, per dire. Aggiungi un secondo test, assert final_price(200.0, None) == 200.0, così final_price è completamente coperto — e poi un blocco __main__ al modulo:
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.11sLa riga 8, il print, non viene mai eseguita sotto pytest — e non lo sarà mai. Marchia l'if con un commento che la coverage capisce:
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.08sQuando il pragma sta su una riga che apre un blocco, tutto il blocco è escluso. Usalo con parsimonia e onestà. Ogni pragma è una riga che hai deciso di non controllare; se ti ritrovi ad aggiungerne uno per far "sparire" un ramo difficile, quel ramo è probabilmente quello che più ha bisogno di un test.
Metterlo in pyproject.toml
Digitare quattro flag --cov ogni volta è il modo per dimenticarli. La coverage legge le sue impostazioni da pyproject.toml, e l'addopts che hai incontrato nel capitolo precedente fa accendere pytest:
[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]controlla la misura:sourceè cosa guardare (quindi un--covnudo senza valore basta),branch = trueè--cov-branch.[tool.coverage.report]controlla la reportistica:fail_underè--cov-fail-under, eexclude_alsoelenca pattern da escludere oltre a# pragma: no cover— qui ogni blocco__main__, senza commento nel codice.
Ora un semplice pytest misura, riporta e fa rispettare. Lo vedrai eseguire nell'esempio completo sotto.
Il 100% di coverage non significa corretto
La coverage risponde "questa riga è stata eseguita?". Non risponde "qualcuno ha controllato che il risultato fosse giusto?". Un piccolo esempio:
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.12sPunteggio perfetto. E la funzione è sbagliata: un anno divisibile per 100 non è bisestile a meno che non sia anche divisibile per 400. Un test in più, scelto pensando alla regola invece che alle righe:
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.12sAncora 100% — e ora un fallimento. La coverage non poteva indicare questo bug, perché non c'era nessuna riga mancante; la cosa mancante era un caso. Tratta la coverage come una mappa di dove non hai guardato per certo, mai come prova che ciò che hai guardato è giusto.
Eseguire i test in parallelo con pytest-xdist
Man mano che una suite cresce diventa lenta, e una suite lenta viene eseguita meno spesso. pytest-xdist distribuisce i test su diversi processi. Otto test che ognuno impiega mezzo secondo:
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 avvia un worker per core CPU (quattro su questa macchina) e assegna a ognuno dei test da eseguire. Funziona insieme a --cov; i dati di coverage di tutti i worker vengono combinati in un unico report.
C'è un prezzo. Ogni worker è un processo separato, e l'ordine in cui i test vengono eseguiti non è più l'ordine nel file. Qualsiasi test che abbia contato silenziosamente su un altro che è stato eseguito prima si romperà:
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 ==========================Il secondo test passava solo perché il primo aveva già riempito la lista condivisa. Su un worker diverso, la lista era vuota. Eseguire pytest test_cart.py::test_cart_has_one_item da solo fallisce allo stesso modo — xdist non ha creato il bug, l'ha rivelato.
La correzione è quella dei capitoli sui fixture: dai a ogni test il suo stato.
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 ===============================Un test deve passare da solo, in qualsiasi ordine, su qualsiasi worker. C'è un plugin, pytest-randomly, costruito interamente su quell'idea: mischia l'ordine dei test a ogni esecuzione, così una dipendenza nascosta viene fuori presto, sulla tua macchina, invece che mesi dopo in CI. Non ti serve per seguire questo corso; basta sapere che "la suite passa solo nell'ordine del file" è un bug.
Eseguirlo a ogni push: GitHub Actions
L'ultimo passo è smettere di contare sulla gente che ricorda di eseguire la suite. Dichiarare gli strumenti di test come gruppo di sviluppo in pyproject.toml, così uv sync li installa:
[dependency-groups]
dev = [
"pytest>=9",
"pytest-cov>=7",
"pytest-xdist>=3.8",
]Poi aggiungi .github/workflows/tests.yml:
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.xmlCosa fa ogni parte:
on:— esegue a ogni push sumaine a ogni pull request.matrix:— il job viene eseguito tre volte, una per versione di Python, affiancate.fail-fast: falselascia finire tutte e tre, così vedi se un fallimento è specifico a una versione.setup-uvinstalla uv e fissa la versione di Python per quella copia del job.uv sync --lockedinstalla esattamente ciò che diceuv.lock, e fallisce se il lock file è fuori data — così CI testa quello che hai committato.uv run pytest -n auto --cov-report=xmlviene eseguito in parallelo; le impostazioni di coverage, inclusofail_under, vengono dapyproject.toml, quindi un calo di coverage fa fallire la build. Il report XML sta accanto a quello terminale, pronto per un servizio di coverage o per il download.
In locale, dopo uv sync, lo stesso comando dà lo stesso risultato (le ultime righe dell'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 ===============================Un esempio completo
Un progetto con layout src, entrambi i moduli di questo capitolo, e ogni impostazione in un posto.
shop-project/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ ├── coupons.py
│ └── shipping.py
└── tests/
├── test_coupons.py
└── test_shipping.pypyproject.toml è quello della sezione sulla configurazione sopra. coupons.py mantiene il suo blocco __main__, senza pragma — exclude_also ci pensa. test_coupons.py ha ancora solo il test SAVE10. E test_shipping.py è ora il piano dei test dall'inizio del capitolo, riga per riga:
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 ===============================Tre cose valgono la pena di essere notate.
Prima, un pytest semplice ha fatto tutto — niente flag. Misurare, controllare i rami, la lista dei mancanti e la soglia sono tutti venuti da pyproject.toml.
Seconda, shipping.py è passato dal 64% al 100% perché il piano è stato scritto, non perché abbiamo inseguito le righe rosse. La coverage conferma poi che il piano ha raggiunto ogni riga e ogni ramo. La riga (5, "BD", 60.0) e la scelta di 0 invece di -1 per il peso non valido non aggiungono nulla alla percentuale — ci sono perché il contratto ha dei bordi, ed è ai bordi che vivono errori di battitura come < al posto di <=.
Terza, la build passa al 96% mentre 2->4 è ancora elencato. La soglia è un minimo, non un traguardo. Il report ti sta ancora dicendo esattamente quale test scrivere dopo: final_price senza coupon.
Quando si rompe
error: unrecognized arguments: --cov=shop pytest-cov non è installato nell'ambiente da cui viene eseguito pytest. Installalo (uv add --dev pytest-cov) ed eseguilo tramite quell'ambiente, per esempio con uv run pytest. Lo stesso messaggio con -n significa che manca pytest-xdist.
CoverageWarning: Module shopp was never imported. (module-not-imported) seguito da No data was collected. Il nome dopo --cov= non corrisponde a nessun pacchetto che i tuoi test hanno importato — qui un errore di battitura. Controlla l'ortografia, e con un layout src dai l'import name (shop), non la cartella (src).
WARNING: Failed to generate report: No data to report. Stessa causa di sopra: nulla è stato misurato, quindi non c'è nulla da riportare. I test sono comunque passati, ed è proprio per questo che questo è facile da perdere.
FAIL Required test coverage of 90% not reached. Total coverage: 83.33% Non è un test rotto — è la soglia. Guarda la colonna Missing e scrivi il test che indica. Abbassare fail_under per far diventare la build verde sconfigge lo scopo di averla.
Un test passa da solo ma fallisce sotto -n auto (o viceversa) I test condividono stato: una lista o dizionario a livello di modulo, un file a un percorso fisso, una variabile d'ambiente, una riga di database. Trova cosa il test che fallisce assume sia già lì, e forniscilo tramite un fixture, tmp_path o monkeypatch.
La coverage è 100% ma gli utenti trovano comunque bug Previsto. La coverage mostra cosa è stato eseguito, non cosa è stato controllato. Cerca i casi — confini, input vuoti, l'anno 1900 — non le righe.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
C'è un solo test, e la coverage per rami è accesa. Cosa mostra la colonna Missing sulla riga 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- ANulla — 100% coperto
- B4
- C2->4
- D3
Con pytest entrambi i test passano, ma con pytest -n 2 test_cart_has_one_item fallisce. Qual è il vero problema?
CART = []
def test_add_item():
CART.append("pen")
assert CART == ["pen"]
def test_cart_has_one_item():
assert len(CART) == 1- Apytest-xdist ha un bug; i test vanno bene
- BIl secondo test dipende dal primo che è stato eseguito — i due test condividono stato
- C`-n 2` esegue ogni test due volte
- D`CART` è a livello di modulo, quindi non viene mai creato sui worker
Una funzione ha il 100% di coverage per righe e per rami, e tutti i test passano. Cosa puoi concludere?
- ASolo che ogni riga e ogni lato di ogni decisione è stata eseguita almeno una volta — non che i risultati siano giusti
- BCon `fail_under = 100`, nessun bug può entrare nella funzione di nuovo
- CLa funzione non può avere un bug sul confine
- DLa funzione è corretta e non servono altri test
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Avvia un piccolo progetto con questo modulo, 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 pointsIl contratto: un punto per ogni 100 interi spesi, raddoppiato per i membri; un totale negativo è un errore; zero è permesso. Arriva con un singolo test, tests/test_loyalty.py:
from shop.loyalty import loyalty_points
def test_member_gets_double_points():
assert loyalty_points(250, True) == 4E un pyproject.toml che misura la coverage per rami a ogni esecuzione:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "--cov --cov-report=term-missing"
[tool.coverage.run]
source = ["shop"]
branch = true- Esegui
pytest -qe leggi la colonnaMissing. Di' a parole cosa significa ogni voce. - Prima di scrivere qualsiasi test, scrivi una tabella del piano dei test per il contratto — incluso il confine a esattamente 100 e il limite appena sotto.
- Trasforma il piano in un test parametrizzato più un test
pytest.raises. - Aggiungi
fail_under = 100sotto[tool.coverage.report]e conferma che l'esecuzione passa. - Cancella le righe non-membro e conferma che l'esecuzione fallisce anche se ogni test rimasto passa.
- Esegui la suite con
pytest -n autoe conferma che il risultato è identico.
Soluzione
Passo 1 — leggi il report.
$ 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 è la riga del raise: nessun test ha passato un totale negativo. 5->7 è un salto dall'if is_member: dritto a return points — il percorso non-membro, mai preso. Senza la coverage per rami, 5->7 non apparirebbe affatto, perché la riga 6 è stata eseguita (per il membro).
Passo 2 — il piano.
| Caso | Input | Atteso | |---|---|---| | membro, raddoppiato | 250, True | 4 | | non-membro | 250, False | 2 | | appena sotto i primi 100 | 99.99, False | 0 | | confine: esattamente 100 | 100, False | 1 | | zero è permesso | 0, True | 0 | | non valido: negativo | -1, True | ValueError, "cannot be negative" |
Passo 3 — i test. 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)Passo 4 — la soglia. Aggiungi a 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.09sPasso 5 — dimostra che la soglia funziona. Con le tre righe False cancellate:
$ 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, stato d'uscita 1. Ogni riga viene ancora eseguita — solo il ramo manca — e la soglia lo intercetta. Rimetti le righe.
Passo 6 — in parallelo.
$ 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 ===============================Stessi sei test, stesso 100%: la coverage dai quattro worker è stata fusa in un unico report.
Perché ogni decisione.
- Il piano ha preceduto i test. Delle sei righe, solo due erano richieste dal report (
3e5->7).99.99e100ci sono perché il contratto ha un confine a 100, e0, Trueperché il contratto dice che zero è permesso — un test che non alza. La coverage sarebbe stata soddisfatta senza nessuno di loro. - Perché `-1` va bene qui. La guardia è
total < 0, quindi il confine stesso è0, e la riga0, Truelo fissa già come valido. Se qualcuno cambiasse la guardia intotal <= 0, quella riga fallirebbe. Qualsiasi numero negativo poi serve per il lato non valido. - Perché `fail_under = 100` qui ma non su un progetto vero. Un modulo così piccolo può ragionevolmente essere completamente coperto. Su una codebase grande, inizia appena sotto il numero di oggi e alzalo.
- Perché l'esecuzione con `-n auto` dimostra qualcosa. Ogni caso ha i suoi argomenti e nulla è condiviso tra i test, quindi l'ordine e il worker non contano. Se il risultato fosse cambiato sotto
-n auto, quello sarebbe stato il bug da correggere per primo.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz