parametrize — un test, molti casi
Sostituire test copia-incollati e cicli all'interno di un test con @pytest.mark.parametrize. ID dei test, ids= e pytest.param, decoratori sovrapposti, valori ed errori in una sola tabella e gli errori più comuni.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una piccola funzione che converte il punteggio di un esame in un voto, grading.py:
def grade(score):
if not 0 <= score <= 100:
raise ValueError(f"score must be between 0 and 100, got {score}")
if score >= 80:
return "A"
if score >= 60:
return "B"
if score >= 40:
return "C"
return "F"$ python3 -c "from grading import grade; print(grade(95), grade(79), grade(40), grade(0))"
A B C FTestarla come si deve significa controllare molti punteggi, specialmente i valori limite: 80 e 79, 60 e 59, 40 e 39. Il primo approccio che chiunque adotta è copiare un test e cambiare due valori:
from grading import grade
def test_grade_95():
assert grade(95) == "A"
def test_grade_79():
assert grade(79) == "B"
def test_grade_40():
assert grade(40) == "C"$ pytest -q test_copy.py
... [100%]
3 passed in 0.01sFunziona, ma non scala. Tre funzioni dicono la stessa cosa — "questo punteggio dà questo voto" — e solo due valori cambiano tra loro. Altri dieci casi significano altre dieci copie da mantenere sincronizzate.
L'idea successiva è quindi una lista di casi e un ciclo all'interno di un test. Due dei voti attesi qui sotto sono volutamente errati: 79 dovrebbe essere B e 40 dovrebbe essere C:
from grading import grade
def test_grades():
cases = [(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")]
for score, expected in cases:
assert grade(score) == expected$ pytest -q --tb=short test_loop.py
F [100%]
=================================== FAILURES ===================================
_________________________________ test_grades __________________________________
test_loop.py:7: in test_grades
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
=========================== short test summary info ============================
FAILED test_loop.py::test_grades - AssertionError: assert 'B' == 'C'
1 failed in 0.01sQuesto report è peggiore di quanto sembri. C'erano due casi errati e ne hai visto solo uno: il primo assert che fallisce termina il test, quindi il caso per 40 non è mai stato eseguito. Il report non dice quale punteggio sia fallito — devi dedurre tu che 'B' == 'C' proveniva da 79. E "1 failed" conta cinque casi come un unico fatto. Correggi il primo, esegui di nuovo e solo allora scopri il secondo.
Ciò che desideri è la sintesi del ciclo unita alla chiarezza di segnalazione delle copie: un'unica funzione, molti test separati.
Alla fine di questo capitolo sarai in grado di
- Eseguire una funzione di test su molti casi con
@pytest.mark.parametrize - Passare diversi valori per caso e leggere gli ID di test generati da pytest
- Assegnare un nome ai casi con
ids=e conpytest.param(..., id=...) - Contrassegnare un singolo caso come
xfailoskipsenza modificare gli altri - Combinare due decoratori parametrize per ottenere ogni combinazione dei loro valori
- Unire casi che restituiscono un valore e casi che sollevano un errore in una sola tabella
- Riconoscere gli errori comuni di parametrize a partire dai loro messaggi di errore
Prerequisiti: confrontare i float con `pytest.approx`.
Prima di scrivere il test
Parametrize è un modo per rappresentare una tabella. Quindi il lavoro da fare prima di qualsiasi codice di test consiste nel costruire quella tabella e decidere se una tabella sia davvero la struttura adatta.
1. Definisci il contratto. grade(score) promette: riceve un punteggio intero da 0 a 100; restituisce uno tra "A", "B", "C", "F"; qualsiasi valore al di fuori di 0–100 solleva un ValueError il cui messaggio specifica il punteggio. Non ha effetti collaterali — nessun file, nessuna stampa a video — quindi non c'è nulla da preparare o pulire. È questo a renderla perfetta: ogni caso è "questi input, quel risultato".
2. Verifica la configurazione. Hai bisogno dell'ambiente virtuale attivo, di pytest installato e di grading.py importabile dal file di test — in questi esempi si trova accanto ai test nella stessa cartella. Niente di più: parametrize è integrato in pytest e contextlib fa parte della libreria standard.
3. Pianifica i casi. I valori limite sono i più importanti, perché è lì che si annidano gli errori tra >= e >. Ciascuna regola riceve il valore su entrambi i lati del proprio confine:
| Caso | Input | Atteso | |---|---|---| | limite superiore dell'intervallo | 100 | "A" | | A più bassa | 80 | "A" | | B più alta | 79 | "B" | | B più bassa | 60 | "B" | | C più alta | 59 | "C" | | C più bassa | 40 | "C" | | F più alta | 39 | "F" | | limite inferiore dell'intervallo | 0 | "F" | | appena sotto l'intervallo | -1 | ValueError | | appena sopra l'intervallo | 101 | ValueError |
Ogni riga diventa un caso. La colonna "Caso" non è una decorazione: diventerà in seguito l'ID del test, così che un fallimento ti indichi quale regola si è rotta.
4. Decidi cosa non testare. Non ogni punteggio da 0 a 100: 95 e 85 testano la stessa regola di 80, e aumentano il tempo di esecuzione senza aggiungere informazioni. Un valore per lato di ogni limite è sufficiente. Non testare gli operatori di confronto propri di Python e non inserire due comportamenti diversi nello stesso test solo perché entrano in una tabella: se le righe richiedono asserzioni diverse, appartengono a test diversi.
Il resto del capitolo trasforma questa tabella in codice, uno strumento alla volta.
Un argomento, molti valori
Il decoratore accetta il nome di un argomento e una lista di valori da assegnargli. Pytest crea un test per ciascun valore e passa il valore con quel nome:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 95, 100])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v test_one.py
collecting ... collected 3 items
test_one.py::test_top_grade[80] PASSED [ 33%]
test_one.py::test_top_grade[95] PASSED [ 66%]
test_one.py::test_top_grade[100] PASSED [100%]
============================== 3 passed in 0.01s ===============================Un'unica funzione, tre test, ciascuno con la propria riga e il proprio nome. La parte tra parentesi quadre è l'ID del test, costruito a partire dal valore. score non è una fixture e non ha un valore predefinito: il nome nella stringa del decoratore deve semplicemente corrispondere a un parametro della funzione.
Diversi argomenti per caso
La maggior parte dei casi richiede un input e un risultato atteso. Indica entrambi i nomi separati da una virgola, e fornisci ciascun caso sotto forma di tupla nello stesso ordine. Ecco di nuovo il ciclo, con gli stessi due valori attesi errati:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(95, "A"), (80, "A"), (79, "C"), (40, "D"), (0, "F")],
)
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q --tb=short test_param.py
..FF. [100%]
=================================== FAILURES ===================================
_______________________________ test_grade[79-C] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'B' == 'C'
E
E - C
E + B
_______________________________ test_grade[40-D] _______________________________
test_param.py:11: in test_grade
assert grade(score) == expected
E AssertionError: assert 'C' == 'D'
E
E - D
E + C
=========================== short test summary info ============================
FAILED test_param.py::test_grade[79-C] - AssertionError: assert 'B' == 'C'
FAILED test_param.py::test_grade[40-D] - AssertionError: assert 'C' == 'D'
2 failed, 3 passed in 0.01sConfronta questo risultato con il ciclo. Ogni caso è stato eseguito. Entrambi i fallimenti vengono segnalati, ciascuno con un ID — test_grade[79-C] — che dà un nome al caso. Il conteggio è trasparente: 2 failed, 3 passed. (Con il traceback lungo predefinito, pytest stampa anche gli argomenti di ogni caso fallito, score = 79, expected = 'C'.)
I nomi possono essere scritti come un'unica stringa, "score,expected" oppure "score, expected" — gli spazi dopo le virgole sono consentiti — o come lista di stringhe, ["score", "expected"]. Entrambe le forme generano esattamente gli stessi test. La stringa è la forma più comune; la lista è comoda quando i nomi vengono generati via codice.
Come vengono generati gli ID
Per numeri, stringhe, booleani e None, pytest inserisce il valore stesso nell'ID e unisce i valori di un caso con -. Per qualsiasi altra cosa — una lista, un dizionario, un oggetto — non ci prova nemmeno: usa il nome dell'argomento unito alla posizione del caso:
import pytest
@pytest.mark.parametrize(
["value", "expected"],
[
(1.5, True),
("hello world", None),
([1, 2], {"a": 1}),
],
)
def test_show_ids(value, expected):
pass$ pytest -v test_ids_auto.py
collecting ... collected 3 items
test_ids_auto.py::test_show_ids[1.5-True] PASSED [ 33%]
test_ids_auto.py::test_show_ids[hello world-None] PASSED [ 66%]
test_ids_auto.py::test_show_ids[value2-expected2] PASSED [100%]
============================== 3 passed in 0.01s ===============================value2-expected2 significa "value ed expected del caso numero 2, contando da zero". È univoco, ma non ti dice nulla. Quando un caso del genere fallisce, devi scorrere la lista per trovarlo. Questa è la ragione per cui conviene assegnare tu stesso un nome ai casi.
Assegnare un nome ai casi: ids=
Passa a ids= una lista di stringhe, una per caso, nello stesso ordine:
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[(80, "A"), (79, "B"), (40, "C"), (39, "F")],
ids=["lowest-A", "highest-B", "lowest-C", "highest-F"],
)
def test_grade_boundaries(score, expected):
assert grade(score) == expected$ pytest -v test_ids_list.py
collecting ... collected 4 items
test_ids_list.py::test_grade_boundaries[lowest-A] PASSED [ 25%]
test_ids_list.py::test_grade_boundaries[highest-B] PASSED [ 50%]
test_ids_list.py::test_grade_boundaries[lowest-C] PASSED [ 75%]
test_ids_list.py::test_grade_boundaries[highest-F] PASSED [100%]
============================== 4 passed in 0.01s ===============================Ora l'output spiega perché ciascun caso esiste. Il punto debole è che i nomi risiedono in una seconda lista, separati dai rispettivi casi: se inserisci un caso nel mezzo e dimentichi il nome, ogni nome successivo sarà sfasato di una posizione.
ids= accetta anche una funzione. Pytest la chiama una volta per ciascun valore, non per ciascun caso, e unisce i risultati con -:
import pytest
from grading import grade
def describe(value):
if isinstance(value, int):
return f"score={value}"
return f"grade={value}"
@pytest.mark.parametrize("score,expected", [(80, "A"), (79, "B")], ids=describe)
def test_grade_named(score, expected):
assert grade(score) == expected$ pytest -v test_ids_func.py
collecting ... collected 2 items
test_ids_func.py::test_grade_named[score=80-grade=A] PASSED [ 50%]
test_ids_func.py::test_grade_named[score=79-grade=B] PASSED [100%]
============================== 2 passed in 0.01s ===============================Una funzione è adatta per valori il cui ID automatico è poco utile, come gli oggetti. Se restituisce None per un valore, pytest ripiega sull'ID automatico per quel valore.
Un caso alla volta: pytest.param
pytest.param avvolge un singolo caso e ti permette di associarvi elementi specifici: un id= posizionato proprio accanto ai suoi valori e marks= che si applicano unicamente a quel caso.
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expected",
[
pytest.param(100, "A", id="perfect"),
pytest.param(60, "B", id="lowest-B"),
pytest.param(59.5, "B", id="round-up", marks=pytest.mark.xfail(reason="no rounding yet")),
pytest.param(0, "F", id="zero", marks=pytest.mark.skip(reason="example of skip")),
],
)
def test_grade_cases(score, expected):
assert grade(score) == expected$ pytest -v test_param_obj.py
collecting ... collected 4 items
test_param_obj.py::test_grade_cases[perfect] PASSED [ 25%]
test_param_obj.py::test_grade_cases[lowest-B] PASSED [ 50%]
test_param_obj.py::test_grade_cases[round-up] XFAIL (no rounding yet) [ 75%]
test_param_obj.py::test_grade_cases[zero] SKIPPED (example of skip) [100%]
=================== 2 passed, 1 skipped, 1 xfailed in 0.01s ====================Il caso round-up annota una decisione non ancora presa — 59.5 dovrebbe essere arrotondato a B? — senza far fallire l'esecuzione, e gli altri casi restano intatti. (xfail e skip avranno un capitolo dedicato più avanti; per ora, basta sapere che un marcatore può essere applicato a un singolo caso.)
Dato che l'ID è un vero nome, un caso può essere eseguito da solo: pytest "test_param_obj.py::test_grade_cases[perfect]". Mettilo tra virgolette: la shell interpreta le parentesi quadre come caratteri speciali.
Due decoratori: ogni combinazione
Sovrapponi due decoratori parametrize e pytest eseguirà il test per ogni combinazione dei loro valori — il prodotto cartesiano. Due valute per tre importi danno sei test:
import pytest
@pytest.mark.parametrize("currency", ["USD", "EUR"])
@pytest.mark.parametrize("amount", [0, 10, 999])
def test_format(amount, currency):
text = f"{amount} {currency}"
assert text.endswith(currency)$ pytest -v test_stack.py
collecting ... collected 6 items
test_stack.py::test_format[0-USD] PASSED [ 16%]
test_stack.py::test_format[0-EUR] PASSED [ 33%]
test_stack.py::test_format[10-USD] PASSED [ 50%]
test_stack.py::test_format[10-EUR] PASSED [ 66%]
test_stack.py::test_format[999-USD] PASSED [ 83%]
test_stack.py::test_format[999-EUR] PASSED [100%]
============================== 6 passed in 0.01s ===============================Il decoratore più vicino alla funzione fornisce la prima parte dell'ID. Sovrapponi i decoratori solo quando gli input sono realmente indipendenti — ogni importo dovrebbe funzionare con ciascuna valuta. Se hanno senso solo determinate coppie, elencale come tuple in un unico decoratore. E fai attenzione alla moltiplicazione: tre decoratori con dieci valori ciascuno generano mille test.
Valori ed errori in una sola tabella
A volte i casi si dividono tra "restituisce questo" e "solleva quello". Puoi rendere il risultato atteso stesso un parametro ed entrarvi con with:
from contextlib import nullcontext
import pytest
from grading import grade
@pytest.mark.parametrize(
"score,expectation",
[
(50, nullcontext("C")),
(100, nullcontext("A")),
(-1, pytest.raises(ValueError)),
(101, pytest.raises(ValueError, match="got 101")),
],
)
def test_grade_or_error(score, expectation):
with expectation as expected:
assert grade(score) == expected$ pytest -v test_raises.py
collecting ... collected 4 items
test_raises.py::test_grade_or_error[50-expectation0] PASSED [ 25%]
test_raises.py::test_grade_or_error[100-expectation1] PASSED [ 50%]
test_raises.py::test_grade_or_error[-1-expectation2] PASSED [ 75%]
test_raises.py::test_grade_or_error[101-expectation3] PASSED [100%]
============================== 4 passed in 0.01s ===============================contextlib.nullcontext è un context manager che non fa nulla: with nullcontext("C") as expected si limita a impostare expected = "C", così i casi normali eseguono l'assert come di consueto. Nei casi di errore, pytest.raises funge da context manager: grade solleva un'eccezione al suo interno, l'assert non termina mai e il test passa perché è arrivato l'errore corretto. Se non viene sollevato alcun errore, ottieni il familiare DID NOT RAISE.
Questo approccio è ordinato per una tabella breve. Se il corpo del test inizia a richiedere istruzioni if per distinguere le due tipologie, suddividilo in due test. Nota anche gli ID: expectation0 segue di nuovo la regola degli oggetti. L'esempio completo risolve il problema con pytest.param.
Parametrizzare una classe
Inserisci il decoratore su una classe di test e ciascun metodo di test al suo interno riceverà il parametro:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [80, 100])
class TestTopBand:
def test_is_a(self, score):
assert grade(score) == "A"
def test_is_a_string(self, score):
assert isinstance(grade(score), str)$ pytest -v test_class.py
collecting ... collected 4 items
test_class.py::TestTopBand::test_is_a[80] PASSED [ 25%]
test_class.py::TestTopBand::test_is_a[100] PASSED [ 50%]
test_class.py::TestTopBand::test_is_a_string[80] PASSED [ 75%]
test_class.py::TestTopBand::test_is_a_string[100] PASSED [100%]
============================== 4 passed in 0.01s ===============================Due metodi per due valori: quattro test. Ogni metodo della classe deve accettare score, altrimenti la raccolta (collection) fallisce.
Un esempio completo
shipping.py:
import math
RATES = {"local": 50, "national": 120}
def shipping_cost(weight_kg, zone):
if zone not in RATES:
raise KeyError(f"unknown zone: {zone}")
if weight_kg <= 0:
raise ValueError("weight must be positive")
# The first kilogram is in the base rate; each started kilogram after it costs 20.
return RATES[zone] + (math.ceil(weight_kg) - 1) * 20$ python3 -c "from shipping import shipping_cost; print(shipping_cost(0.5, 'local'), shipping_cost(1.2, 'local'), shipping_cost(3, 'national'))"
50 70 160test_shipping.py:
from contextlib import nullcontext
import pytest
from shipping import shipping_cost
@pytest.mark.parametrize(
"weight_kg,zone,expected",
[
pytest.param(0.5, "local", 50, id="light-local"),
pytest.param(1, "local", 50, id="exactly-1kg"),
pytest.param(1.2, "local", 70, id="part-kg-rounds-up"),
pytest.param(3, "national", 160, id="heavy-national"),
],
)
def test_cost(weight_kg, zone, expected):
assert shipping_cost(weight_kg, zone) == expected
@pytest.mark.parametrize("zone", ["local", "national"])
@pytest.mark.parametrize("weight_kg", [0.1, 0.5, 1])
def test_first_kg_is_flat(weight_kg, zone):
assert shipping_cost(weight_kg, zone) == shipping_cost(1, zone)
@pytest.mark.parametrize(
"weight_kg,zone,expectation",
[
pytest.param(2, "local", nullcontext(70), id="ok"),
pytest.param(0, "local", pytest.raises(ValueError), id="zero-weight"),
pytest.param(-1, "local", pytest.raises(ValueError), id="negative"),
pytest.param(1, "moon", pytest.raises(KeyError, match="moon"), id="bad-zone"),
],
)
def test_cost_or_error(weight_kg, zone, expectation):
with expectation as expected:
assert shipping_cost(weight_kg, zone) == expected$ pytest -v test_shipping.py
collecting ... collected 14 items
test_shipping.py::test_cost[light-local] PASSED [ 7%]
test_shipping.py::test_cost[exactly-1kg] PASSED [ 14%]
test_shipping.py::test_cost[part-kg-rounds-up] PASSED [ 21%]
test_shipping.py::test_cost[heavy-national] PASSED [ 28%]
test_shipping.py::test_first_kg_is_flat[0.1-local] PASSED [ 35%]
test_shipping.py::test_first_kg_is_flat[0.1-national] PASSED [ 42%]
test_shipping.py::test_first_kg_is_flat[0.5-local] PASSED [ 50%]
test_shipping.py::test_first_kg_is_flat[0.5-national] PASSED [ 57%]
test_shipping.py::test_first_kg_is_flat[1-local] PASSED [ 64%]
test_shipping.py::test_first_kg_is_flat[1-national] PASSED [ 71%]
test_shipping.py::test_cost_or_error[ok] PASSED [ 78%]
test_shipping.py::test_cost_or_error[zero-weight] PASSED [ 85%]
test_shipping.py::test_cost_or_error[negative] PASSED [ 92%]
test_shipping.py::test_cost_or_error[bad-zone] PASSED [100%]
============================== 14 passed in 0.01s ==============================Tre funzioni, quattordici test e ogni riga descrive cosa è stato verificato. Vale la pena notare tre aspetti.
Innanzitutto, gli ID in test_cost indicano il motivo di ciascun caso — part-kg-rounds-up dà un nome alla regola protetta dal test, cosa che 1.2-local-70 non farebbe.
In secondo luogo, test_first_kg_is_flat sovrappone i decoratori perché peso e zona sono realmente indipendenti. Inoltre confronta la funzione con se stessa anziché con un numero, resistendo così a un eventuale cambio delle tariffe.
In terzo luogo, pytest.param(..., id=...) trasforma expectation2 in negative. La tabella di valori ed errori si legge ora come una specifica.
Quando qualcosa va storto
function uses no argument 'scores' Un nome nella stringa del decoratore non corrisponde ad alcun parametro della funzione — qui "scores,expected" a fronte di def test_grade(score, expected). Fallisce in fase di raccolta, prima che venga eseguito qualsiasi test:
import pytest
from grading import grade
@pytest.mark.parametrize("scores,expected", [(80, "A"), (40, "C")])
def test_grade(score, expected):
assert grade(score) == expected$ pytest -q test_mismatch.py
==================================== ERRORS ====================================
______________________ ERROR collecting test_mismatch.py _______________________
In test_mismatch.py::test_grade: function uses no argument 'scores'
=========================== short test summary info ============================
ERROR test_mismatch.py - Failed: In test_mismatch.py::test_grade: function us...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.01sfixture 'expected' not found Il caso opposto: la funzione ha un parametro che il decoratore non menziona mai, quindi pytest cerca una fixture con quel nome e non ne trova nessuna. Aggiungi il nome alla stringa del decoratore.
the number of names (2) ... must be equal to the number of values (1) Un caso ha il numero errato di valori — ad esempio (40,) in una lista per "score,expected":
$ pytest -q test_count.py
==================================== ERRORS ====================================
________________________ ERROR collecting test_count.py ________________________
test_count.py::test_grade: in "parametrize" the number of names (2):
['score', 'expected']
must be equal to the number of values (1):
(40,)ID come [score0] e un TypeError che menziona una tupla Con un solo nome di argomento, ciascun valore nella lista viene passato intero. Se scrivi tuple per abitudine, la funzione riceverà una tupla:
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"$ pytest -v --tb=no test_tuples.py
collecting ... collected 2 items
test_tuples.py::test_top_grade[score0] FAILED [ 50%]
test_tuples.py::test_top_grade[score1] FAILED [100%]
=========================== short test summary info ============================
FAILED test_tuples.py::test_top_grade[score0] - TypeError: '<=' not supported...
FAILED test_tuples.py::test_top_grade[score1] - TypeError: '<=' not supported...
============================== 2 failed in 0.01s ===============================Gli ID sono l'indizio: un semplice numero avrebbe prodotto [80], quindi [score0] indica che il valore era qualcos'altro. Per un solo argomento, scrivi [80, 95].
Un caso passa da solo e fallisce insieme agli altri I valori dei parametri vengono creati una sola volta, quando il modulo viene raccolto, e lo stesso oggetto viene consegnato a ogni test che lo riceve. Se un test modifica un valore mutabile — una lista, un dizionario — il test successivo vedrà tale modifica:
import pytest
@pytest.mark.parametrize("cart", [[]])
class TestCart:
def test_add_item(self, cart):
cart.append("pen")
assert cart == ["pen"]
def test_starts_empty(self, cart):
assert cart == []$ pytest -q --tb=short test_shared.py
.F [100%]
=================================== FAILURES ===================================
______________________ TestCart.test_starts_empty[cart0] _______________________
test_shared.py:11: in test_starts_empty
assert cart == []
E AssertionError: assert ['pen'] == []
E
E Left contains one more item: 'pen'
E Use -v to get more diff
=========================== short test summary info ============================
FAILED test_shared.py::TestCart::test_starts_empty[cart0] - AssertionError: a...
1 failed, 1 passed in 0.01sPassa qualcosa di immutabile, come la tupla (), e costruisci un nuovo oggetto all'interno di ciascun test con cart = list(items). In questo modo ogni test parte dalla propria lista vuota ed entrambi passano.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Eseguendo pytest -v, quali quattro ID di test compaiono, e in quale ordine?
import pytest
@pytest.mark.parametrize("b", ["x", "y"])
@pytest.mark.parametrize("a", [1, 2])
def test_pair(a, b):
pass- Atest_pair[1-x], test_pair[2-y]
- Btest_pair[1-x], test_pair[1-y], test_pair[2-x], test_pair[2-y]
- Ctest_pair[x-1], test_pair[y-1], test_pair[x-2], test_pair[y-2]
- Dtest_pair[x-1], test_pair[x-2], test_pair[y-1], test_pair[y-2]
grade(80) e grade(95) restituiscono entrambi "A", eppure entrambi i test falliscono. Perché?
import pytest
from grading import grade
@pytest.mark.parametrize("score", [(80,), (95,)])
def test_top_grade(score):
assert grade(score) == "A"- AGli ID `score0` e `score1` entrano in conflitto con il nome della funzione
- BI valori devono essere una tupla di tuple, non una lista
- CCon un solo nome, ogni valore viene passato intero, quindi `score` è la tupla `(80,)`, non `80`
- Dparametrize non può essere usato con un solo nome di argomento
Due valori attesi qui sono errati (79 dovrebbe essere B, 40 dovrebbe essere C). Cosa riporta pytest?
def test_grades():
cases = [(95, "A"), (79, "C"), (40, "D")]
for score, expected in cases:
assert grade(score) == expected- A2 failed, 1 passed — una riga per caso
- B1 failed — solo per il primo caso errato; il caso con 40 non viene mai eseguito
- C1 failed — per il caso con 40, l'ultimo errato
- D3 failed, perché un singolo fallimento fa fallire tutti i casi
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
Scrivi password.py con una funzione check_password(text) che restituisce una lista di problemi: "too short" se sotto gli 8 caratteri, "no digit" se non contiene cifre, "no letter" se non contiene lettere. Una password valida restituisce []. Qualsiasi valore che non sia una str solleva TypeError.
Poi scrivi test_password.py con:
- Un test parametrizzato di almeno sei casi, scritto con
pytest.parame unid=che indichi lo scopo di ciascun caso ("seven-chars","digits-only"e così via) - Due decoratori sovrapposti — tre password valide per due suffissi,
""e"!"— per verificare che tutte e sei restituiscano[] - Una tabella che unisca casi normali (
nullcontext) e casi conTypeError(pytest.raises) —Nonee l'intero12345678sono ottimi input per il secondo tipo - Un caso contrassegnato con
xfailper una regola non ancora implementata, come "nessuna lettera maiuscola"
Esegui pytest -v e leggi l'elenco degli ID come se li avesse scritti qualcun altro. Riusciresti a capire le regole delle password solo a partire dagli ID? Se la risposta è no, rinomina i casi finché non ci riesci.
Poi introduci intenzionalmente un errore in un valore atteso ed esegui di nuovo. Verifica che fallisca esattamente una sola riga, che il suo ID ti dica di quale caso si tratta e che ogni altro caso sia stato comunque eseguito.
Soluzione
Comincia dal contratto, poi passa alla tabella. check_password accetta una str e restituisce una lista di tutti i problemi riscontrati, in un ordine fisso; un valore non-str solleva TypeError anziché restituire qualcosa. password.py:
def check_password(text):
if not isinstance(text, str):
raise TypeError(f"password must be a str, got {type(text).__name__}")
problems = []
if len(text) < 8:
problems.append("too short")
if not any(ch.isdigit() for ch in text):
problems.append("no digit")
if not any(ch.isalpha() for ch in text):
problems.append("no letter")
return problems$ python3 -c "from password import check_password as c; print(c('abcdef12'), c('ab1'), c(''))"
[] ['too short'] ['too short', 'no digit', 'no letter']test_password.py:
from contextlib import nullcontext
import pytest
from password import check_password
@pytest.mark.parametrize(
"text,expected",
[
pytest.param("abcdef12", [], id="eight-chars-ok"),
pytest.param("abcde12", ["too short"], id="seven-chars"),
pytest.param("abcdefgh", ["no digit"], id="letters-only"),
pytest.param("12345678", ["no letter"], id="digits-only"),
pytest.param("", ["too short", "no digit", "no letter"], id="empty"),
pytest.param("ab1", ["too short"], id="short-but-mixed"),
pytest.param(
"abcdef12",
["no uppercase"],
id="needs-uppercase",
marks=pytest.mark.xfail(reason="uppercase rule not written yet"),
),
],
)
def test_problems(text, expected):
assert check_password(text) == expected
@pytest.mark.parametrize("suffix", ["", "!"])
@pytest.mark.parametrize("good", ["abcdef12", "pass1234", "9lives9lives"])
def test_good_passwords(good, suffix):
assert check_password(good + suffix) == []
@pytest.mark.parametrize(
"value,expectation",
[
pytest.param("abcdef12", nullcontext([]), id="str-ok"),
pytest.param("short", nullcontext(["too short", "no digit"]), id="str-problems"),
pytest.param(None, pytest.raises(TypeError), id="none"),
pytest.param(12345678, pytest.raises(TypeError, match="int"), id="int"),
],
)
def test_type_or_problems(value, expectation):
with expectation as expected:
assert check_password(value) == expected$ pytest -v test_password.py
collecting ... collected 17 items
test_password.py::test_problems[eight-chars-ok] PASSED [ 5%]
test_password.py::test_problems[seven-chars] PASSED [ 11%]
test_password.py::test_problems[letters-only] PASSED [ 17%]
test_password.py::test_problems[digits-only] PASSED [ 23%]
test_password.py::test_problems[empty] PASSED [ 29%]
test_password.py::test_problems[short-but-mixed] PASSED [ 35%]
test_password.py::test_problems[needs-uppercase] XFAIL (uppercase ru...) [ 41%]
test_password.py::test_good_passwords[abcdef12-] PASSED [ 47%]
test_password.py::test_good_passwords[abcdef12-!] PASSED [ 52%]
test_password.py::test_good_passwords[pass1234-] PASSED [ 58%]
test_password.py::test_good_passwords[pass1234-!] PASSED [ 64%]
test_password.py::test_good_passwords[9lives9lives-] PASSED [ 70%]
test_password.py::test_good_passwords[9lives9lives-!] PASSED [ 76%]
test_password.py::test_type_or_problems[str-ok] PASSED [ 82%]
test_password.py::test_type_or_problems[str-problems] PASSED [ 88%]
test_password.py::test_type_or_problems[none] PASSED [ 94%]
test_password.py::test_type_or_problems[int] PASSED [100%]
======================== 16 passed, 1 xfailed in 0.01s =========================Perché è scritto in questo modo:
- La coppia di valori limite viene prima.
eight-chars-okeseven-charssi trovano sui due lati della regola sulla lunghezza, il punto in cui emergerebbe una svista tra<e<=. Ciascuna delle altre regole riceve un caso che viola solo quella regola, così che un eventuale fallimento punti esattamente a una singola riga dicheck_password. emptyverifica l'ordine della lista. È l'unico caso che infrange tutte e tre le regole, garantendo quindi che i problemi vengano restituiti in un ordine fisso — un aspetto su cui farà affidamento il codice chiamante che li mostra a un utente.- I valori attesi sono liste nuove in ogni caso, e il test si limita a leggerli. Non viene modificato nulla, quindi la trappola dell'oggetto condiviso vista in "Quando qualcosa va storto" qui non può verificarsi.
- La sovrapposizione è perfetta per
test_good_passwordsperché suffisso e password sono indipendenti: qualsiasi password valida deve rimanere tale con o senza!. Gli IDabcdef12-eabcdef12-!sono leggibili senza aiuti, quindi non è stato necessario usareids=. - La tabella degli errori usa gli ID di
pytest.paramperché gli oggettinullcontextepytest.raisesapparirebbero altrimenti comeexpectation0,expectation1.match="int"controlla che il messaggio indichi il tipo errato, non solo che si sia verificato un genericoTypeError. - Il caso
xfaildefinisce una regola futura senza interrompere l'esecuzione. Quando verrà aggiunta la regola per le maiuscole, quel caso inizierà a passare e pytest lo segnalerà comeXPASS— il segnale che indica di rimuovere il marcatore.
Leggendo gli ID dall'alto verso il basso si ottengono le regole: otto caratteri, almeno una cifra, almeno una lettera, solo stringhe, maiuscole previste per il futuro. Questo è il test definitivo per capire se i nomi sono sufficientemente chiari.
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