Configurazione e avvisi — le regole in un unico file
Smetti di digitare gli stessi flag a ogni esecuzione: metti le regole del progetto in pyproject.toml o pytest.ini — testpaths, addopts, pythonpath, markers, xfail_strict, log_cli — e testa e controlla gli avvisi con pytest.warns, deprecated_call e filterwarnings.
Come vuoi imparare?
Il problema che stiamo risolvendo
Il progetto shop ora ha la forma che condividono la maggior parte dei progetti reali: codice in src/, test in tests/.
shop/
├── pyproject.toml
├── src/
│ └── shop/
│ ├── __init__.py
│ └── pricing.py
└── tests/
└── test_pricing.pyHai imparato a eseguirlo correttamente, e "correttamente" è diventato una lunga riga:
PYTHONPATH=src pytest -ra --strict-markers testsUn collega clona il repository e digita quello che chiunque digiterebbe:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
collected 0 items / 1 error
==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_pricing.py ____________________
ImportError while importing test module '/home/you/shop/tests/test_pricing.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_pricing.py:1: in <module>
from shop.pricing import line_total
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR tests/test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.01s ===============================Non c'è nulla di sbagliato nel codice. Quello che non va è che il modo di eseguire i test vive nella tua testa e nella cronologia del tuo shell, invece che nel progetto. CI eseguirà una terza variante, e tre persone vedrano tre risultati da un unico commit.
Quei flag non sono una tua preferenza; sono le regole del progetto. Questo capitolo li sposta in un file che pytest legge ogni volta — e poi usa lo stesso file per risolvere un secondo problema, più silenzioso: gli avvisi che scorrono via e non vengono mai letti.
Alla fine di questo capitolo sarai in grado di
- Mettere le impostazioni di pytest in
pyproject.toml(opytest.ini) e confermare dall'intestazione quale file è stato usato - Scegliere
testpaths,addopts,pythonpath,minversion,markersexfail_strictin modo deliberato, e spiegare perché - Attivare i log in tempo reale con
log_cliquando hai bisogno di osservare un'esecuzione - Verificare che il codice emetta avvisi con
pytest.warnsepytest.deprecated_call, usandomatche l'elenco registrato - Controllare gli avvisi con
filterwarningsnella configurazione, come marker e con-W— e renderli errori in CI
Prerequisiti: marker, skip e xfail.
Prima di scrivere il test
Questo capitolo scrive meno test del solito e più regole su come vengono eseguiti i test. Il ragionamento che viene prima è lo stesso: decidi cosa è promesso, poi pianifica i casi che verificano la promessa.
Cosa è promesso. Due tipi di promessa sono in gioco.
- Come viene eseguita la suite. Chiunque digiti solo
pytestottiene gli stessi test, lo stesso percorso di importazione e la stessa severità di CI. Un marker scritto male ferma l'esecuzione. Unxfailche inizia a passare viene riportato, non nascosto. - Cosa dice il codice oltre al suo valore di ritorno.
apply_discount(total, percent)restituisce un numero, e perpercent > 50emette anche unUserWarningche dice che lo sconto "looks unusually large".total_with_vat()funziona ancora ma emette unDeprecationWarningche punta aline_total(). Un avviso è output; quando fa parte del contratto, viene testato come l'output.
Cosa deve essere pronto. Un ambiente virtuale con pytest 9 (pytest --version), il codice importabile dai test (cioè pythonpath più avanti), e un file di configurazione alla radice del progetto. Nessun plugin extra.
Il piano per il comportamento degli avvisi:
| caso | input | atteso | | --- | --- | --- | | caso ideale | apply_discount(200.0, 10) | 180.0, nessun avviso | | confine, ancora tranquillo | apply_discount(200.0, 50) | 100.0, nessun avviso | | appena oltre il confine | apply_discount(200.0, 60) | 80.0 e un UserWarning che menziona "unusually large" | | nome deprecato | total_with_vat(100.0, 1) | 115.0 e un DeprecationWarning che menziona line_total | | la deprecazione di una libreria | qualsiasi cosa la attivi | non fa fallire la nostra suite |
Per la configurazione stessa, il "test" è un'esecuzione: pytest solo, dalla radice e da dentro tests/, deve raccogliere solo tests/ e passare, e un marker scritto male deve fermare l'esecuzione.
Cosa non testare. Non che il modulo warnings di Python funzioni, né che pytest legga la sua configurazione — quello è codice testato da altri. Non l'intero testo di un avviso: abbina la frase su cui un lettore agisce ("unusually large"), così riformulare il resto non rompe nulla. E non le deprecazioni di una libreria di terze parti: quelle vengono filtrate, il che è una decisione di configurazione, non un test.
La riga del confine è quella che le persone dimenticano, perché un avviso mancante non fa rumore. Alla fine del capitolo, una riga di configurazione la trasforma in un controllo che ottieni gratis.
Un file, letto prima di ogni altra cosa
pytest legge le sue impostazioni prima di raccogliere un singolo test. La sede abituale è il pyproject.toml che il progetto ha già, in una tabella chiamata [tool.pytest.ini_options]:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
pythonpath = ["src"]pythonpath elenca cartelle, relative alla radice del progetto, che pytest aggiunge a sys.path prima di importare i test. Il codice sotto test è normale:
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)$ PYTHONPATH=src python -c "from shop.pricing import line_total; print(line_total(15.0, 3))"
51.75
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 items
tests/test_pricing.py .. [100%]
============================== 2 passed in 0.01s ===============================Leggi l'intestazione. configfile: pyproject.toml è pytest che ti dice quale file ha usato. Ogni volta che le impostazioni sembrano essere ignorate, controlla prima questa riga.
Perché non sistemare l'importazione dentro il file di test? È il modo sbagliato più ovvio, e funziona — a volte:
import sys
sys.path.insert(0, "src") # relative to wherever pytest was started
from shop.pricing import line_total
def test_three_items():
assert line_total(15.0, 3) == 51.75$ pytest -q
. [100%]
1 passed in 0.01s
$ cd tests && pytest -q
E ModuleNotFoundError: No module named 'shop'
=========================== short test summary info ============================
ERROR test_pricing.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01s"src" lì è relativa alla cartella corrente, quindi il test passa o si rompe a seconda di dove ti trovi. pythonpath nella configurazione è relativo alla radice del progetto, che non si muove. L'impostazione degli import appartiene al progetto, quindi va nel file del progetto una volta sola, non in cima a ogni modulo di test.
Quale file prevale, e dove sta la radice
pytest accetta diversi file. In ogni cartella controlla, in questo ordine: pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg. Parte dai percorsi sulla riga di comando (o dalla cartella corrente) e risale verso l'alto; il primo file che contiene davvero una sezione pytest prevale. Un pyproject.toml senza una tabella pytest non conta.
Quella cartella diventa la rootdir — la riga rootdir: dell'intestazione — e i percorsi relativi nella configurazione sono relativi a essa. Così stare dentro tests/ trova ancora la radice:
$ cd tests && pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pyproject.toml
collected 2 itemsLe stesse impostazioni in pytest.ini usano la sintassi INI, una sezione [pytest], e niente virgolette o parentesi:
[pytest]
pythonpath = src
testpaths = tests
addopts = -ra --strict-markers
markers =
slow: takes more than a second; deselect with -m 'not slow'Scegli un file. Se esistono entrambi, pytest.ini prevale completamente — non viene unito nulla — e l'intestazione lo dice. Qui è stato aggiunto un pytest.ini di due righe (minversion = 9.0) accanto al pyproject.toml:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/shop
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
collected 0 items / 1 error…e il ModuleNotFoundError torna, perché pythonpath viveva nel file che ora è ignorato.
testpaths — dove guardare quando non dici nulla
Qualcuno ha lasciato un file di appunti, notes/test_scratch.py, per provare l'arrotondamento. pytest solo raccoglie tutto sotto la radice, quindi viene eseguito anche quello:
$ pytest -q --tb=no
F.. [100%]
=========================== short test summary info ============================
FAILED notes/test_scratch.py::test_try_rounding - assert 2.67 == 2.68
1 failed, 2 passed in 0.01stestpaths dice dove vive la vera suite:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]$ pytest -q
.. [100%]
2 passed in 0.01sNell'intestazione completa vedresti adesso anche testpaths: tests. È un valore predefinito, non una barriera: si applica solo quando esegui dalla radice senza indicare percorsi, quindi pytest notes esegue comunque il file di appunti. Su un repository grande accelera anche la raccolta, perché pytest smette di risalere docs/, build/ o la cartella di un ambiente virtuale.
addopts e markers — regole che valgono per tutti
addopts è del testo che pytest mette davanti a quello che digiti. Le due regole del progetto dall'inizio vanno qui:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"-ra stampa una breve riga di riepilogo per ogni test che non è passato in modo limpido — saltato, xfailed, xpassed, fallito — così uno skip non può mai passare inosservato. --strict-markers rende un marker non registrato un errore. Un nuovo file, tests/test_discounts.py, ha un test marcato @pytest.mark.slow (e uno saltato quando non si è su Windows), e il marker non è ancora registrato:
$ pytest -q
==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_discounts.py ___________________
'slow' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_discounts.py - Failed: 'slow' not found in `markers` configu...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sQuesto è lo scopo del flag. Senza di esso, un errore di battitura come @pytest.mark.slwo ottiene solo un PytestUnknownMarkWarning nel riepilogo, l'esecuzione resta verde, e -m "not slow" continua a eseguire il test che volevi escludere. Registra il marker, una riga ciascuno, con una descrizione che pytest --markers mostrerà:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01s
$ pytest --markers
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'La riga SKIPPED è -ra al lavoro: uno skip ora si spiega da solo a ogni esecuzione.
Quello che non va messo in addopts. Il modo sbagliato è addopts = "-v -x --pdb". Sono le tue abitudini per il debug di oggi; nel file condiviso vengono imposte a tutti, CI compresa — dove --pdb aspetta per sempre una tastiera davanti alla quale nessuno è seduto. Metti in addopts solo le regole del progetto. Quello che digiti viene aggiunto dopo, quindi pytest -x funziona ancora per te da solo, e -o sovrascrive qualsiasi impostazione per un'unica esecuzione:
$ pytest -q -o addopts=""
.s.. [100%]
3 passed, 1 skipped in 0.01sQuesta volta nessuna riga di skip, perché -ra è stato eliminato per quell'esecuzione.
minversion e xfail_strict
minversion ferma subito l'esecuzione quando il pytest di qualcuno è troppo vecchio per il file. Chiedere una versione che non esiste mostra il messaggio:
$ pytest
ERROR: /home/you/mv/pytest.ini: 'minversion' requires pytest-10.0, actual pytest-9.1.1'Un errore chiaro all'avvio è meglio di un pytest più vecchio che capisce a metà le impostazioni.
xfail_strict affronta il debito aperto del capitolo scorso: un xfail che inizia a passare. Qui un bug era marcato come destinato a fallire, e da allora qualcuno lo ha corretto:
import pytest
def refund(amount):
return round(amount * 0.9, 2)
@pytest.mark.xfail(reason="bug #41: refunds lose a cent")
def test_refund_keeps_cents():
assert refund(10.0) == 9.0$ pytest -q
X [100%]
1 xpassed in 0.01sUn'esecuzione verde, una X che nessuno legge, e un marker che ora mente. Con xfail_strict = true nella configurazione:
$ pytest -q
F [100%]
=================================== FAILURES ===================================
___________________________ test_refund_keeps_cents ____________________________
[XPASS(strict)] bug #41: refunds lose a cent
=========================== short test summary info ============================
FAILED test_refunds.py::test_refund_keeps_cents - [XPASS(strict)] bug #41: re...
1 failed in 0.01sLa buona notizia ora arriva come un fallimento che ti dice di rimuovere il marker — ed è per questo che vale la pena impostarlo per l'intero progetto anziché test per test. pytest 9.1 accetta anche il nome più recente strict_xfail, e un singolo strict = true che attiva tutte le opzioni di severità in una volta (marker e xfail compresi); xfail_strict continua a funzionare.
Log in tempo reale: log_cli
Per impostazione predefinita pytest cattura i record di log e li mostra solo per i test falliti. Per osservare un'esecuzione che passa mentre racconta la sua storia, attiva i log in tempo reale:
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"Le registrazioni INFO e superiori di ogni test vengono quindi stampate sotto un'intestazione live log call mentre il test viene eseguito; l'esempio completo più avanti lo mostra. I log in tempo reale sono rumorosi, quindi molti progetti tengono nel file solo log_cli_level e attivano l'output quando serve con pytest -o log_cli=true.
La tabella nativa di pytest 9: [tool.pytest]
[tool.pytest.ini_options] ha un nome strano per un motivo: sono impostazioni INI che stanno in un file TOML, quindi ogni valore viene convertito in testo. pytest 9 aggiunge una tabella nativa, [tool.pytest], dove i valori mantengono i loro tipi TOML — liste vere, booleani veri:
[tool.pytest]
minversion = "9.0"
pythonpath = ["src"]
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers"]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
strict_xfail = true$ pytest -q
.s.. [100%]
=========================== short test summary info ============================
SKIPPED [1] tests/test_discounts.py:14: Windows rounding check
3 passed, 1 skipped in 0.01saddopts ora è una lista. Due regole: usa una tabella o l'altra, mai entrambe in un solo file (pytest rifiuta di partire), e ricorda che pytest prima della 9 non legge affatto [tool.pytest]. Questo capitolo usa [tool.pytest.ini_options] perché funziona su ogni pytest che probabilmente incontrerai; su un progetto che esegue solo pytest 9, la tabella nativa è la forma consigliata. pytest 9 legge anche un pytest.toml autonomo con una tabella [pytest] nello stesso stile nativo.
La cache, in breve
Dopo un'esecuzione c'è una cartella .pytest_cache/ alla radice. Ricorda quali test sono falliti l'ultima volta, ed è quello che alimenta --lf (last failed) e --ff (failed first). Due flag valcono la pena di essere conosciuti. pytest --cache-clear la svuota prima dell'esecuzione — utile quando --lf continua a scegliere test che non esistono più. pytest -p no:cacheprovider disattiva del tutto il plugin della cache, quindi non viene scritto nulla, il che va bene per una checkout in sola lettura. Il costo è che le opzioni del plugin spariscono con esso:
$ pytest -q -p no:cacheprovider --lf
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --lf
inifile: /home/you/mv/pytest.ini
rootdir: /home/you/mvAvvisi: la sezione in fondo all'esecuzione
Un avviso è un messaggio che dice che qualcosa non è ancora sbagliato. Python lo stampa e continua:
import warnings
def line_total(price, quantity):
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
return round(total * (100 - percent) / 100, 2)$ python -c "from pricing import apply_discount; print(apply_discount(200.0, 60))"
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
80.0stacklevel=2 fa sì che la deprecazione punti alla riga del chiamante, la riga che qualcuno deve cambiare. pytest intercetta gli avvisi sollevati durante i test e li raccoglie in una sola sezione alla fine:
from pricing import total_with_vat
def test_old_name_still_works():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
. [100%]
=============================== warnings summary ===============================
test_old_api.py::test_old_name_still_works
/home/you/wn/test_old_api.py:5: DeprecationWarning: total_with_vat() is deprecated; use line_total()
assert total_with_vat(100.0, 1) == 115.0
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
1 passed, 1 warning in 0.01sIl test è passato, e l'ultima riga dice 1 passed, 1 warning. In una suite grande questa sezione cresce fino a dozzine di voci, tutti imparano a scorrerla, e un giorno la funzione deprecata viene rimossa e la suite che "passava" si rompe. Con un avviso puoi fare due cose: aspettartelo, quando fa parte del comportamento, o rifiutarlo, quando non lo è.
pytest.warns — aspettarsi un avviso
Quando un avviso fa parte del contratto — la riga "appena oltre il confine" del piano — testalo come testi un'eccezione con pytest.raises:
import pytest
from pricing import apply_discount
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large"):
result = apply_discount(200.0, 60)
assert result == 80.0
def test_small_discount_is_quiet():
assert apply_discount(200.0, 10) == 180.0$ pytest -q test_discount.py
.. [100%]
2 passed in 0.01sIl blocco passa quando almeno un UserWarning (o sottoclasse) viene emesso al suo interno e il suo messaggio corrisponde a match, un'espressione regolare cercata nel testo, come con pytest.raises. Entrambe le metà falliscono in modo rumoroso. Qui il codice è tranquillo a 50, e il secondo test cerca le parole sbagliate:
import pytest
from pricing import apply_discount
def test_fifty_percent_warns():
with pytest.warns(UserWarning):
apply_discount(200.0, 50)
def test_wrong_message():
with pytest.warns(UserWarning, match="too large"):
apply_discount(200.0, 60)$ pytest -q test_discount_bad.py
FF [100%]
=================================== FAILURES ===================================
___________________________ test_fifty_percent_warns ___________________________
def test_fifty_percent_warns():
> with pytest.warns(UserWarning):
E Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted.
E Emitted warnings: [].
test_discount_bad.py:7: Failed
______________________________ test_wrong_message ______________________________
def test_wrong_message():
> with pytest.warns(UserWarning, match="too large"):
E Failed: Regex pattern did not match any of the 1 warnings emitted.
E Regex: 'too large'
E Emitted warnings: [UserWarning('discount of 60% looks unusually large')].
test_discount_bad.py:12: Failed
=============================== warnings summary ===============================
test_discount_bad.py::test_wrong_message
/home/you/wn/pricing.py:19: UserWarning: discount of 60% looks unusually large
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED test_discount_bad.py::test_fifty_percent_warns - Failed: DID NOT WARN....
FAILED test_discount_bad.py::test_wrong_message - Failed: Regex pattern did n...
2 failed, 1 warning in 0.01sEntrambi i report elencano gli avvisi che sono stati emessi, di solito tutto quello che ti serve per correggere il test. Un avviso che non corrisponde non viene ingoiato nemmeno; finisce nel riepilogo degli avvisi.
Perché dare sempre match. pytest.warns(UserWarning) da solo è la forma debole: qualsiasi UserWarning da qualsiasi punto del blocco lo soddisfa — incluso uno di una libreria che stai chiamando, per un motivo non correlato. Il test potrebbe continuare a passare dopo che l'avviso che ti interessa è scomparso. Una frase che corrisponde inchioda il test a questo avviso.
Per controllare quanti avvisi sono stati emessi, o cosa dicevano, tieni il record con as:
import pytest
from pricing import apply_discount
def test_each_large_discount_warns_once():
with pytest.warns(UserWarning) as record:
apply_discount(100.0, 60)
apply_discount(100.0, 10)
apply_discount(100.0, 75)
assert len(record) == 2
print(record[0].message)
print(record[1].category.__name__, record[1].lineno)$ pytest -q -s test_record.py
discount of 60% looks unusually large
UserWarning 19
.
1 passed in 0.01srecord elenca ogni avviso catturato nel blocco — due, perché la chiamata al 10% è tranquilla. Ogni voce ha .message, .category, .filename e .lineno.
pytest.deprecated_call — una promessa ai tuoi utenti
Deprecare una funzione è una promessa: "funziona ancora, e te l'abbiamo detto". pytest.deprecated_call() controlla entrambe le metà:
import pytest
from pricing import total_with_vat
def test_old_name_still_works():
with pytest.deprecated_call():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q test_old_api.py
. [100%]
1 passed in 0.01sÈ pytest.warns con le categorie già compilate — DeprecationWarning, PendingDeprecationWarning e FutureWarning — e accetta anche match. La voce nel riepilogo degli avvisi è sparita: l'avviso era atteso, quindi è stato consumato.
filterwarnings — rifiutare gli avvisi
Gli avvisi attesi ora sono test. Il resto è rumore o allarmi anticipati, e l'abitudine di CI che vale la pena adottare è: ogni avviso inaspettato è un errore. Una riga:
[tool.pytest.ini_options]
filterwarnings = [
"error",
]Ecco una suite con un test pulito, uno che chiama ancora il nome deprecato, e uno che chiama currency.rate() di una libreria che non puoi modificare, che emette il suo DeprecationWarning:
from currency import rate
from pricing import line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0
def test_rate():
assert rate("USD") == 1.0$ pytest -q --tb=short
.FF [100%]
=================================== FAILURES ===================================
________________________________ test_old_name _________________________________
test_shop.py:10: in test_old_name
assert total_with_vat(100.0, 1) == 115.0
^^^^^^^^^^^^^^^^^^^^^^^^
pricing.py:9: in total_with_vat
warnings.warn(
E DeprecationWarning: total_with_vat() is deprecated; use line_total()
__________________________________ test_rate ___________________________________
test_shop.py:14: in test_rate
assert rate("USD") == 1.0
^^^^^^^^^^^
currency.py:6: in rate
warnings.warn("rate() will need an API key from v3", DeprecationWarning)
E DeprecationWarning: rate() will need an API key from v3
=========================== short test summary info ============================
FAILED test_shop.py::test_old_name - DeprecationWarning: total_with_vat() is ...
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
2 failed, 1 passed in 0.01sOgni avviso ora viene sollevato come un'eccezione, con un traceback che punta alla riga che l'ha causato. Ogni fallimento riceve una decisione invece di un silenzio generalizzato.
Il modo sbagliato è "ignore::DeprecationWarning": una riga, tutto verde, e la tua deprecazione nascosta insieme a quella della libreria. Il modo giusto è restringere ogni eccezione. Un filtro ha la forma action:message:category:module — la stessa dell'opzione -W di Python — dove message corrisponde all'inizio del testo dell'avviso e module è il modulo a cui l'avviso viene attribuito:
[tool.pytest.ini_options]
filterwarnings = [
"error",
"ignore::DeprecationWarning:currency",
]L'ordine conta: le righe successive hanno la precedenza, quindi error va prima e le eccezioni dopo. L'avviso della libreria ora viene ignorato solo quando proviene da currency. Per un singolo test, lo stesso filtro può essere applicato come marker — qui su test_old_name, col resto del file invariato:
import pytest
from pricing import total_with_vat
@pytest.mark.filterwarnings("ignore:total_with_vat:DeprecationWarning")
def test_old_name():
assert total_with_vat(100.0, 1) == 115.0$ pytest -q
... [100%]
3 passed in 0.01sQuel marker si limita a permettere l'avviso. Se intendi "questo deve emettere un avviso", pytest.deprecated_call() è la scelta più forte, perché fallisce anche quando l'avviso scompare. Usa il marker per gli avvisi che tolleri e warns per gli avvisi che prometti.
Gli stessi filtri si possono indicare per un'unica esecuzione con -W, comodo per provare una politica prima di scriverla. Sul precedente test_old_api.py senza alcuna configurazione:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
F [100%]
=========================== short test summary info ============================
FAILED test_old_api.py::test_old_name_still_works - DeprecationWarning: total...
1 failed in 0.01sQuando le fonti sono in disaccordo, la più specifica prevale: un marker sul test batte -W, e -W batte il file di configurazione. Sulla suite di tre test, -W sovrascrive l'ignore della configurazione per currency, mentre il test marcato mantiene la sua esenzione:
$ pytest -q -W error::DeprecationWarning --tb=no -ra
..F [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_rate - DeprecationWarning: rate() will need an API ...
1 failed, 2 passed in 0.01sUna cosa in più che ti regala error. Guarda la riga del confine del piano, "nessun avviso a 50". Con error nella configurazione, un test normale di apply_discount(200.0, 50) lo verifica già: se quella chiamata emettesse un avviso, l'avviso sarebbe un'eccezione e il test fallirebbe. I casi tranquilli non richiedono codice speciale.
Un esempio completo
Il progetto shop con ogni regola in un unico posto. pyproject.toml:
[project]
name = "shop"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]
log_cli_level = "INFO"src/shop/pricing.py:
import logging
import warnings
log = logging.getLogger(__name__)
def line_total(price, quantity):
if quantity < 1:
raise ValueError("quantity must be at least 1")
return round(price * quantity * 1.15, 2)
def total_with_vat(price, quantity):
warnings.warn(
"total_with_vat() is deprecated; use line_total()",
DeprecationWarning,
stacklevel=2,
)
return line_total(price, quantity)
def apply_discount(total, percent):
if percent > 50:
warnings.warn(f"discount of {percent}% looks unusually large", UserWarning)
log.info("discount %d%% on %.2f", percent, total)
return round(total * (100 - percent) / 100, 2)$ PYTHONPATH=src python -c "from shop.pricing import apply_discount; print(apply_discount(200.0, 10))"
180.0tests/test_pricing.py — il piano dall'inizio del capitolo, riga per riga:
import pytest
from shop.pricing import apply_discount, line_total, total_with_vat
def test_line_total():
assert line_total(100.0, 1) == 115.0
def test_old_name_is_deprecated():
with pytest.deprecated_call(match="use line_total"):
assert total_with_vat(100.0, 1) == 115.0
def test_large_discount_warns():
with pytest.warns(UserWarning, match="unusually large") as record:
assert apply_discount(200.0, 60) == 80.0
assert len(record) == 1
def test_fifty_percent_is_quiet():
assert apply_discount(200.0, 50) == 100.0
@pytest.mark.slow
def test_many_lines():
total = sum(line_total(1.0, 1) for _ in range(10_000))
assert round(total, 2) == 11500.0
@pytest.mark.xfail(reason="bug #41: zero quantity should be free, not an error")
def test_zero_quantity():
assert line_total(15.0, 0) == 0.0$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_pricing.py .....x [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
========================= 5 passed, 1 xfailed in 0.01s =========================
$ pytest -m "not slow" -o log_cli=true
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/full
configfile: pyproject.toml
testpaths: tests
collected 6 items / 1 deselected / 5 selected
tests/test_pricing.py::test_line_total PASSED [ 20%]
tests/test_pricing.py::test_old_name_is_deprecated PASSED [ 40%]
tests/test_pricing.py::test_large_discount_warns
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 60% on 200.00
PASSED [ 60%]
tests/test_pricing.py::test_fifty_percent_is_quiet
-------------------------------- live log call ---------------------------------
INFO shop.pricing:pricing.py:25 discount 50% on 200.00
PASSED [ 80%]
tests/test_pricing.py::test_zero_quantity XFAIL (bug #41: zero quant...) [100%]
=========================== short test summary info ============================
XFAIL tests/test_pricing.py::test_zero_quantity - bug #41: zero quantity should be free, not an error
================== 4 passed, 1 deselected, 1 xfailed in 0.01s ==================Tre cose valgono la pena di essere notate.
Primo, nulla in nessuno dei due comandi è una regola del progetto. Le regole — dove sono i test, cosa è importabile, marker severi, xfail severo, avvisi come errori — sono tutte nel file, quindi il collega che digita solo pytest ottiene esattamente la prima esecuzione, e così fa CI.
Secondo, non c'è riepilogo degli avvisi. Entrambi gli avvisi che il codice emette sono attesi, quindi warns e deprecated_call li hanno consumati; qualsiasi altro avviso avrebbe fatto fallire un test. Un'esecuzione pulita è ora davvero pulita.
Terzo, test_fifty_percent_is_quiet non contiene codice di avviso, eppure protegge il confine: sotto error, un avviso al 50% lo farebbe fallire.
Quando qualcosa va storto
ModuleNotFoundError: No module named 'shop' durante la raccolta Il codice non è importabile dai test. Aggiungi pythonpath = ["src"] alla configurazione, e controlla la riga configfile: dell'intestazione per essere sicuro che quel file sia quello che viene letto.
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!) Due file di configurazione, e quello che hai modificato ha perso. Non viene unito nulla. Eliminane uno.
`'slow' not found in markers configuration option` --strict-markers (o strict = true) è attivo, e il marker non è registrato o è scritto male. Registralo sotto markers, o correggi l'ortografia.
PytestConfigWarning: Unknown config option: testpath Un'impostazione scritta male — testpath per testpaths. pytest avvisa solo, quindi l'impostazione di fatto non fa nulla. Con strict_config = true (o strict = true) diventa ERROR: Unknown config option: testpath e l'esecuzione si ferma, che è quello che vuoi.
ERROR: ... Cannot use both [tool.pytest] (native TOML types) and [tool.pytest.ini_options] (string-based INI format) simultaneously. pyproject.toml ha entrambe le tabelle. Sposta tutto in una sola.
TypeError: ... config option 'addopts' expects a list for type 'args', got str: '-ra --strict-markers' Nella tabella nativa [tool.pytest] addopts è una lista: addopts = ["-ra", "--strict-markers"]. La forma stringa appartiene a [tool.pytest.ini_options].
ERROR: ... 'minversion' requires pytest-10.0, actual pytest-9.1.1' Il pytest installato è più vecchio di quanto il progetto richiede. Aggiornalo dentro l'ambiente virtuale.
Failed: DID NOT WARN. No warnings of type (<class 'UserWarning'>,) were emitted. Nulla di quella categoria è stato emesso nel blocco. Leggi Emitted warnings: sotto — spesso il codice emette un avviso con una categoria diversa, o non su questo input.
Failed: Regex pattern did not match any of the 1 warnings emitted. Categoria giusta, testo sbagliato. match è un'espressione regolare, quindi (, . e ? richiedono l'escape — oppure avvolgi il testo in re.escape().
AttributeError: module 'builtins' has no attribute 'DeprecatedWarning'. Did you mean: 'DeprecationWarning'? Un errore di battitura nella categoria di una riga filterwarnings. pytest rifiuta di partire e stampa la riga che non ha potuto interpretare proprio sopra.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Questo viene eseguito con pytest -q -s. Quale numero stampa il test?
import warnings
import pytest
def check(n):
if n < 0:
warnings.warn("negative value", UserWarning)
return abs(n)
def test_record():
with pytest.warns(UserWarning) as record:
check(-1)
check(2)
check(-3)
print(len(record))- A3
- B0
- C2
- D1
Alla radice del progetto ci sono sia un pytest.ini che un pyproject.toml con [tool.pytest.ini_options]. Cosa succede quando esegui pytest?
- Apytest si ferma con un errore per aver due file di configurazione
- BViene usato `pytest.ini`, le impostazioni di `pyproject.toml` vengono ignorate, e l'intestazione lo segnala
- CPrevale `pyproject.toml` perché è lo standard più recente
- DI due file vengono uniti, con `pyproject.toml` che colma le lacune
La configurazione ha addopts = -ra --strict-markers e registra solo slow sotto markers. Cosa succede con pytest -m "not slow"?
import pytest
@pytest.mark.slwo
def test_every_day_of_a_year():
fees = [day * 5 for day in range(366)]
assert fees[-1] == 1825- AIl test viene eseguito, con un avviso nel riepilogo
- BIl test viene deselezionato, come previsto
- CLa raccolta si interrompe: 'slwo' not found in `markers` configuration option
- DIl test viene saltato
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
Il progetto della biblioteca addebita una multa per ritardo. src/library/fees.py:
import warnings
PER_DAY = 5
def late_fee(days):
if days < 0:
raise ValueError("days cannot be negative")
if days > 60:
warnings.warn(f"{days} days late looks like a lost book", UserWarning)
return days * PER_DAY
def fine(days):
warnings.warn(
"fine() is deprecated; use late_fee()",
DeprecationWarning,
stacklevel=2,
)
return late_fee(days)$ PYTHONPATH=src python -c "from library.fees import late_fee; print(late_fee(4))"
20- Scrivi la tabella
[tool.pytest.ini_options]in modo chepytestsolo, dalla radice o da dentrotests/, importilibrary, raccolga solotests/, stampi i riepiloghi-ra, rifiuti marker sconosciuti, fallisca su un passaggio inaspettato dixfaile trasformi ogni avviso inaspettato in un errore. Registra un markerslow. - Scrivi prima il piano dei test — caso, input, atteso — coprendo la riconsegna in tempo, il confine dei 60 giorni, 61 giorni, un numero negativo e la
fine()deprecata. - Scrivi
tests/test_fees.pydal piano, con un testslowche controlla ogni giorno di un anno. - Esegui
pytest, poipytest -m "not slow", poicd tests && pytest.
Poi due esperimenti, prevedendo ogni risultato prima: sostituisci il test con deprecated_call con una chiamata normale a fine(3), e scrivi male slow come slwo.
Soluzione
Il piano:
| caso | input | atteso | | --- | --- | --- | | riconsegnato in tempo | late_fee(0) | 0, nessun avviso | | confine, ancora tranquillo | late_fee(60) | 300, nessun avviso | | appena oltre il confine | late_fee(61) | 305 e un UserWarning che menziona "lost book" | | input non valido | late_fee(-1) | ValueError che menziona "negative" | | nome deprecato | fine(3) | 15 e un DeprecationWarning che menziona late_fee | | un anno intero (slow) | late_fee(0) … late_fee(365) | ultima multa 1825, avvisi attesi |
pyproject.toml:
[project]
name = "library"
version = "0.1.0"
[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra --strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
]
xfail_strict = true
filterwarnings = [
"error",
]tests/test_fees.py:
import pytest
from library.fees import fine, late_fee
def test_returned_on_time():
assert late_fee(0) == 0
def test_sixty_days_is_quiet():
# filterwarnings = error: any warning here would fail this test
assert late_fee(60) == 300
def test_sixty_one_days_warns():
with pytest.warns(UserWarning, match="lost book") as record:
assert late_fee(61) == 305
assert len(record) == 1
def test_negative_days_rejected():
with pytest.raises(ValueError, match="negative"):
late_fee(-1)
def test_fine_is_deprecated():
with pytest.deprecated_call(match="use late_fee"):
assert fine(3) == 15
@pytest.mark.slow
def test_every_day_of_a_year():
with pytest.warns(UserWarning):
fees = [late_fee(day) for day in range(366)]
assert fees[-1] == 1825$ pytest
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/you/library
configfile: pyproject.toml
testpaths: tests
collected 6 items
tests/test_fees.py ...... [100%]
============================== 6 passed in 0.01s ===============================
$ pytest -m "not slow" -q
..... [100%]
5 passed, 1 deselected in 0.01s
$ cd tests && pytest -q
...... [100%]
6 passed in 0.01sI due esperimenti:
$ pytest -q # test_fine_still_works calls fine(3) with no deprecated_call
=========================== short test summary info ============================
FAILED tests/test_fees.py::test_fine_still_works - DeprecationWarning: fine()...
1 failed, 5 passed in 0.01s
$ pytest -q # @pytest.mark.slwo
ERROR tests/test_fees.py - Failed: 'slwo' not found in `markers` configuratio...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sPerché ogni decisione:
pythonpathetestpathsnel file, non sulla riga di comando. È per questo checd tests && pytestfa passare gli stessi sei test: entrambi sono relativi alla rootdir, che pytest trova risalendo fino apyproject.toml.- Solo regole in
addopts.-rae--strict-markerssono politiche che tutti condividono;-vo-xsono abitudini personali e restano sulla tua riga di comando. filterwarnings = ["error"]senza eccezioni. Il progetto non ha ancora avvisi di terze parti, quindi non c'è nulla da scusare. Il giorno in cui ne appare uno, aggiungi una riga strettaignore::...:module, mai unignoregeneralizzato.- Il test dei 60 giorni non ha codice di avviso. Con
error, qualsiasi avviso a 60 giorni lo farebbe fallire, quindi la configurazione protegge il confine. matchsu ogni controllo puntato a un messaggio. "lost book" e "use late_fee" sono le parole su cui un lettore agisce; il resto può essere riformulato liberamente. Il test di un anno intero omettematchdi proposito: riguarda le multe, e deve solo dire che lì ci si aspetta degli avvisi.recordnel test dei 61 giorni. Prova che una singola chiamata emette un avviso una volta sola, non a ogni passo interno.- Gli esperimenti. Senza
deprecated_call, la deprecazione è inaspettata, quindierrorla trasforma in un fallimento — esattamente ciò che CI dovrebbe fare quando qualcuno chiama una vecchia API. Il marker scritto male ferma la raccolta perché--strict-markersè inaddopts. (Qui il filtroerrorl'avrebbe intercettato comunque, perché un marker sconosciuto emette unPytestUnknownMarkWarning— ma la regola dei marker non dovrebbe dipendere dalla politica degli avvisi, quindi restano entrambe.)
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