Matteo Manferdini

Molti sviluppatori trovano i test unitari confusi. Questo è peggiorato dalle tecniche avanzate necessarie per testare le classi o il codice asincrono, come quello per le richieste di rete.

In realtà, alla base del test unitario, ci sono semplici concetti fondamentali. Una volta che li avete afferrati, i test unitari diventano improvvisamente più accessibili.

Contenuti

  • I test unitari spiegati in termini semplici,
  • Il test delle unità ti permette di controllare casi limite difficili da raggiungere e prevenire i bug
  • Scrivere test delle unità in Xcode
  • Una buona architettura rende il tuo codice più facile da testare
  • Trattare i tipi di valore come funzioni pure per semplificare il test delle unità
  • Verificare il tuo codice usando le funzioni di asserzione del framework XCTest
  • Misurare la copertura del codice di un test in Xcode
  • Perché puntare a una copertura specifica del codice non ha senso e cosa dovresti fare invece

I test unitari spiegati in termini semplici, comprensibile

Quando scrivi un’applicazione, passi attraverso un ciclo di scrittura del codice, lo esegui e controlli se funziona come previsto. Ma, man mano che il tuo codice cresce, diventa più difficile testare l’intera app manualmente.

I test automatizzati possono eseguire questi test per voi. Quindi, praticamente parlando, i test unitari sono il codice per assicurarsi che il codice della vostra app sia corretto.

L’altra ragione per cui i test unitari possono essere difficili da capire è che molti concetti li circondano: test in Xcode, architettura dell’app, doppi test, dependency injection, e così via.

Ma, nel suo nucleo, l’idea dei test unitari è abbastanza semplice. Puoi scrivere semplici test unitari in Swift. Non avete bisogno di nessuna caratteristica speciale di Xcode o tecnica sofisticata, e potete persino eseguirli all’interno di uno Swift Playground.

Guardiamo un esempio. La funzione qui sotto calcola il fattoriale di un intero, che è il prodotto di tutti gli interi positivi minori o uguali all’input.

1
2
3
4
5
6
7

func factorial(of number: Int) -> Int {
var result = 1
for factor in 1….numero {
risultato = risultato * fattore
}
return result
}

Per testare questa funzione manualmente, dovresti darle un paio di numeri e controllare che il risultato sia quello che ti aspetti. Così, per esempio, la provereste su quattro e controllereste che il risultato è 24.

Potete automatizzare questo, scrivendo una funzione Swift che faccia il test per voi. Per esempio:

1
2
3
4
5

func testFactorial() {
if factorial(of: 4) != 24 {
print(“Il fattoriale di 3 è sbagliato”)
}
}

Questo è semplice codice Swift che tutti possono capire. Tutto ciò che fa è controllare l’output della funzione e stampare un messaggio se non è corretto.

Questo è il test unitario in poche parole. Tutto il resto sono campanelli e fischietti aggiunti da Xcode per rendere i test più semplici.

O almeno, sarebbe così se il codice delle nostre applicazioni fosse semplice come la funzione fattoriale che abbiamo appena scritto. Ecco perché avete bisogno del supporto di Xcode e di tecniche avanzate come i test doppi.

Nonostante, questa idea è utile sia per i test semplici che per quelli complessi.

I test unitari vi permettono di controllare casi limite difficili da raggiungere e prevenire i bug

Potete ottenere diversi benefici dai test unitari.

Il più ovvio è che non dovete continuamente testare il vostro codice manualmente. Può essere noioso eseguire la tua app e arrivare ad una specifica posizione con la funzionalità che vuoi testare.

Ma questo non è tutto. I test unitari ti permettono anche di testare i casi limite che potrebbero essere difficili da creare in un’app in esecuzione. E i casi limite sono dove vivono la maggior parte dei bug.

Per esempio, cosa succede se inseriamo zero o un numero negativo nella nostra funzione factorial(of:) di cui sopra?

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of: 0) != 1 {
print(“Il fattoriale di 0 è sbagliato”)
}
}

Il nostro codice si rompe:

In una vera applicazione, questo codice causerebbe un crash. Zero è fuori dall’intervallo nel ciclo for.

Ma il nostro codice non fallisce a causa di una limitazione degli intervalli Swift. Fallisce perché abbiamo dimenticato di considerare gli interi non positivi nel nostro codice. Per definizione, il fattoriale di un intero negativo non esiste, mentre il fattoriale di 0 è 1.

1
2
3
4
5
6
7
8
9
10
11
12
13

func factorial(of number: Int) -> Int? {
if (numero < 0) {
return nil
}
if (numero == 0) {
return 1
}
var result = 1
for factor in 1…number {
result = result * factor
}
return result
}

Possiamo ora eseguire nuovamente i nostri test e anche aggiungere un controllo per i numeri negativi.

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of: -1) != nil {
print(“Il fattoriale di -1 è sbagliato”)
}
}

Qui vedete l’ultimo e, secondo me, più importante beneficio dei test unitari. I test che abbiamo scritto in precedenza assicurano che, quando aggiorniamo la nostra funzione factorial(of:), non rompiamo il suo codice esistente.

Questo è cruciale nelle applicazioni complesse, dove dovete aggiungere, aggiornare e cancellare codice continuamente. I test unitari ti danno la sicurezza che le tue modifiche non hanno rotto il codice che funzionava bene.

Scrivere test unitari in Xcode

Con una comprensione dei test unitari, possiamo ora guardare le caratteristiche di test di Xcode.

Quando crei un nuovo progetto Xcode, hai la possibilità di aggiungere subito i test unitari. Userò, come esempio, un’applicazione per tenere traccia delle calorie. Puoi trovare il progetto Xcode completo qui.

Quando selezioni questa opzione, il tuo progetto modello includerà un obiettivo di test già configurato. Puoi sempre aggiungerne uno in seguito, ma io di solito spunto l’opzione per default. Anche se non ho intenzione di scrivere subito dei test, tutto sarà configurato quando deciderò di farlo.

Il target dei test inizia già con del codice template per i nostri test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

import XCTest
@testable import Calories
class CaloriesTests: XCTestCase {
override func setUpWithError() throws {
// Metti il codice di setup qui. Questo metodo è chiamato prima dell’invocazione di ogni metodo di test nella classe.
}
override func tearDownWithError() throws {
// Metti qui il codice di teardown. Questo metodo è chiamato dopo l’invocazione di ogni metodo di test nella classe.
}
func testExample() throws {
// Questo è un esempio di test case funzionale.
// Usa XCTAssert e le funzioni correlate per verificare che i tuoi test producano i risultati corretti.
}
func testPerformanceExample() throws {
// Questo è un esempio di test di prestazioni.
self.measure {
// Metti qui il codice di cui vuoi misurare il tempo.
}
}
}

  • La linea @testable import Calories ti dà accesso a tutti i tipi nel target principale del tuo progetto. Il codice di un modulo Swift non è accessibile dall’esterno a meno che tutti i tipi e i metodi siano esplicitamente dichiarati come pubblici. La parola chiave @testable ti permette di aggirare questa limitazione e accedere anche al codice privato.
  • La classe CaloriesTests rappresenta un caso di test, cioè un raggruppamento di test correlati. Una classe test case deve discendere da XCTestCase.
  • Il metodo setUpWithError() permette di impostare un ambiente standard per tutti i test di un test case. Se avete bisogno di fare un po’ di pulizia dopo i vostri test, usate tearDownWithError(). Questi metodi vengono eseguiti prima e dopo ogni test in un caso di test. Noi non ne avremo bisogno, quindi potete cancellarli.
  • Il metodo testExample() è un test come quelli che abbiamo scritto per il nostro esempio fattoriale. Puoi chiamare i metodi di test come vuoi, ma devono iniziare con il prefisso test perché Xcode li riconosca.
  • E infine, il testPerformanceExample() ti mostra come misurare le prestazioni del tuo codice. I test di performance sono meno standard dei test unitari, e si usano solo per il codice cruciale che deve essere veloce. Non useremo nemmeno questo metodo.

Puoi trovare tutti i tuoi casi di test e relativi test elencati nel navigatore Test di Xcode.

Puoi aggiungere nuovi casi di test al tuo progetto semplicemente creando nuovi file .swift con codice come quello sopra, ma è più facile usare il menu aggiungi in fondo al navigatore dei test.

Una buona architettura rende il vostro codice più facile da testare

In un progetto reale, di solito non avete funzioni libere come nel nostro esempio factorial. Invece, hai strutture, enumerazioni e classi che rappresentano le varie parti della tua applicazione.

È fondamentale organizzare il tuo codice secondo un design pattern come MVC o MVVM. Avere un codice ben organizzato non solo rende il vostro codice ben organizzato e più facile da mantenere.

Questo ci aiuta anche a rispondere a una domanda comune: quale codice si dovrebbe testare per primo?

La risposta è: iniziare dai tipi di modello.

Ci sono diverse ragioni per questo:

  • Se si segue il pattern MVC o uno dei suoi derivati, i tipi di modello contengono la logica di business del dominio. La vostra intera applicazione dipende dalla correttezza di tale codice, quindi anche pochi test hanno un impatto significativo.
  • I tipi di modello sono spesso strutture, che sono tipi di valore. Questi sono molto più facili da testare rispetto ai tipi di riferimento (vedremo perché tra un momento).
  • I tipi di riferimento, cioè le classi, hanno dipendenze e causano effetti collaterali. Per scrivere test unitari per queste, avete bisogno di test doppi (dummies, stubs, fakes, spie e mocks) e usate tecniche sofisticate.
  • I controllori (in termini MVC) o i modelli di vista (in termini MVVM) sono spesso collegati a risorse come la memoria su disco, un database, la rete e sensori di dispositivi. Potrebbero anche eseguire codice parallelo. Questo rende i test unitari più difficili.
  • Il codice delle viste è quello che cambia più frequentemente, producendo test fragili che si rompono facilmente. E a nessuno piace sistemare i test rotti.

Trattare i tipi di valore come funzioni pure per semplificare i test unitari

Aggiungiamo quindi alcuni tipi di modello alla nostra applicazione.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

struct FoodItem {
let name: String
let caloriesFor100Grams: Int
let grammi: Int
}
struct Meal {
private(set) var items: =
var calorie: Int {
var calorie = 0
for item in items {
calorie += (item.caloriesFor100Grams / 100) * item.grams
}
return calorie
}
mutating func add(_ item: FoodItem) {
items.append(item)
}
}

La struttura Meal contiene qualche semplice logica per aggiungere elementi di cibo e calcolare le calorie totali del cibo che contiene.

Il test delle unità ha un tale nome perché dovresti testare il tuo codice in unità separate, rimuovendo tutte le dipendenze (testare le dipendenze insieme è invece chiamato test di integrazione). Ogni unità viene quindi trattata come una scatola nera. Le si fornisce un certo input e si testa il suo output per verificare se è quello che ci si aspetta.

Questo era facile nel caso del nostro esempio factorial perché era una funzione pura, cioè una funzione dove l’output dipende solo dall’input, e che non crea alcun effetto collaterale.

Ma questo non sembra il caso del nostro tipo Meal, che contiene stato.

Il valore restituito dalla proprietà calories computed non ha input. Dipende solo dal contenuto dell’array items. Il metodo add(_:), invece, non restituisce alcun valore e cambia solo lo stato interno di un pasto.

Ma le strutture sono tipi di valore, e possiamo considerarle come funzioni pure. Poiché non hanno dipendenze, si può considerare lo stato iniziale di una struttura come l’input di un metodo e il suo stato dopo aver chiamato il metodo come l’output.

(Questo è uno dei motivi per cui si dovrebbe evitare di mettere tipi di riferimento dentro tipi di valore).

Verificare il vostro codice usando le funzioni di asserzione del framework XCTest

Ora abbiamo del codice da testare, e un posto nel nostro progetto Xcode dove scrivere i test.

Ci manca un ultimo ingrediente: un modo per esprimere se il nostro codice è corretto o no.

Nell’esempio all’inizio di questo articolo, ho usato semplici istruzioni di stampa. Questo non è pratico per un vero test unitario. Non si vuole perdere tempo a setacciare i log per identificare quali test sono falliti. Abbiamo bisogno di un modo che punti direttamente ai test falliti.

Nel framework XCTest, trovate una serie di funzioni di asserzione che fanno sì che Xcode punti ai test che non passano.

Con queste, possiamo scrivere il nostro primo test.

Come ho detto sopra, i test unitari sono utili per controllare i casi limite, quindi cominciamo ad assicurarci che un pasto vuoto non abbia calorie.

1
2
3
4
5
6

class CaloriesTests: XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, “Un pasto vuoto dovrebbe avere 0 calorie”)
}
}

XCTest offre una funzione generica XCTAssert che ti permette di asserire qualsiasi condizione. Potreste usarla per qualsiasi test che scrivete, ma è meglio usare funzioni di asserzione più specializzate quando possibile, come XCTAssertEqual (sopra), XCTAssertNil e altre. Queste producono errori migliori in Xcode rispetto a XCTAssert.

Puoi eseguire un singolo test cliccando sul diamante accanto al suo nome nel gutter dell’editor di codice.

Quando un test passa, diventa verde, mentre i test falliti sono segnati in rosso. Puoi cambiare lo 0 nel test con qualsiasi altro numero per vedere come appare un test fallito.

Puoi eseguire tutti i test in un caso di test cliccando sul diamante accanto alla dichiarazione della classe. Puoi anche usare il navigatore di test che abbiamo visto sopra per eseguire singoli test o casi di test.

Vuoi spesso eseguire tutti i test del tuo progetto in una volta sola, cosa che puoi fare rapidamente premendo cmd+U sulla tua tastiera o selezionando l’opzione Test nel menu Product di Xcode.

Misurare la copertura del codice di un test in Xcode

La prossima, più comune domanda sui test unitari è: quanto del tuo codice dovresti testare?

Questo è solitamente misurato dalla copertura del codice, cioè, la percentuale di codice coperta dai test. Quindi, prima di rispondere a questa domanda, vediamo come potete misurare la copertura del codice dei vostri test in Xcode.

Prima di tutto, dovete lasciare che Xcode raccolga la copertura per il vostro obiettivo di test. Clicca sul pulsante con il nome del progetto nel controllo segmentato nella barra degli strumenti di Xcode (vicino al pulsante stop). Poi, seleziona Modifica schema… nel menu a comparsa.

Lì, seleziona Test nel contorno, poi vai alla scheda Opzioni. E, infine, selezionate Gather coverage for. Puoi lasciare l’opzione su tutti gli obiettivi.

Puoi ora rilanciare i tuoi test, il che permetterà a Xcode di raccogliere i dati di copertura. Troverete il risultato nel navigatore Report, selezionando la voce Code coverage nell’outline.

Qui, potete controllare le percentuali di codice coperte dai vostri test per l’intero progetto e per ogni file.

Questi numeri non sono così significativi per il nostro esempio, dato che non abbiamo scritto quasi nessun codice. Tuttavia, possiamo vedere che il metodo add(_:) del tipo Meal ha una copertura dello 0%, il che significa che non l’abbiamo ancora testato.

La proprietà calcolata calorie ha, invece, una copertura dell’85,7%, il che significa che ci sono dei percorsi di esecuzione nel nostro codice che il nostro test non ha attivato.

Nel nostro semplice esempio, è facile capire quale percorso sia. Abbiamo testato solo le calorie di un pasto vuoto, quindi il codice nel ciclo for non è stato eseguito.

In metodi più sofisticati, però, potrebbe non essere così semplice.

Per questo, potete tirare fuori la striscia di copertura del codice nell’editor Xcode. Potete trovarla nel menu Adjust Editor Options nell’angolo in alto a destra.

Questo rivelerà una striscia che vi mostra la copertura per ogni linea di codice.

I numeri vi dicono quante volte ogni linea è stata eseguita durante il test. In rosso, potete vedere quali linee non sono state eseguite affatto (pieno) o eseguite solo parzialmente (striato).

Perché puntare ad una specifica copertura del codice non ha senso e cosa dovreste fare invece

Quindi, qual è una buona percentuale di copertura del codice? Alcuni sviluppatori pensano che il 20% sia sufficiente. Altri vanno per cifre più alte, come il 70% o l’80%. Ci sono persino sviluppatori che credono che solo il 100% sia accettabile.

Secondo me, la copertura del codice è una metrica senza senso. Puoi usarla per informare le tue decisioni sui test, ma non dovresti trattarla come un obiettivo da raggiungere.

Per vedere perché scriviamo un altro test per coprire il codice che non abbiamo ancora testato.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

class CaloriesTests: XCTestCase {
let banana = FoodItem(name: “Banana”, caloriesFor100Grams: 89, grams: 118)
let steak = FoodItem(name: “Steak”, caloriesFor100Grams: 271, grams: 225)
let goatCheese = FoodItem(name: “Goat Cheese”, caloriesFor100Grams: 364, grams: 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calorie, 0, “Un pasto vuoto dovrebbe avere 0 calorie”)
}
func testCalorie() {
var meal = Meal()
meal.add(banana)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Se si rilanciano tutti i test, si ottiene una copertura del 100% per il file Model.swift. Sembra buono, vero?

Ora, andate a rimuovere il metodo testEmptyMeal(). Se eseguite il test rimanente da solo, vedrete che la copertura per i nostri tipi Meal è ancora del 100%.

Questo vi mostra già che il numero 100% può darvi un falso senso di sicurezza. Sapete che ora non stiamo testando tutti i casi limite per la proprietà Calories Computed. Eppure, la nostra copertura di test non riflette questo.

Vorrei aggiungere che il 100% di copertura non è solo fuorviante ma anche dannoso. Alcuni codici nel vostro progetto sono soggetti a continui cambiamenti, specialmente il codice più recente e specialmente nelle viste.

Coprire il 100% di esso significa che dovrete produrre solo test fragili che si rompono continuamente, e che dovete sistemare. Questi test non rilevano i bug. Si rompono perché la funzionalità del vostro codice cambia.

E qui arriviamo al punto di cui nessuno parla.

Scrivere test non è divertente, nonostante quello che alcuni sviluppatori sostengono. Correggere i test è ancora meno divertente. Fatelo troppo, e odierete i test unitari e li metterete da parte del tutto.

La psicologia è essenziale per lo sviluppo del software come le migliori pratiche. Non siamo macchine.

Quindi, a quale numero dovreste puntare? Questo richiede una discussione più estesa, ma qui vi darò un riassunto della mia opinione.

Scrivete test utili, iniziando dal codice più vecchio del vostro progetto che non cambierà. Usa la copertura del codice solo per identificare quale codice non è ancora testato, ma non usarla come obiettivo o per decidere quando scrivere più test. Il 20% di copertura è meglio dell’80% se i vostri test sono significativi e se state testando il codice giusto.

Come ho detto sopra, i tipi di modello contengono il codice più critico nel vostro progetto, che tende anche a cambiare di meno. Quindi, iniziate da lì.

Di solito non mi preoccupo di testare ogni singolo percorso di esecuzione. Per esempio, in un progetto reale, non scriverei il metodo testEmptyMeal() che vi ho mostrato sopra. Sì, è fondamentale testare i casi limite, ma non sono tutti importanti.

Io di solito scriverei solo il testCalorie(). Questo mi dice che il mio codice funziona, e mi avverte se, più tardi, faccio un errore quando cambio questo metodo.

Certo, non copre ogni percorso, ma questo codice è così semplice che è solo una perdita di tempo. Spendere il vostro tempo scrivendo codice reale per le caratteristiche che aiutano i vostri utenti è più importante che testare ogni percorso di esecuzione.

So che riceverò qualche critica da alcuni sviluppatori per questa opinione, ma non mi interessa. La copertura dei test è uno di quei dibattiti che non finirà mai.

Il codice che scrivete di solito è quasi sempre giusto. Non c’è bisogno di essere paranoici. Avere una pletora di test che si rompono ogni volta che si cambia qualcosa è una responsabilità, non una risorsa.

Il tuo tempo e la tua forza di volontà sono limitati. Spendili in compiti più importanti.

Architettura delle app SwiftUI con MVC e MVVM

È facile creare un’app mettendo insieme del codice. Ma senza le migliori pratiche e un’architettura robusta, ci si ritrova presto con un codice spaghetti ingestibile. In questa guida vi mostrerò come strutturare correttamente le app SwiftUI.

OTTENETE ORA IL LIBRO GRATUITO

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.