Una panoramica sullo sviluppo guidato dai test.
Lo sviluppo guidato dai test (TDD) indica uno stile di programmazione in cui i test vengono scritti per guidare l'implementazione del design del programma nel codice.
Uno o più test (in particolare i test unitari) vengono scritti prima di scrivere il codice. I test hanno lo scopo di coprire un aspetto del comportamento del programma, che può riguardare una singola funzione o un singolo metodo. Scrivere i test è un modo per trasformare i requisiti del programma e l'architettura generale in un design specifico dell'implementazione. I test vengono eseguiti e dovrebbero fallire, perché il codice non è stato ancora implementato. Il codice viene implementato e i test vengono eseguiti di nuovo. Se i test passano, allora l'implementazione di quel comportamento è conclusa, oppure restano ancora dei test necessari da creare. Se i test non passano, allora si fa debug del codice e i test vengono eseguiti di nuovo. Il ciclo di test e scrittura del codice si ripete finché tutti i test necessari non passano; a quel punto l'implementazione di quell'aspetto del comportamento del programma è pronta... per ora.
La riorganizzazione è la riscrittura del codice per migliorarne il design. Non è semplicemente riscrivere il codice per correggere dei bug. A volte si dice «riorganizzazione» per indicare la modifica del codice con l'obiettivo di farlo passare i test. Anche se modificare il codice può includere il miglioramento del design come modo per far passare i test, il semplice debug non è necessariamente un miglioramento del design del codice, e quindi non è necessariamente una riorganizzazione.
Il seguente è un esempio di debug senza riorganizzazione:
# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
# used multiply operator by mistake. It fails the tests.
return x * y
# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
return x + y
Il seguente è un esempio di riorganizzazione, e poi di debug:
# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
# Introduced multiply operator by mistake. It fails the tests. This is why we test.
return old_cars * new_cars
# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
return old_cars + new_cars
Il track Python di Exercism utilizza la metodologia TDD nei suoi esercizi. I test unitari sono già scritti. Lo studente può consultare i test per farsi un'idea più dettagliata di ciò che serve perché una soluzione passi. Allo studente può essere fornito uno stub della soluzione.
Quando uno o più test falliscono per una soluzione Python, le attività corrispondenti non avranno uno sfondo verde. La prima area di attività che fallisce verrà espansa e la sua intestazione sarà più o meno così
Task 1 Extract coordinates -
Cliccando sul segno meno l'attività si chiude, così possiamo guardare le altre attività; per ora però restiamo su questa.
Sotto ci sarà un'area Test espansa che sarà più o meno così
Test 1 ⌄
FAILED TisburyTreasure > get coordinate
dove Tisbury Treasure indica l'esercizio, e get_coordinate indica la funzione o il metodo che fallisce.
Test 1 di solito è una specie di modello con una sezione di codice per impostare i test.
Non contiene informazioni sui test specifici che sono falliti.
Verso il fondo dirà che
One or more variations of this test failed. Details can be found under each [variant#].
Cliccando su ⌄ il test si chiude.
Sotto ci sarà un test chiuso che sarà più o meno così:
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
L'aspetto varia in base alla larghezza impostata per il pannello di destra.
Cliccando su > il test si espande.
I dati di input e i dati del risultato atteso saranno probabilmente mostrati in una sezione di codice.
I dati potrebbero riferirsi a tutti i test di questa attività.
In fondo, nella sezione Test Failure, c'è il motivo specifico per cui questo test è fallito.
Potrebbe essere più o meno così:
AssertionError: ['2A'] != '2A'
In questo caso specifico, indica che il valore restituito ['2A'] non è uguale al valore atteso '2A'.
Se guardiamo il codice di get_coordinate vediamo che è implementato così
def get_coordinate(record):
return [record[1]]
Se togliamo le parentesi quadre della lista (ad esempio return record[1]) e rieseguiamo i test, i test dell'attività 1 passeranno.
Se una o più attività continuano a fallire, allora si ripete il processo qui sopra con ciascuna di esse finché tutti i test non passano.
A volte i dati attesi e i dati restituiti sono troppo grandi per stare tutti nella sezione Test Failure.
Potrebbe essere più o meno così:
AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.
Potrebbero esserci comunque abbastanza dati per capire qual è il problema.
Nel caso qui sopra, vengono restituiti due caratteri di nuova riga (ad esempio \n\n(\'Brass Spyglass) quando ne è atteso solo uno (ad esempio \n(\'Brass Spyglass).
Complimenti! Tutti i test sono passati. E adesso? La soluzione si potrebbe pubblicare subito. Oppure, ora che il codice funziona, se vuoi riorganizzarlo per qualsiasi motivo, puoi modificare il codice e inviare un'altra iterazione. Se pensi che il codice potrebbe essere migliore, ma non sai come fare, puoi richiedere il mentoring per la soluzione. Se un mentore è disponibile, potrebbe contattarti con delle idee su altri approcci a una soluzione. Quando pubblichi la soluzione, puoi consentire i commenti e altri studenti potrebbero cogliere l'occasione per pubblicare commenti o fare domande.
Anche se «l'ottimizzazione prematura è la radice di tutti i mali» (un detto attribuito sia a Tony Hoare sia a Donald Knuth), arriva comunque il momento in cui, anche se la soluzione funziona, si desidera migliorarne le prestazioni.
Uno di questi momenti può essere quando la soluzione passa alcuni test ma va in timeout su altri.
Può essere utile sapere esattamente quanto tempo impiega un pezzo di codice.
Il modulo timeit può essere usato per misurare il tempo di esecuzione del codice fino a durate molto piccole.
La funzione timeit può accettare fino a cinque argomenti: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None).
Il parametro stmt definisce il codice effettivo da eseguire e cronometrare.
Il parametro number determina quante volte il codice stmt verrà eseguito.
Il parametro setup definisce il codice che viene eseguito una sola volta per preparare l'esecuzione del codice stmt.
Il tempo di esecuzione del codice setup è incluso nel tempo complessivo.
Più iterazioni vengono eseguite dal codice stmt, meno il tempo di setup incide per ogni iterazione.
Il parametro timer permette di passare un Timer diverso da quello predefinito.
L'argomento predefinito per il parametro timer è perf_counter, che dovrebbe essere sufficiente nella maggior parte dei casi.
L'argomento predefinito per il parametro number è 1_000_000.
Il parametro globals specifica un namespace in cui eseguire il codice.
L'argomento predefinito per il parametro globals è None.
Il seguente è un esempio di utilizzo di timeit per vedere quanto tempo ci vuole per determinare se una frase contiene tutte le vocali inglesi:
import timeit
# run one million times
loops = 1_000_000
# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)
Eseguire il codice un milione di volte ha richiesto in media 4.965089999896008e-07 secondi per chiamata (circa 497 nanosecondi per chiamata).
L'esempio seguente serve a vedere se togliere la chiamata a casefold dalla list comprehension fa risparmiare tempo:
import timeit
loops = 1_000_000
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
sentence = sentence.casefold()
return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)
Eseguire il codice un milione di volte ha richiesto in media 4.923898000270128e-07 secondi per chiamata (circa 492 nanosecondi per chiamata). Quindi, togliere casefold dalla list comprehension ha fatto risparmiare circa 5 nanosecondi per chiamata, ovvero circa 5 millisecondi in totale su un milione di chiamate.
Anche cProfile può essere usato per profilare il codice; tuttavia, non è così granulare, dato che scende solo fino a durate nell'ordine dei millisecondi.