Prima di scrivere un test: ciò di cui hai davvero bisogno
Il ragionamento che precede qualsiasi codice di test: trasformare un requisito vago in un contratto preciso, rendere il codice testabile, scegliere i casi con i valori limite, conoscere la risposta attesa senza eseguire il codice e decidere cosa non testare, il tutto con Python standard e assert.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una funzione che calcola la media di una lista di voti d'esame, e il modo in cui la maggior parte di noi controlla una funzione del genere: chiamarla un paio di volte e dare un'occhiata:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0Entrambe le risposte sono corrette. La funzione "funziona", e finisce nel programma. Una settimana dopo, una classe che non ha ancora risultati arriva a eseguire la stessa riga:
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero"Funziona, l'ho provata" era vero. Significava soltanto "funziona per i due input che ho provato per caso". Il controllo manuale presenta due punti deboli che questo esempio mette a nudo.
Il primo è che copre solo ciò che ti è venuto in mente sul momento, e una lista vuota è esattamente il tipo di cosa a cui nessuno pensa sul momento.
Il secondo è che non si ripete. Le due chiamate a print sono state eseguite una volta, lette una volta ed eliminate. Quando la funzione cambierà il mese prossimo, nessuno le eseguirà di nuovo.
I test automatizzati risolvono il secondo punto debole, e il resto di questo corso è dedicato a scriverli con pytest. Guarda però più da vicino il primo punto debole, perché nessuno strumento può risolverlo al posto tuo. Supponi di voler scrivere un controllo per la lista vuota proprio adesso. Cosa dovrebbe dire? average([]) dovrebbe restituire 0? Restituire None? Sollevare un errore? Nessuno lo ha deciso. Non puoi scrivere un test per un comportamento che nessuno ha stabilito, e non puoi verificare una risposta che non conosci già in anticipo.
Questo capitolo riguarda proprio questo: il ragionamento che deve avvenire prima della prima riga di codice di test. Se imposti bene questo passaggio, i test si scrivono quasi da soli. Se lo salti, finirai per avere test che passano ma non intercettano comunque il bug.
Alla fine di questo capitolo sarai in grado di
- Spiegare cos'è un test in una frase: un'affermazione eseguibile sul comportamento
- Trasformare un requisito vago in un contratto preciso: input, output, errori, effetti collaterali
- Riconoscere il codice difficile da testare e ristrutturarlo in un nucleo puro con un guscio sottile
- Pianificare i casi con classi di equivalenza e valori limite, sotto forma di tabella
- Calcolare le risposte attese indipendentemente dal codice: l'oracolo
- Decidere cosa non testare e quando i test sono sufficienti
- Scrivere il piano come semplici controlli con
assert, eseguirli e spiegare perché non bastano ancora
Prerequisiti: nessuno — il corso comincia qui. Ti serve Python 3 e devi saper scrivere una funzione. Non hai ancora bisogno di pytest; tutto ciò che trovi in questo capitolo viene eseguito con Python standard.
Prima di scrivere il test
Ogni capitolo di questo corso ha una sezione con questo titolo, e ciascuna risponde alle stesse sei domande per il proprio argomento. Questo capitolo è il punto da cui nascono queste domande, quindi eccole raccolte insieme. Tienile a mente: il resto del corso le userà come una checklist.
- Cosa viene promesso esattamente? Il contratto: per questi input, questo output; per questi input, questo errore; e questi effetti collaterali, o nessuno.
- Il codice può essere chiamato da un test? Riceve i suoi input come argomenti e restituisce un risultato, oppure legge dalla tastiera, stampa a video e consulta l'orologio?
- Quali casi? Uno per ogni tipo di input, entrambi i lati di ogni limite, gli input non validi, i casi vuoti e quelli molto grandi.
- Come conosco la risposta esatta? Calcolata a mano o a partire dalla specifica, mai eseguendo il codice sotto test.
- Cosa deve essere pronto? La versione di Python, un ambiente, la posizione in cui risiederanno i file di test e, nei capitoli successivi, i file, le impostazioni e gli strumenti necessari per l'argomento.
- Cosa non testerò? Python stesso, le librerie scritte da altri, il codice troppo semplice per essere errato e i comportamenti che nessuno ha promesso.
Eccole applicate alla funzione average vista sopra, che è abbastanza piccola da poter essere esaminata per intero.
Il contratto ha una lacuna, quindi il primo passo è colmarla. Supponi di chiedere chiarimenti e che la risposta sia: "la media di zero voti non ha senso; solleva ValueError". La funzione è già testabile: riceve una lista e restituisce un numero. Per questo capitolo, tutto ciò che deve essere pronto è Python 3. Ed ecco i casi:
| Caso | Input | Atteso | Perché | | --- | --- | --- | --- | | Tipico | [80, 90, 70] | 80.0 | l'uso comune: (80 + 90 + 70) / 3 | | Un solo punteggio | [100] | 100.0 | la lista più piccola che abbia una media | | Vuota | [] | ValueError | il caso che è andato in errore; ora è stato deciso |
Cosa non è nell'elenco: se sum fa la somma correttamente o se / divide: questo è compito di Python, ed è testato in modo molto più approfondito di qualsiasi cosa possiamo scrivere tu o io.
Il resto del capitolo affronta ciascuna delle sei domande in sequenza, per poi concludere applicando l'intero processo a una funzione realistica.
Cos'è davvero un test
Un test è un'affermazione eseguibile sul comportamento: dato questo input, attenditi questo risultato. Il "risultato" si presenta esattamente in tre forme:
- un valore di ritorno —
average([80, 90, 70])restituisce80.0 - un errore —
average([])sollevaValueError - un effetto collaterale — dopo
add_score(scores, 70), la listascoresha70alla fine
Python dispone già di un'istruzione per mettere per iscritto un'affermazione: assert. Non fa nulla quando la condizione è vera e solleva AssertionError quando è falsa. Ecco un'affermazione per ciascun tipo, con average che ora rispetta il contratto stabilito:
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedNota di cosa ha bisogno ciascuna affermazione: un input che scegli tu, un risultato che conosci già in anticipo e un modo per chiamare il codice e osservare cosa ha fatto. Le sei domande esistono per assicurarsi che tu abbia tutti e tre gli elementi prima di iniziare. Nota anche quanto sia macchinoso il controllo dell'errore: cinque righe di try/except/else solo per dire "questo dovrebbe sollevare un'eccezione". pytest trasforma tutto questo in una sola riga nel capitolo quattro.
1. Il contratto: da un requisito vago a una promessa precisa
I requisiti di solito arrivano sotto forma di una frase: "I membri hanno diritto al 10% di sconto sui grandi ordini." Sembra completa. Mandala a due sviluppatori scrupolosi e guarda cosa succede:
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123Nessuno dei due ha commesso un errore. La frase non diceva se esattamente 1000 conti come "grande", né come arrotondare. Ciascuno ha colmato il vuoto in modo diverso, e un ordine di 1000 riceve uno sconto di 0 da uno e di 100 dall'altro. Nessun test può dire quale dei due abbia ragione, perché "giusto" non è mai stato definito.
Un contratto è il requisito reso sufficientemente preciso da poter essere testato. Per passare dalla frase al contratto, fai delle domande — le stesse ogni volta:
- Input. Quali tipi? Quale intervallo? C'è un limite, e il limite stesso è incluso? Che dire di zero, valori negativi, vuoti,
None? - Output. Cosa viene restituito esattamente: lo sconto o il nuovo totale? Di che tipo? Arrotondato a cosa?
- Errori. Quali input vengono rifiutati e in che modo: quale eccezione?
- Effetti collaterali. Modifica qualcosa (una lista passata come argomento, un file, un database) o stampa qualcosa? Oppure non fa assolutamente nulla?
Poste per lo sconto, queste domande producono risposte, e le risposte vanno dove si trova il codice: nella docstring:
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5Ogni riga della docstring è ora qualcosa che un test può verificare, e ogni verifica ha un valore atteso evidente. Questa è la prova del nove di un buon contratto: una persona esterna saprebbe scrivere il valore atteso per qualsiasi input, senza leggere il codice? Se la risposta è no, c'è ancora una domanda da fare.
Chi risponde alle domande? Chiunque sia responsabile del requisito: un product owner, un docente, un cliente o tu. Quando non c'è nessuno a cui chiedere, prendi una decisione, scrivila nel contratto e vai avanti. Una decisione esplicita può essere riesaminata e modificata; una implicita rimane nascosta nel codice finché non coglie qualcuno di sorpresa.
2. Codice testabile: un nucleo puro e un guscio sottile
Un test chiama una funzione con input scelti e confronta ciò che torna indietro. Alcune funzioni rendono tutto questo impossibile. Eccone una, per l'happy hour di un bar: 20% di sconto sulle bevande tra le 17:00 e le 19:00:
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")Funziona quando la esegui e digiti un prezzo. Ora prova a verificarla da un altro file, check_drinks.py:
from drinks import drink_price
result = drink_price()
print("returned:", result)Un controllo automatizzato viene eseguito senza nessuno alla tastiera. < /dev/null fornisce al programma esattamente questo: un input completamente vuoto:
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a lineForniscigli un prezzo e viene eseguito, ma guarda cosa torna indietro:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: NoneTre problemi distinti, ciascuno molto comune:
- Legge il proprio input con
input()invece di riceverlo come argomento, quindi un test non può scegliere l'input. - Stampa il proprio risultato invece di restituirlo, quindi un test riceve
Nonee non ha nulla da confrontare. - Legge l'orologio in modo autonomo. Quell'esecuzione è avvenuta poco prima delle sette del mattino, quindi la risposta è stata
10.00; lo stesso comando alle cinque e mezza del pomeriggio dà8.00. Un controllo su di essa passerebbe o fallirebbe a seconda di quando viene eseguito: un test instabile (flaky), che è peggio di non avere alcun test, perché le persone imparano a ignorarlo.
La soluzione non è un trucco di testing. È un cambio di struttura: separa la decisione dall'input e output. La decisione diventa una funzione pura: tutto ciò di cui ha bisogno arriva come argomenti, la risposta esce come valore di ritorno e non tocca null'altro. La tastiera, lo schermo e l'orologio si spostano in un guscio sottile attorno ad essa:
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()Ora i controlli possono scegliere qualsiasi ora preferiscano, in qualsiasi momento della giornata:
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedIl programma si comporta esattamente come prima per la persona alla tastiera. Ciò che è cambiato è che la parte che vale la pena testare — la regola — ora può essere testata in isolamento, e il guscio è così sottile che non c'è quasi nulla al suo interno in cui si possa sbagliare.
I segnali d'allarme del codice difficile da testare, così puoi individuarli prima di scrivere un test:
| Segnale | Perché danneggia il test | La soluzione comune | | --- | --- | --- | | input() all'interno della logica | il test non può scegliere l'input | riceverlo come argomento | | print() come unico risultato | non torna indietro nulla da confrontare | restituire il valore; stampare nel guscio | | datetime.now(), random all'interno | la risposta cambia tra un'esecuzione e l'altra | passare l'orario o il valore casuale come argomento | | legge o modifica una variabile globale | i test si influenzano a vicenda | passarla come argomento, restituire il nuovo valore | | apre un file o un URL fisso all'interno | il test richiede quel file o la rete | passare i dati, o il percorso, come argomento |
Non sempre è possibile ristrutturare il codice: a volte appartiene a qualcun altro, oppure l'I/O è il comportamento stesso. pytest dispone di strumenti per questi casi: capsys cattura l'output stampato (capitolo nove), tmp_path fornisce una cartella temporanea usa e getta (capitolo nove), e monkeypatch e i mock sostituiscono l'orologio, la rete e l'ambiente (capitoli dieci e undici). Ma la ristrutturazione viene prima di tutto. Il codice facile da testare è in genere semplicemente più facile da capire.
3. Scegliere i casi
Non puoi testare tutti gli input; già solo drink_price accetta qualsiasi prezzo a qualsiasi ora. Il compito è scegliere un piccolo insieme di casi in cui ciascuno possa rilevare un errore che gli altri non vedrebbero. Due concetti svolgono la maggior parte di questo lavoro.
Classi di equivalenza. Raggruppa gli input che il contratto tratta allo stesso modo. Un qualsiasi elemento del gruppo rappresenta tutti gli altri: se drink_price(10, 12) è corretta, quasi certamente lo è anche drink_price(10, 11), perché la stessa riga di codice gestisce entrambi. Supponi che il contratto sia stato reso più rigoroso, specificando che un'ora al di fuori di 0–23 o un prezzo negativo sollevano ValueError. Le ore si dividono allora in cinque classi:
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalidValori limite. Gli errori non si distribuiscono in modo uniforme all'interno di una classe. Si concentrano sui suoi bordi, perché è lì che si scelgono < e <=, ed è lì che "fino alle 19:00" viene tradotto in codice. Quindi, per ogni limite, testa il valore su entrambi i lati: l'ultimo di una classe e il primo della successiva. Per un limite L, questo significa esaminare L - 1, L e L + 1; per conteggi e lunghezze, anche 0 e 1.
Ecco perché "un valore tipico per classe" non è sufficiente. Qui sotto, il test dell'happy hour è scritto con una svista comune, 17 < hour invece di 17 <= hour:
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorUn caso per ogni classe: passano tutti, e i clienti che arrivano alle cinque pagano il prezzo pieno. Il caso limite lo rileva all'istante.
Classi e valori limite coprono la struttura dell'input. Poi passa in rassegna queste domande, ognuna delle quali ha permesso di scovare bug reali:
- Input non valido — ciò che il contratto dice di rifiutare: un prezzo negativo, l'ora
24. - Valori vuoti e zero —
[],"",0, un prezzo pari a0. Come si comporta il caso "nullo"? None— solo se il contratto lo menziona. Se non lo fa, non è un caso (vedi la domanda sei).- Valori grandi — una lista molto lunga, un numero molto grande. Per qualsiasi elemento con un limite, testa il valore al limite.
- Arrotondamento — dove un calcolo produce più decimali di quanti ne mantenga il risultato.
Tutto questo, messo insieme, costituisce un piano di test: una riga per ogni caso, scritta prima di qualsiasi codice di test. L'ultima colonna è la più importante: una riga che non puoi giustificare è una riga di cui non hai bisogno.
| Caso | Input (price, hour) | Atteso | Perché | | --- | --- | --- | --- | | Ora valida minima | (10, 0) | 10 | limite dell'intervallo valido | | Ultima ora a prezzo pieno | (10, 16) | 10 | sotto il limite delle 17:00 | | Prima ora scontata | (10, 17) | 8.0 | sul limite delle 17:00 | | Ultima ora scontata | (10, 18) | 8.0 | sotto il limite delle 19:00 | | Prima ora di nuovo a prezzo pieno | (10, 19) | 10 | sul limite delle 19:00 — "escluse" | | Ora valida massima | (10, 23) | 10 | limite dell'intervallo valido | | Bevanda gratis | (0, 17) | 0 | prezzo pari a zero | | Arrotondamento | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, arrotondato a 2 decimali | | Ora troppo bassa | (10, -1) | ValueError | appena fuori dall'intervallo | | Ora troppo alta | (10, 24) | ValueError | appena fuori dall'intervallo | | Prezzo negativo | (-1, 12) | ValueError | input non valido |
Ed ecco il piano trasformato in controlli, a fronte della funzione resa più rigorosa:
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passedUndici righe, e ciascuna si trova lì per una ragione che puoi spiegare a voce alta.
4. L'oracolo: conoscere la risposta senza il codice
Ogni controllo confronta la risposta del codice con una risposta attesa. La fonte della risposta attesa si chiama oracolo, e ha una sola regola: non deve essere il codice sotto test.
Sembra fin troppo ovvio per doverlo precisare, finché non vedi con quanta facilità questa regola viene infranta. Qui member_discount presenta un bug (1% invece del 10%) e due controlli:
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorIl primo controllo confronta la funzione con se stessa. Passa per la funzione corretta, per questa versione difettosa e per qualsiasi altra versione che chiunque potrebbe mai scrivere: non può fallire, quindi non dice nulla.
Nessuno lo scrive in modo così palese. Lo stesso errore di solito si presenta sotto uno di questi due travestimenti:
- Copiare l'output. Esegui la funzione, vedi che stampa
20.0e incolli20.0nel controllo. Se il codice era già errato, la verifica ora protegge il bug. - Ripetere la formula. Scrivi il valore atteso come
2000 * 0.01, ovvero lo stesso calcolo che fa il codice. Una copia della logica del codice condivide gli errori del codice. ("Quando qualcosa va storto", più sotto, mostra questo caso in esecuzione).
I buoni oracoli, all'incirca dal più al meno comune:
- Calcolare il risultato a mano a partire dal contratto. Scegli input che rendano il calcolo facile: 2000 anziché 1873.41, in modo che il "10% di esso" sia qualcosa che puoi calcolare a mente.
- Esempi nella specifica. Se il requisito dice "un ordine di 1500 riceve uno sconto di 150", quella riga è un test.
- Fatti noti. L'acqua bolle a 100 °C e 212 °F; una lista vuota ha lunghezza 0.
- Un metodo diverso e affidabile. Un calcolo più semplice e più lento, oppure una funzione della libreria standard che svolge lo stesso compito in un altro modo:
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.mean è stata scritta da altre persone, in un modo diverso, quindi concordare con essa ha un valore reale. (Confrontare risultati decimali con == può riservare brutte sorprese: 0.1 + 0.2 == 0.3 è False in Python. I valori usati qui sono sicuri; il capitolo cinque mostrerà come confrontare correttamente i numeri in virgola mobile).
Se non riesci a calcolare la risposta attesa con nessuno di questi mezzi, non è un problema di test. Significa che non sai ancora cosa dovrebbe fare il codice: torna al contratto.
5. Cosa deve essere pronto
Per questo capitolo, solo Python 3. Controlla quale versione hai:
$ python3 --version
Python 3.12.3Questo corso utilizza Python 3.12, e qualsiasi versione da 3.10 in su eseguirà quasi tutto. Su Windows il comando è solitamente py --version.
A partire dal prossimo capitolo, altre tre cose dovranno essere pronte prima che un test possa essere eseguito, e il capitolo uno le imposterà ciascuna:
- Un ambiente virtuale per il progetto, in modo che lo strumento di test sia installato nello stesso Python che esegue il tuo codice.
- pytest installato al suo interno.
- Un posto per i test. I file di test si trovano accanto al codice o in una cartella
tests/, con nomi che iniziano pertest_. Il capitolo uno parte dalla prima soluzione; il capitolo tre passa alla seconda e ne spiega il motivo.
I capitoli successivi aggiungeranno le proprie esigenze specifiche (un file di configurazione, un plugin, dati di esempio su disco) e ciascuno le elencherà nella propria sezione "Prima di scrivere il test".
6. Cosa non testare e quando fermarsi
Un test che non può rilevare un errore nel tuo codice richiede tempo per essere scritto, tempo per essere eseguito e tempo per essere mantenuto, senza dare nulla in cambio. Escludi questi elementi:
- Python stesso. Non verificare se
sortedordina o seroundarrotonda. Testa che la tua funzione ordini l'elemento giusto nell'ordine giusto. - Le librerie di altri. Non verificare se
requestsinvia una richiesta o sepandaslegge un CSV. Testa cosa fa il tuo codice con il risultato; e se hai bisogno di essere certo del comportamento di una libreria, un singolo piccolo controllo che documenti la tua ipotesi è sufficiente. - Codice troppo semplice per essere errato. Una funzione che restituisce una costante, o una classe che si limita a memorizzare i suoi argomenti. Se non riesci a immaginare l'errore, il test non può rilevarlo.
- Comportamenti che nessuno ha promesso. Se il contratto non dice nulla sul passaggio di una stringa a
drink_price, un test si limiterebbe a congelare qualsiasi cosa faccia oggi per caso. O lo aggiungi al contratto — e poi lo testi — oppure lo tralasci. - Il come, anziché il cosa. Non quale funzione ausiliaria sia stata chiamata internamente, né in quale ordine siano stati eseguiti i passaggi. Testa cosa entra e cosa esce: in questo modo la parte interna può essere riscritta liberamente, e i test dimostreranno che funziona ancora.
E quanti test sono sufficienti? Non esiste un numero prefissato, ma c'è una regola. Ne hai abbastanza quando:
- ogni classe di equivalenza ha almeno un caso,
- ogni valore limite ha un caso su entrambi i lati,
- ogni errore promesso dal contratto ha un caso dedicato,
- e ogni bug che hai mai corretto ha un proprio caso di test, così che non possa ripresentarsi di nascosto.
A quel punto, fermati. Per ogni nuovo caso che hai la tentazione di aggiungere, chiediti: quale errore intercetterebbe questo caso che gli altri non intercettano già? Se non riesci a indicarne uno, è un duplicato. drink_price(10, 11) accanto a drink_price(10, 12) non individua nulla di nuovo.
Un esempio completo
Ora vediamo l'intero processo, dall'inizio alla fine, su una funzione realistica.
Il requisito, così come è arrivato: "La spedizione costa 60 per i pacchi locali e 120 per quelli nazionali. I pacchi più pesanti costano di più: 20 per ogni chilo extra a livello locale, 30 a livello nazionale. Non accettiamo nulla oltre i 30 kg."
Le domande, e le risposte ottenute:
| Domanda | Risposta | | --- | --- | | Cosa copre il prezzo base? | Il primo chilogrammo: esattamente 1 kg rientra ancora nel prezzo base. | | 1.2 kg viene fatto pagare come 1 kg o come 2? | Ogni chilogrammo iniziato conta: 1.2 kg viene tariffato come 2 kg. | | 30 kg è accettato? | Sì. 30 è consentito; qualsiasi valore superiore viene rifiutato. | | Che dire di 0 kg o di un peso negativo? | Rifiutati: si tratta di un errore di inserimento dati. | | Come sono scritte le zone? | Esattamente "local" o "national". Qualsiasi altra cosa viene rifiutata, compreso "Local". | | Cosa viene restituito? | Il costo come numero intero. Non viene stampato né salvato nulla. |
Il contratto e la funzione testabile, shipping.py. Riceve tutto come argomenti e restituisce un numero; non c'è alcun guscio da separare, perché non contiene alcun tipo di input o output:
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceil arrotonda per eccesso al numero intero successivo — math.ceil(1.2) è 2 — che corrisponde esattamente a "ogni chilogrammo iniziato conta".
La tabella dei casi. Le classi di peso sono: non valido (0 e inferiori), il chilogrammo base (sopra 0, fino a 1), chilogrammi extra (sopra 1, fino a 30), non valido (sopra 30). Le zone sono "local", "national" e qualsiasi altra cosa. Ogni valore atteso viene calcolato a mano a partire dal contratto, non dal codice:
| Caso | Input | Atteso | Perché | | --- | --- | --- | --- | | Pacco leggero | (0.5, "local") | 60 | all'interno del chilogrammo base | | Esattamente 1 kg | (1, "local") | 60 | limite superiore della base: 1 kg è incluso | | Poco più di 1 kg | (1.2, "local") | 80 | il chilogrammo iniziato conta: 60 + 1 × 20 | | Chili interi, locale | (3, "local") | 100 | 60 + 2 × 20 | | Chili interi, nazionale | (3, "national") | 180 | 120 + 2 × 30 — l'altra zona | | Al limite | (30, "national") | 990 | 120 + 29 × 30; 30 è accettato | | Zero | (0, "local") | ValueError | limite inferiore: 0 viene rifiutato | | Negativo | (-2, "local") | ValueError | input non valido | | Oltre il limite | (30.5, "local") | ValueError | appena sopra 30 | | Zona errata | (2, "Local") | ValueError | non esattamente "local" |
Cosa è deliberatamente escluso dalla tabella: un peso pari a None o "2". Il contratto specifica che il peso è un numero, quindi questi casi sono al di fuori di esso: Python solleva il proprio TypeError quando vengono confrontati con 0, e noi non facciamo alcuna promessa ulteriore. Nemmeno math.ceil: appartiene a Python, non a noi. E nemmeno (2, "local") accanto a (3, "local"): stessa classe, stessa riga di codice, nulla di nuovo da rilevare.
I controlli, check_shipping.py — la tabella, riga per riga:
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedOra rompiamola. Qualche mese dopo, qualcuno fa pulizia in shipping.py, toglie import math e scrive int(weight_kg) invece di math.ceil(weight_kg). Si legge bene e viene eseguito senza errori. Esegui nuovamente i controlli:
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorIl piano ha fatto il suo dovere: la modifica è stata intercettata nel momento stesso in cui sono stati eseguiti i controlli. int taglia i decimali anziché arrotondare per eccesso, quindi 0.5 kg è diventato int(0.5) - 1, ovvero -1 chilogrammo extra.
Ma leggi il report come se non lo sapessi già, e nota quanto poco dica:
- Qual era il valore effettivo? Il report mostra la riga, non il numero. Devi eseguire tu stesso
shipping_cost(0.5, "local")per scoprire che era40. - Cos'altro è rotto? Lo script si è fermato al primo errore. Anche il caso di 1.2 kg è errato (ora restituisce
60invece di80), ma quel controllo non è mai stato eseguito. - Quali promesse sono state mantenute? Nulla ti dice se i casi di errore passano ancora.
- E i controlli degli errori hanno richiesto una funzione ausiliaria con
try/exceptsolo per dire "questo dovrebbe sollevare un'eccezione".
Il ragionamento era la parte difficile, ed è fatto: il contratto, la tabella, l'oracolo. Ciò che manca è un runner migliore: uno che trovi i controlli da solo, esegua ciascuno di essi anche dopo un errore, mostri il valore effettivo accanto a quello atteso e verifichi gli errori in una sola riga. Questo è pytest, e installarlo è l'argomento del prossimo capitolo.
Quando qualcosa va storto
Questi sono errori di pianificazione piuttosto che errori di Python, quindi i sintomi sono più silenziosi: di solito un controllo che passa quando non dovrebbe.
Il valore atteso ripete la formula del codice stesso Il controllo viene scritto eseguendo lo stesso calcolo che fa la funzione. In questo caso la funzione presenta il bug di int visto sopra, e lo stesso vale per il controllo:
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorQuesto è anche l'aspetto che assume il "testare l'implementazione": il controllo rispecchia come funziona il codice invece di dichiarare cosa promette. Scrivi il valore atteso come un semplice numero ricavato dal contratto.
Il valore atteso è stato copiato dall'output Hai eseguito la funzione, hai visto 20.0 e l'hai incollato nel controllo. Il test passa; e se 20.0 era sbagliato, ora protegge il bug. Calcola ogni valore atteso prima di eseguire il codice; se il tuo numero e quello del codice non concordano, scopri quale dei due è errato prima di scrivere qualsiasi cosa.
Il piano non contiene casi limite Tutti i controlli passano, e il bug si annida proprio sul limite: le cinque in punto, esattamente 1 kg, 12 anni. Una tabella con sole righe "tipiche" è la causa più frequente. Per ogni <, <=, "fino a", "a partire da" e "almeno" presente nel contratto, dovrebbe esserci un caso su ciascun lato.
La funzione non può essere chiamata da un controllo EOFError: EOF when reading a line significa che la funzione è in attesa della tastiera; returned: None significa che ha stampato la risposta anziché restituirla; un controllo che passa al mattino e fallisce la sera significa che legge l'orologio. Tutti e tre sono problemi di progettazione, risolti dalla ristrutturazione con nucleo puro e guscio sottile descritta nella sezione due.
SyntaxWarning: assertion is always true, perhaps remove parentheses? Le parentesi attorno a un assert e al suo messaggio li trasformano in un'unica tupla, e una tupla non vuota è sempre vera:
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999 è palesemente errato, eppure il controllo è passato. Scrivi assert drink_price(10, 17) == 999, "happy hour price" — senza parentesi — e fallirà come deve. In questo caso Python ti avverte; leggi l'avviso invece di scorrere oltre.
Un controllo che confronta risultati decimali con == fallisce a fronte di una risposta corretta 0.1 + 0.2 == 0.3 restituisce False in Python, perché 0.1 + 0.2 fa 0.30000000000000004. La lezione di pianificazione consiste nello specificare nel contratto come vengono arrotondati i risultati, come fanno member_discount e drink_price. Lo strumento di pytest per confrontare i float sarà trattato nel capitolo cinque.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Il requisito: "gli ordini di 500 o più hanno la spedizione gratuita". Cosa succede quando viene eseguito questo script?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- AViene stampato `checks passed`, poi un `AssertionError` sull'ultimo assert
- BUn `AssertionError` sul primo assert, e non viene stampato nulla
- CNon succede nulla: gli assert vengono ignorati in uno script
- DVengono stampati `checks passed` e poi `all done`
Quale funzione può essere testata in modo affidabile, così com'è, con una sola riga di assert?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
Questa verifica serve a controllare la regola "1.2 kg viene tariffato come 2 kg". Cosa c'è che non va?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- ADovrebbe usare `is` invece di `==`
- BLa funzione deve essere chiamata all'interno di `try`/`except`
- CIl valore atteso proviene dal codice sotto test, quindi la verifica non può mai fallire
- D`1.2` non è un valore limite, quindi non vale la pena testarlo
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
Un cinema ti invia questo requisito:
"I bambini pagano meno, i pensionati hanno uno sconto e i neonati entrano gratis. Gli adulti pagano il prezzo pieno, 300."
Fai tutto ciò che è descritto in questo capitolo, in ordine, prima di scrivere un solo controllo:
- Fai le domande. Annota almeno cinque punti che la frase lascia aperti: cosa significa "bambini", dove iniziano i "pensionati", se i valori limite sono inclusi, quali sono i prezzi, cosa succede con un'età impossibile. Poi stabilisci le risposte, come farebbe il cinema: sotto i 3 anni è gratis; da 3 a 12 anni si paga 150; da 13 a 64 anni si paga 300; dai 65 anni in su si paga 200; le età vanno da 0 a 120 anni, e qualsiasi valore al di fuori solleva
ValueError. - Scrivi il contratto come docstring di una funzione
ticket_price(age)incinema.py. - Rendila testabile: argomenti in ingresso, un valore di ritorno in uscita, niente
input()e nienteprint(). - Compila la tabella dei casi: caso, input, atteso, perché. Individua prima le classi di equivalenza, poi inserisci un caso su ciascun lato di ogni limite. Calcola ogni valore atteso a partire dal contratto.
- Decidi cosa non testerai e spiegane il motivo.
- Scrivi
check_cinema.pycon un singoloassertper ogni riga, ed eseguilo.
Poi rompi la funzione di proposito: cambia la regola per i bambini da "fino a 12 anni compresi" a "meno di 12 anni", ovvero da <= a <. Prima di eseguire, prevedi quale controllo fallirà. Esegui il codice e confronta il risultato.
Soluzione
1. Le domande, e le risposte stabilite:
| Domanda | Risposta | | --- | --- | | Fino a quale età un neonato entra gratis? | Età 0, 1 e 2 anni. Dai 3 anni il biglietto si paga. | | Chi è un "bambino"? | Da 3 a 12 anni compresi. Prezzo 150. | | Chi ha 13 anni è un bambino? | No: a 13 anni si paga il prezzo da adulto, 300. | | Da dove partono i pensionati? 65 anni è incluso? | Dai 65 anni in su. Prezzo 200. | | Cos'è un'età impossibile? | Inferiore a 0 o superiore a 120: ValueError. | | Cosa viene restituito? | Il prezzo come numero intero; non viene stampato nulla. |
2 e 3. Il contratto e la funzione, cinema.py. I limiti sono costanti con nome, quindi il codice si legge esattamente come la tabella:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. La tabella dei casi. Le classi sono: non valido (sotto 0), gratuito (0–2), bambini (3–12), adulti (13–64), anziani (65–120), non valido (sopra 120). Questo produce quattro casi tipici, otto casi limite e due non validi:
| Caso | Input | Atteso | Perché | | --- | --- | --- | --- | | Neonato tipico | 1 | 0 | classe gratuita | | Bambino tipico | 8 | 150 | classe bambini | | Adulto tipico | 30 | 300 | classe adulti | | Anziano tipico | 80 | 200 | classe anziani | | Neonato | 0 | 0 | età valida minima | | Ultima età gratuita | 2 | 0 | sotto il limite tra gratis e bambini | | Prima età da bambino | 3 | 150 | sul limite tra gratis e bambini | | Ultima età da bambino | 12 | 150 | "fino a 12 anni compresi" — il limite stesso | | Prima età da adulto | 13 | 300 | appena oltre il limite dei bambini | | Ultima età da adulto | 64 | 300 | sotto il limite degli anziani | | Prima età da anziano | 65 | 200 | "dai 65 anni in su" — il limite stesso | | Età valida massima | 120 | 200 | limite superiore dell'intervallo valido | | Sotto l'intervallo | -1 | ValueError | appena fuori, lato inferiore | | Sopra l'intervallo | 121 | ValueError | appena fuori, lato superiore |
5. Cosa non viene testato: una stringa o None per l'età: il contratto specifica un numero intero e non promette nient'altro. Un'età decimale come 12.5: lo stesso discorso. I prezzi di altri cinema, o l'operatore <= di Python: non spetta a noi testarli. E un secondo caso tipico nella stessa classe, come 9 accanto a 8: non intercetterebbe nulla di nuovo.
6. I controlli, check_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedIl bug intenzionale: if age <= CHILD_UP_TO: diventa if age < CHILD_UP_TO:. Solo l'età 12 cambia classe (passa alla tariffa adulti), quindi solo il controllo per 12 dovrebbe fallire:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLa riga 23 è assert ticket_price(12) == 150, esattamente come previsto.
Perché viene fatto in questo modo:
- Le domande sono venute per prime. "Bambini" e "pensionati" non sono numeri; ogni risposta nella prima tabella ha trasformato una parola in un valore limite che puoi testare. Senza di esse,
12e65sarebbero state solo supposizioni. - La funzione è pura. Età in ingresso, prezzo in uscita, nient'altro: così ogni controllo è una sola riga, e dà la stessa risposta a qualsiasi ora del giorno su qualsiasi macchina.
- Costanti con nome per i valori limite.
CHILD_UP_TO = 12esprime in un unico posto ciò che dice il contratto, e i controlli richiamano gli stessi numeri. - Le righe dei valori limite hanno fatto il lavoro importante. Le quattro età tipiche (1, 8, 30, 80) passano ancora tutte con il bug presente. Solo la riga per
12, il limite stesso, lo ha intercettato. Questa è l'intera dimostrazione del valore dei casi limite in una sola esecuzione. - Ogni valore atteso proviene dal contratto. Nessuno di essi è stato copiato eseguendo
ticket_price, quindi una risposta errata restituita dal codice non può infiltrarsi nei controlli. - Si è fermato al primo errore e ha indicato solo la riga. Sapevi quale controllo era fallito perché lo avevi previsto, ma l'output non ti ha detto che il prezzo effettivo per l'età 12 era 300. Questa è la lacuna che il prossimo capitolo andrà a colmare.
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