Marker, skip e xfail — quali test vengono eseguiti e quando
Dì a pytest di cosa ha bisogno ogni test e cosa ti aspetti da esso con skip, skipif, importorskip e xfail; cosa significano strict=True, raises= e XPASS; registra i tuoi marker e selezionali con -m; più fixture parametrizzati e factory.
Come vuoi imparare?
Il problema che stiamo risolvendo
Un piccolo progetto, shop, con un modulo e due file di test. money.py:
def parse_price(text: str) -> float:
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)test_money.py lo verifica con valori semplici. test_export.py legge i prezzi da un file YAML, il che richiede il pacchetto di terze parti yaml:
import yaml
from money import parse_price
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0Su una macchina in cui yaml non è installato, pytest -q restituisce:
==================================== ERRORS ====================================
_______________________ ERROR collecting test_export.py ________________________
ImportError while importing test module '/home/you/shop/test_export.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.12/importlib/__init__.py:90: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_export.py:1: in <module>
import yaml
E ModuleNotFoundError: No module named 'yaml'
=========================== short test summary info ============================
ERROR test_export.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sGuarda le ultime due righe. Interrupted. I due test validi in test_money.py non sono mai stati eseguiti. Un singolo file che non poteva essere eseguito su questa macchina ha bloccato l'intera esecuzione.
Questa è la realtà in qualsiasi progetto che vive abbastanza a lungo. Alcuni test hanno bisogno di una libreria opzionale. Alcuni hanno senso solo su Linux. Alcuni richiedono una chiave API disponibile solo in CI. Alcuni descrivono un bug noto a tutti ma ancora non risolto. Alcuni durano un minuto e non vuoi aspettare un minuto ogni volta che salvi un file.
Cancellare quei test è sbagliato, così come commentarli. Quello che vuoi è dire a pytest di cosa ha bisogno ogni test e cosa ti aspetti da esso, e lasciare che pytest decida, in ogni esecuzione, cosa farne. Lo strumento per questo è il marker.
Alla fine di questo capitolo sarai in grado di
- Saltare un test sempre, in base a una condizione, o dall'interno del test, e rendere il motivo visibile
- Saltare un intero file quando manca un pacchetto opzionale, usando
pytest.importorskip - Marcare un bug noto con
xfail, e renderlo preciso constrict=Trueeraises= - Leggere le lettere di report
s,xeX, e chiedere dettagli con-rs,-rx,-rA - Inventare marker personalizzati, registrarli e intercettare errori di battitura con
--strict-markers - Selezionare test con
-m "not slow"e-m "slow and db" - Scrivere un fixture che esegue ogni test più volte, e un fixture che restituisce una funzione
Prerequisiti: mocking.
Prima di scrivere il test
I marker non riguardano cosa verifica un test — i capitoli precedenti l'hanno spiegato. Riguardano due domande a cui rispondere prima di scrivere il test, e che la maggior parte delle persone risponde solo dopo che un'esecuzione è diventata rossa per il motivo sbagliato.
1. Di cosa ha bisogno questo test per poter essere eseguito? Un pacchetto che non è una dipendenza obbligatoria, un sistema operativo, una versione di Python, una variabile d'ambiente contenente un segreto, un database, una rete, molto tempo. Ognuno di questi è una precondizione. Se manca, il test non ha nulla da dire: non è passato e non è fallito.
2. Cosa mi aspetto che faccia oggi? Normalmente, che passi. Ma se il codice ha un bug noto e stai scrivendo il test che descrive il comportamento corretto, ti aspetti che fallisca — e che fallisca in un modo preciso.
Le risposte determinano il marker, quindi scrivile prima. Per il progetto shop, il piano è questo:
| Caso | Input / situazione | Necessità | Aspettativa | Marker | |---|---|---|---|---| | prezzo semplice | "1,250.50" | nulla | 1250.5 | nessuno | | esportazione YAML | "pen: '15.00'" | il pacchetto yaml | 15.0 se installato | pytest.importorskip("yaml") | | percorso POSIX | "data", "prices.csv" | non Windows | "data/prices.csv" | skipif(sys.platform == "win32") | | tassi in tempo reale | l'API reale | RATES_API_KEY impostata | un tasso, solo se la chiave esiste | pytest.skip() dall'interno del test | | simbolo valuta | "$1,250" | nulla | 1250.0 — oggi ValueError (bug #42) | xfail(raises=ValueError, strict=True) | | molti prezzi | 1000 stringhe | un secondo o più | somma corretta | slow (personalizzato) |
Leggi la tabella per colonne. La colonna Necessità genera skip. La colonna Aspettativa genera xfail. Il tempo richiesto genera un marker personalizzato che ti permette di scegliere cosa eseguire.
Cosa devi avere prima che tutto questo funzioni:
- un ambiente virtuale con
pytestinstallato, emoney.pyimportabile dai test (esegui dalla radice del progetto, come nei capitoli precedenti); - un
pyproject.tomlnella radice del progetto, perché è lì che vengono registrati i marker personalizzati; - il bug scritto da qualche parte con un numero, in modo che
reason=possa puntarci.
E cosa non marcare:
- Non saltare un test perché fallisce e non sai perché. Uno skip nasconde il fallimento in ogni esecuzione successiva. Correggilo, oppure capiscilo abbastanza bene da scrivere
xfail(raises=...)con una motivazione. - Non marcare un test flaky con
xfail. Un test che passa a volte mostreràXalcuni giorni exaltri, e constrict=Truefallirà a caso. La flakiness è un bug nel test; correggilo. - Non etichettare tutto come
slow. Se la maggior parte della suite viene deselezionata a ogni esecuzione, l'esecuzione quotidiana non verifica nulla. Etichetta solo i pochi test che richiedono davvero qualche secondo. - Non testare che pytest salti correttamente. Stai testando
money.py, non pytest. Il marker è una configurazione; il report dell'esecuzione è prova sufficiente che funzioni.
Il resto del capitolo implementa questa tabella, riga per riga.
skip — non questo test, ecco perché
Prima il modo più comune, prima che qualcuno conoscesse i marker — un return all'inizio:
import os
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
return
assert False, "would call the real API here". [100%]
1 passed in 0.07sPassato. Il test non ha verificato nulla, e il report dice che è riuscito. Su un laptop senza la chiave questa è una bugia in ogni esecuzione, e il punto verde è indistinguibile da uno reale. La soluzione non è evitare la condizione, ma riportarla onestamente — come uno skip.
Il marker più semplice è un decoratore che dice "non eseguirlo", con una motivazione allegata:
import os
import sys
import pytest
from money import parse_price, split_bill
@pytest.mark.skip(reason="rounding rules not agreed yet")
def test_split_rounding():
assert split_bill(100.0, 3) == 33.34
@pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX path")
def test_posix_path():
assert os.path.join("data", "prices.csv") == "data/prices.csv"
@pytest.mark.skipif(sys.version_info < (3, 13), reason="needs Python 3.13")
def test_new_feature():
assert parse_price("10") == 10.0
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
assert False, "would call the real API here"s.ss [100%]
1 passed, 3 skipped in 0.09sQuattro test, quattro modi diversi di decidere:
@pytest.mark.skip(reason=...)— sempre saltato. Il corpo non viene mai eseguito.@pytest.mark.skipif(condition, reason=...)— saltato quando la condizione è vera. La condizione viene valutata durante la raccolta dei file. Questa esecuzione era su Linux, quinditest_posix_pathè stato eseguito e passato; Python era 3.12, quinditest_new_featureè stato saltato.pytest.skip("...")chiamato dall'interno del test — la decisione viene presa durante l'esecuzione del test. Usalo quando lo scopri solo a metà, per esempio dopo aver controllato l'ambiente o ciò che ha restituito un fixture.
Ogni s nella riga di progresso è un test saltato. Uno skip non è un fallimento: l'esecuzione resta verde.
Ma le motivazioni non sono visibili. Per impostazione predefinita pytest riassume solo fallimenti ed errori. Chiedi gli skip con -rs:
s.ss [100%]
=========================== short test summary info ============================
SKIPPED [1] test_skips.py:9: rounding rules not agreed yet
SKIPPED [1] test_skips.py:19: needs Python 3.13
SKIPPED [1] test_skips.py:26: RATES_API_KEY is not set
1 passed, 3 skipped in 0.10sÈ per questo che reason è importante. Tra tre mesi, dire solo "skipped" non spiega a nessuno se quel test può essere riattivato.
importorskip — quando un intero file ha bisogno di un pacchetto
Tornando a test_export.py. Sostituisci import yaml con questo:
import pytest
from money import parse_price
yaml = pytest.importorskip("yaml")
def test_prices_from_yaml():
data = yaml.safe_load("pen: '15.00'")
assert parse_price(data["pen"]) == 15.0.. [100%]
=========================== short test summary info ============================
SKIPPED [1] test_export.py:5: could not import 'yaml': No module named 'yaml'
2 passed, 1 skipped in 0.08spytest.importorskip("yaml") prova a importarlo. Se funziona, restituisce il modulo, ed è per questo che viene assegnato a yaml. Se fallisce, l'intero file viene saltato, con l'errore di importazione come motivazione. Gli altri due test ora vengono eseguiti. Installando yaml, lo stesso file viene eseguito interamente, senza alcuna modifica.
xfail — un bug che conosci
Uno skip dice "questo test non può essere eseguito qui". C'è una seconda affermazione diversa: "questo test viene eseguito, e ci si aspetta che fallisca, perché il codice ha un bug noto".
parse_price ne ha uno: non gestisce un simbolo di valuta:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped")
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0
@pytest.mark.xfail(reason="spaces as thousands separators")
def test_parse_with_spaces():
assert parse_price("1 250") == 1250.0pytest -q -rx:
xx [100%]
=========================== short test summary info ============================
XFAIL test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
XFAIL test_xfail.py::test_parse_with_spaces - spaces as thousands separators
2 xfailed in 0.08sA differenza di un test saltato, un test xfail viene eseguito. È fallito, come previsto, quindi viene mostrato con una x minuscola e l'esecuzione resta verde. Il test è ora una registrazione scritta del bug, presente nella suite, anziché una nota nella testa di qualcuno.
Perché non semplicemente skip? Perché un test saltato non viene mai eseguito, quindi non può mai dirti nulla — né quando il bug viene risolto, né quando il codice cambia in modo da fallire in un altro modo. Un test xfail viene eseguito ogni volta, e il suo risultato viene confrontato con quello che hai previsto. Skip significa "non può essere eseguito qui"; xfail significa "viene eseguito, e so già cosa dirà".
Ora qualcuno risolve il bug #42 e modifica money.py per rimuovere il $:
Xx [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS test_xfail.py::test_parse_with_symbol - bug #42: currency symbol is not stripped
1 xfailed, 1 xpassed in 0.12sUna X maiuscola, XPASS: "ci si aspettava che fallisse, ma è passato". E l'esecuzione resta comunque verde — il codice di uscita è 0. Nessuno guarda un'esecuzione verde, quindi il marker resta lì e il test continua a dichiarare un bug che non esiste più.
strict=True — rendi visibili le buone notizie
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42: currency symbol is not stripped", strict=True)
def test_parse_with_symbol():
assert parse_price("$1,250") == 1250.0F [100%]
=================================== FAILURES ===================================
____________________________ test_parse_with_symbol ____________________________
[XPASS(strict)] bug #42: currency symbol is not stripped
=========================== short test summary info ============================
FAILED test_xfail.py::test_parse_with_symbol - [XPASS(strict)] bug #42: curre...
1 failed in 0.10sCon strict=True, un passaggio inaspettato è un fallimento. Può sembrare contrario al senso comune, ma è esattamente ciò che vuoi: chi ha risolto il bug viene avvisato di rimuovere il marker, e il test diventa un test normale che protegge la correzione da quel momento in poi. Se vuoi questo per tutti gli xfail del progetto, imposta xfail_strict = true nella configurazione di pytest.
raises= — fallisci per il motivo giusto
Un xfail senza argomenti accetta qualsiasi fallimento. Questo include quelli che non intendevi:
import pytest
from money import parse_price
@pytest.mark.xfail(reason="bug #42")
def test_loose():
assert parse_prise("$1,250") == 1250.0
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
assert parse_prise("$1,250") == 1250.0xF [100%]
=================================== FAILURES ===================================
_________________________________ test_precise _________________________________
@pytest.mark.xfail(raises=ValueError, reason="bug #42")
def test_precise():
> assert parse_prise("$1,250") == 1250.0
^^^^^^^^^^^
E NameError: name 'parse_prise' is not defined
test_raises.py:13: NameError
=========================== short test summary info ============================
XFAIL test_raises.py::test_loose - bug #42
1 failed, 1 xfailed in 0.09sEntrambi i test hanno lo stesso errore di battitura, parse_prise. test_loose ha ignorato il NameError e riportato un tranquillo x — sarebbe rimasto "atteso per fallire" per sempre, senza mai testare parse_price. test_precise ha dichiarato quale fallimento si aspettava, il ValueError che float("$1250") solleva, quindi qualsiasi altra eccezione viene riportata come un fallimento reale. Correggi l'errore di battitura e entrambi diventano x.
Le lettere del report
La riga di progresso usa un carattere per test, e -r prende le stesse lettere per scegliere cosa inserire nel riepilogo:
| Lettera | Significato | Mostrata nel riepilogo con | |---|---|---| | . | passato | -rp | | F | fallito | -rf (attivo per impostazione predefinita) | | E | errore in setup o teardown | -rE (attivo per impostazione predefinita) | | s | saltato | -rs | | x | xfailed — fallito come previsto | -rx | | X | xpassed — passato inaspettatamente | -rX |
Le lettere si possono combinare: -rsx mostra insieme skip e xfail. -rA mostra tutti, inclusi i passati:
..xs [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_params.py::test_parse_price[15-15.0]
PASSED test_params.py::test_parse_price[1,250.50-1250.5]
SKIPPED [1] test_params.py:6: space separators not decided
XFAIL test_params.py::test_parse_price[$1,250-1250.0] - bug #42
2 passed, 1 skipped, 1 xfailed in 0.08sI tuoi marker personalizzati
skip e xfail cambiano ciò che pytest fa con un test. Un marker può anche essere una semplice etichetta, con un nome che inventi, che non cambia nulla finché non la usi per selezionare i test. Alcuni test sono lenti, altri hanno bisogno di un database:
import time
import pytest
from money import parse_price, split_bill
def test_parse_plain():
assert parse_price("1,250.50") == 1250.5
@pytest.mark.slow
def test_parse_many():
time.sleep(1)
assert sum(parse_price("1.5") for _ in range(1000)) == 1500.0
@pytest.mark.slow
@pytest.mark.db
def test_bill_from_database():
time.sleep(1)
assert split_bill(300.0, 3) == 100.0
@pytest.mark.db
def test_bill_lookup():
assert split_bill(90.0, 3) == 30.0.... [100%]
=============================== warnings summary ===============================
test_marks.py:12
/home/you/shop/test_marks.py:12: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:18
/home/you/shop/test_marks.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slow
test_marks.py:19
/home/you/shop/test_marks.py:19: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
test_marks.py:25
/home/you/shop/test_marks.py:25: PytestUnknownMarkWarning: Unknown pytest.mark.db - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.db
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 4 warnings in 2.09sTutti e quattro sono passati, e pytest si lamenta di ogni utilizzo. pytest.mark.<anything> viene accettato — l'attributo viene creato al volo — quindi pytest non può distinguere slow da un errore di battitura. Ti chiede di dichiarare i nomi che intendi usare.
Registrare i marker
L'elenco va in pyproject.toml, una stringa per marker, con il nome prima dei due punti e una descrizione dopo:
[tool.pytest.ini_options]
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
].... [100%]
4 passed in 2.11sGli avvisi sono spariti, e pytest --markers ora li elenca per la prossima persona che entra nel progetto:
@pytest.mark.slow: takes more than a second; deselect with -m 'not slow'
@pytest.mark.db: needs the databaseSelezionare con -m
Ora le etichette ripagano. -m accetta un'espressione sui nomi dei marker, con and, or, not e parentesi:
$ pytest -q -m "not slow"
.. [100%]
2 passed, 2 deselected in 0.07s
$ pytest -q -m "slow and db"
. [100%]
1 passed, 3 deselected in 1.08s
$ pytest -q -m "slow or db"
... [100%]
3 passed, 1 deselected in 2.07sDeselected non significa skipped. Un test saltato era stato scelto e poi rifiutato di essere eseguito; un test deselezionato non è mai stato scelto. -m "not slow" è l'esecuzione che fai a ogni salvataggio; l'esecuzione completa è per CI e prima di fare push.
--strict-markers — un errore di battitura diventa un errore
L'avviso è facile da perdere. Ecco un test aggiunto in seguito, con il marker scritto male:
@pytest.mark.slwo
def test_split_large_group():
time.sleep(1)
assert split_bill(1000.0, 8) == 125.0... [100%]
=============================== warnings summary ===============================
test_marks.py:30
/home/you/shop/test_marks.py:30: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
3 passed, 2 deselected, 1 warning in 1.07sÈ stato richiesto -m "not slow", e l'esecuzione ha comunque impiegato un secondo: il test con l'errore di battitura non è slow, quindi è stato eseguito. Con --strict-markers, un marker non registrato interrompe la raccolta:
==================================== ERRORS ====================================
________________________ ERROR collecting test_marks.py ________________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_marks.py - Failed: 'slwo' not found in `markers` configuration option
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sNon vuoi digitare quel flag ogni volta, quindi mettilo in addopts:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"db: needs the database",
]pytestmark — marcare un intero file
Quando tutti i test in un file hanno bisogno del database, decorarli uno per uno è ripetitivo. Una variabile a livello di modulo chiamata pytestmark si applica a tutti i test del file:
import pytest
from money import split_bill
pytestmark = pytest.mark.db
def test_bill_for_two():
assert split_bill(50.0, 2) == 25.0
def test_bill_for_four():
assert split_bill(80.0, 4) == 20.0$ pytest -q -m db
.. [100%]
2 passed, 4 deselected in 0.07sIl nome deve essere esattamente pytestmark. Può anche essere una lista, pytestmark = [pytest.mark.db, pytest.mark.slow].
Marker su un singolo caso di parametrize
Un marker su un test parametrizzato si applica a tutti i casi. Per marcare un solo caso, avvolgilo in pytest.param(..., marks=...):
import pytest
from money import parse_price
@pytest.mark.parametrize(
"text, expected",
[
("15", 15.0),
("1,250.50", 1250.5),
pytest.param(
"$1,250", 1250.0,
marks=pytest.mark.xfail(raises=ValueError, reason="bug #42"),
),
pytest.param(
"1 250", 1250.0,
marks=pytest.mark.skip(reason="space separators not decided"),
),
],
)
def test_parse_price(text, expected):
assert parse_price(text) == expectedtest_params.py::test_parse_price[15-15.0] PASSED [ 25%]
test_params.py::test_parse_price[1,250.50-1250.5] PASSED [ 50%]
test_params.py::test_parse_price[$1,250-1250.0] XFAIL (bug #42) [ 75%]
test_params.py::test_parse_price[1 250-1250.0] SKIPPED (space separa...) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.11s ====================Il bug noto vive accanto ai casi che funzionano, nella stessa tabella, e il giorno in cui viene risolto basta eliminare un wrapper pytest.param.
Andare oltre: fixture che variano, e fixture che costruiscono
Due pattern di fixture non sono entrati nei capitoli sulle fixture, e i marker sono un buon momento per presentarli, perché entrambi moltiplicano o modellano i test che hai appena imparato a selezionare.
Un fixture parametrizzato
parametrize varia gli input di un solo test. A volte vuoi che ogni test che utilizza un fixture venga eseguito una volta per ogni valore di quel fixture. Dai a @pytest.fixture una lista params, e leggi il valore corrente da request.param:
import pytest
from money import parse_price
@pytest.fixture(params=["1250", "1,250", "1,250.00"], ids=["plain", "comma", "decimals"])
def price_text(request):
return request.param
def test_parses_to_1250(price_text):
assert parse_price(price_text) == 1250.0
def test_is_positive(price_text):
assert parse_price(price_text) > 0test_fixtures.py::test_parses_to_1250[plain] PASSED [ 16%]
test_fixtures.py::test_parses_to_1250[comma] PASSED [ 33%]
test_fixtures.py::test_parses_to_1250[decimals] PASSED [ 50%]
test_fixtures.py::test_is_positive[plain] PASSED [ 66%]
test_fixtures.py::test_is_positive[comma] PASSED [ 83%]
test_fixtures.py::test_is_positive[decimals] PASSED [100%]
============================== 6 passed in 0.08s ===============================Due test, tre valori, sei esecuzioni. request è un fixture integrato che descrive il test in fase di setup; request.param esiste solo quando il fixture ha params. ids dà un nome a ogni valore nel report — senza di esso, pytest costruisce un id dal valore stesso, il che per valori lunghi o non stampabili è difficile da leggere. Anche la lista può contenere elementi pytest.param(...) con marks=, esattamente come in parametrize.
Quando è utile: lo stesso insieme di test su diversi backend, diversi formati di file, diverse valute. Aggiungi un valore a params, e ogni test che usa quel fixture lo copre.
Un fixture factory
Un fixture non può prendere argomenti dal test; il test lo nomina e basta. Allora cosa fai quando un test ha bisogno di due oggetti, costruiti in modo diverso? Restituisci una funzione:
import pytest
from money import parse_price
@pytest.fixture
def make_receipt(tmp_path):
def _make(name, lines):
path = tmp_path / f"{name}.txt"
path.write_text("\n".join(lines))
return path
return _make
def receipt_total(path):
return sum(parse_price(line) for line in path.read_text().splitlines())
def test_two_receipts(make_receipt):
lunch = make_receipt("lunch", ["15", "1,250.50"])
taxi = make_receipt("taxi", ["300"])
assert receipt_total(lunch) + receipt_total(taxi) == 1565.5
def test_empty_receipt(make_receipt):
empty = make_receipt("empty", [])
assert receipt_total(empty) == 0.. [100%]
2 passed in 0.08sIl fixture viene comunque eseguito una volta per test, e può usare altri fixture (tmp_path qui, che si occupa anche di eliminare i file). Ciò che viene passato è _make, e il test lo chiama quante volte vuole, con gli argomenti che vuole. Il nome make_... è la convenzione abituale e dice subito al lettore che questo fixture è una funzione da chiamare, non un valore già pronto.
Un esempio completo
Tutto insieme in un unico progetto. pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: talks to an outside service",
]test_checkout.py, usando lo stesso money.py — ancora con il bug #42:
import os
import time
import pytest
from money import parse_price, split_bill
# Every test that asks for amount_text runs once per format.
@pytest.fixture(params=["300", "300.00", "1,250"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
# A factory: the fixture hands back a function the test can call many times.
@pytest.fixture
def make_share():
def _make(text, people):
return split_bill(parse_price(text), people)
return _make
def test_share_is_positive(amount_text, make_share):
assert make_share(amount_text, 3) > 0
@pytest.mark.parametrize(
"text, people, share",
[
("300", 3, 100.0),
("90", 4, 22.5),
pytest.param(
"$300", 3, 100.0,
marks=pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #42"),
),
],
)
def test_share(make_share, text, people, share):
assert make_share(text, people) == share
@pytest.mark.slow
def test_many_group_sizes(make_share):
time.sleep(1) # stands in for real, slow work
assert all(make_share("300", n) > 0 for n in range(1, 1000))
@pytest.mark.network
def test_live_exchange_rate():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")L'esecuzione quotidiana, con le motivazioni:
$ pytest -q -rsx -m "not slow"
.....xs [100%]
=========================== short test summary info ============================
SKIPPED [1] test_checkout.py:52: RATES_API_KEY is not set
XFAIL test_checkout.py::test_share[$300-3-100.0] - bug #42
5 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09sSolo quello lento, e l'esecuzione offline che esclude sia i test lenti sia quelli di rete:
$ pytest -q -m slow
. [100%]
1 passed, 7 deselected in 1.07s
$ pytest -q -m "not slow and not network"
.....x [100%]
5 passed, 2 deselected, 1 xfailed in 0.08sContali: un fixture con tre valori genera tre esecuzioni di test_share_is_positive, tre casi di test_share, un test lento e uno di rete — in totale otto, il che corrisponde a 1 passed, 7 deselected.
E ogni risultato dice la verità. Il test di rete non finge di passare senza una chiave; dice che è stato saltato e perché. Il bug #42 è nella suite, e poiché è strict con raises=ValueError, il giorno in cui viene risolto l'esecuzione diventa rossa e chiede di rimuovere il marker.
Quando qualcosa va storto
PytestUnknownMarkWarning: Unknown pytest.mark.slow - is this a typo? Il marker non è registrato. Aggiungilo a markers in pyproject.toml. Se il nome è davvero un errore di battitura, correggilo — e aggiungi --strict-markers a addopts in modo che il prossimo sia un errore e non un avviso.
`'slwo' not found in markers configuration option` È --strict-markers che fa il suo lavoro: il file usa un marker non presente nell'elenco. Correggi l'ortografia o registra il nuovo nome.
8 deselected e no tests ran, codice di uscita 5 -m non ha trovato nulla. Un nome scritto male in un'espressione -m non è un errore, nemmeno con --strict-markers — -m sloww seleziona semplicemente zero test. Controlla il nome con pytest --markers.
ERROR: Wrong expression passed to '-m': not slow and: at column 13: expected not OR left parenthesis OR identifier; got end of input L'espressione -m è incompleta. Metti l'intera espressione tra virgolette e assicurati che ogni and/or abbia qualcosa su entrambi i lati.
Error evaluating 'skipif': you need to specify reason=STRING when using booleans as conditions. skipif(sys.platform == "linux") senza reason=. Una condizione booleana non dice perché, quindi pytest insiste per avere una motivazione.
`Using pytest.skip outside of a test will skip the entire module. If that's your intention, pass allow_module_level=True.` pytest.skip("...") è stato chiamato al livello superiore di un file. Per un pacchetto mancante usa pytest.importorskip; altrimenti scrivi pytest.skip("...", allow_module_level=True), oppure usa uno skipif in pytestmark.
[XPASS(strict)] bug #42 Buone notizie, riportate come fallimento: il bug è stato risolto. Rimuovi il marker xfail e mantieni il test.
Un test xfail che resta x anche dopo che il bug è stato risolto Sta fallendo per un altro motivo — spesso un errore di battitura nel test stesso. Aggiungi raises= con l'eccezione che ti aspetti davvero, e qualsiasi altra cosa verrà mostrata come un fallimento reale.
AttributeError: 'SubRequest' object has no attribute 'param' Il fixture legge request.param, ma non ha una lista params= e non è parametrizzato in nessun altro modo. Aggiungi params=[...] a @pytest.fixture.
fixture 'name' not found, riferito a un fixture Un fixture è stato scritto come def make_user(name):, sperando che il test passasse name. Gli argomenti dei fixture sono altri fixture, quindi pytest ha cercato un fixture chiamato name. Trasformalo in un factory: una funzione interna che accetta name, restituita dal fixture.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Quali sono la riga di progresso e l'ultima riga di pytest -q? (Ricorda che round(2.675, 2) restituisce 2.67.)
import pytest
@pytest.mark.skip(reason="not ready")
def test_export():
assert False
def test_total():
assert 2 + 2 == 4
@pytest.mark.xfail(reason="bug #7")
def test_rounding():
assert round(2.675, 2) == 2.68- A..x 2 passed, 1 xfailed
- Bs.X 1 passed, 1 skipped, 1 xpassed
- Cs.F 1 passed, 1 skipped, 1 failed
- Ds.x 1 passed, 1 skipped, 1 xfailed
slow è registrato in pyproject.toml, e --strict-markers è disattivato. Esegui pytest -m "not slow", e l'esecuzione impiega comunque cinque secondi. Perché?
import time
import pytest
@pytest.mark.slwo
def test_big_report():
time.sleep(5)
assert True- A`-m "not slow"` esegue solo i test lenti
- BIl marker è scritto male come `slwo`, quindi il test non è `slow` e viene selezionato; pytest avvisa solo
- C`time.sleep` fa sì che pytest ignori i marker
- D`-m` funziona solo con marker integrati come `skip`
Il bug #12 è già stato risolto, quindi l'assert è vera. Cosa riporta pytest -q?
import pytest
@pytest.mark.xfail(strict=True, reason="bug #12")
def test_upper():
assert "taka".upper() == "TAKA"- A`X` — 1 xpassed, codice di uscita 0
- B`F` — FAILED con `[XPASS(strict)]`, codice di uscita 1
- C`s` — il test viene saltato perché marcato
- D`x` — 1 xfailed, codice di uscita 0
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
Prendi una suite di test che hai già — da un capitolo precedente, o dal tuo lavoro — e dagli un insieme onesto di marker:
- Registra due marker personalizzati in
pyproject.toml(ad esempioslowenetwork), e attiva--strict-markersinaddopts. - Marca almeno un test con ognuno. Usa
time.sleep(1)per simularne uno lento se non ne hai. - Aggiungi un test che ha bisogno di una variabile d'ambiente, e saltalo dall'interno con
pytest.skipquando la variabile manca. - Trova, o inventa, un bug nel tuo codice. Scrivi il test che dovrebbe passare, e marchialo con
xfail,strict=Truee unraises=preciso. - Aggiungi un fixture con
paramseids, usato da almeno due test.
Poi esegui e salva l'output di ognuno:
pytest -q -rsx -m "not slow"pytest -q -m slowpytest -q -rA
Infine, due esperimenti. Sbaglia a digitare uno dei tuoi marker ed esegui una volta senza e una volta con --strict-markers. Poi correggi il bug del punto 4 ed esegui di nuovo. Leggi cosa dice pytest in ogni caso — quei due messaggi sono quelli che mantengono onesta una suite con i marker, e sono più facili da riconoscere la seconda volta se li hai provocati tu stesso una volta.
Soluzione
Una soluzione, usando il progetto shop con il suo money.py. Il bug per il punto 4 è uno nuovo, bug #51: una cella vuota in un foglio di calcolo arriva come "", e dovrebbe contare come 0.0 — ma float("") solleva ValueError.
pyproject.toml:
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"slow: takes more than a second; deselect with -m 'not slow'",
"network: needs a real outside service and its API key",
]test_solution.py:
import os
import time
import pytest
from money import parse_price, split_bill
# Step 5: one fixture, three formats, used by two tests.
@pytest.fixture(params=["90", "90.00", "1,090"], ids=["whole", "decimals", "comma"])
def amount_text(request):
return request.param
def test_parse_is_positive(amount_text):
assert parse_price(amount_text) > 0
def test_split_for_one_is_whole_amount(amount_text):
assert split_bill(parse_price(amount_text), 1) == parse_price(amount_text)
# Step 2: a slow test, labelled so it can be left out.
@pytest.mark.slow
def test_many_prices():
time.sleep(1) # stands in for real, slow work
assert sum(parse_price("2.5") for _ in range(1000)) == 2500.0
# Step 3: decided inside the test, because only the environment knows.
@pytest.mark.network
def test_live_rates():
if "RATES_API_KEY" not in os.environ:
pytest.skip("RATES_API_KEY is not set")
raise AssertionError("would call the real service here")
# Step 4: bug #51 - an empty cell should count as 0, today it raises ValueError.
@pytest.mark.xfail(raises=ValueError, strict=True, reason="bug #51: empty cell")
def test_empty_cell_is_zero():
assert parse_price("") == 0.0Le tre esecuzioni:
$ pytest -q -rsx -m "not slow"
......sx [100%]
=========================== short test summary info ============================
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
6 passed, 1 skipped, 1 deselected, 1 xfailed in 0.09s
$ pytest -q -m slow
. [100%]
1 passed, 8 deselected in 1.07s
$ pytest -q -rA
.......sx [100%]
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED test_solution.py::test_parse_is_positive[whole]
PASSED test_solution.py::test_parse_is_positive[decimals]
PASSED test_solution.py::test_parse_is_positive[comma]
PASSED test_solution.py::test_split_for_one_is_whole_amount[whole]
PASSED test_solution.py::test_split_for_one_is_whole_amount[decimals]
PASSED test_solution.py::test_split_for_one_is_whole_amount[comma]
PASSED test_solution.py::test_many_prices
SKIPPED [1] test_solution.py:34: RATES_API_KEY is not set
XFAIL test_solution.py::test_empty_cell_is_zero - bug #51: empty cell
7 passed, 1 skipped, 1 xfailed in 1.08sIl primo esperimento: @pytest.mark.slow scritto male come @pytest.mark.slwo. Senza --strict-markers (qui disattivato con -o addopts=""), l'esecuzione impiega comunque un secondo, perché il test "lento" non è stato deselezionato:
$ pytest -q -m "not slow" -o addopts=""
.......sx [100%]
=============================== warnings summary ===============================
test_solution.py:24
/home/you/shop/test_solution.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
@pytest.mark.slwo
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
7 passed, 1 skipped, 1 xfailed, 1 warning in 1.09sCon esso, l'esecuzione rifiuta di partire:
$ pytest -q -m "not slow"
==================================== ERRORS ====================================
______________________ ERROR collecting test_solution.py _______________________
'slwo' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR test_solution.py - Failed: 'slwo' not found in `markers` configuration ...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13sIl secondo esperimento: bug #51 corretto in money.py:
def parse_price(text: str) -> float:
if not text.strip():
return 0.0
return float(text.replace(",", ""))
def split_bill(total: float, people: int) -> float:
return round(total / people, 2)$ pytest -q -m "not slow"
......sF [100%]
=================================== FAILURES ===================================
___________________________ test_empty_cell_is_zero ____________________________
[XPASS(strict)] bug #51: empty cell
=========================== short test summary info ============================
FAILED test_solution.py::test_empty_cell_is_zero - [XPASS(strict)] bug #51: e...
1 failed, 6 passed, 1 skipped, 1 deselected in 0.08sRimuovi la riga xfail e il test passa come test normale da quel momento in poi.
Perché ogni decisione:
--strict-markersinaddopts, non sulla riga di comando. Una rete di sicurezza che devi ricordarti di attivare non è una vera rete di sicurezza. Nella configurazione si applica a tutti, CI compreso.slowcome etichetta, non come skip. Il test lento è un buon test; dovrebbe essere eseguito, solo non a ogni salvataggio. Un'etichetta permette di scegliere; uno skip lo rimuoverebbe da tutte le esecuzioni.pytest.skip()dall'interno del test per la chiave API, nonskipif. Entrambi funzionerebbero qui, ma il controllo appartiene al codice che ha bisogno della chiave. Quando un test scopre solo a metà che non può continuare, la forma interna è l'unica che funziona.raises=ValueErrorestrict=Trueinsieme.raises=garantisce che il test fallisca a causa del bug #51 e non per un errore di battitura;strict=Truegarantisce che il giorno in cui il bug viene risolto la cosa venga notata. Ciascuno chiude un modo diverso in cui il marker potrebbe iniziare a mentire.- Un fixture parametrizzato, non
parametrize, per i formati. Due test condividono gli stessi tre input. Con un fixture l'elenco vive in un unico posto; aggiungere un quarto formato lo aggiunge a entrambi i test contemporaneamente. - Gli id
whole,decimals,comma. Nel report,[comma]dice di cosa tratta il caso.[1,090]funzionerebbe anche qui, ma gli id continuano a funzionare quando i valori sono lunghi o non stampabili.
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