1. Perche un «test bello» equivale a velocita di sviluppo
Se il codice di produzione viene scritto «per il computer», i test nella realta vengono scritti «per la persona del futuro». E quasi sempre quella persona sei tu, ma tra due settimane, quando non ricordi piu perche parseCommand all'improvviso ha iniziato a restituire nil per la stringa "add War and Peace". Un buon test fa risparmiare tempo in due modi: spiega rapidamente lo scenario e spiega rapidamente il motivo del fallimento.
Un test senza struttura di solito sembra «ho fatto qualcosa → e andato in errore da qualche parte». Un test con la struttura AAA, invece, sembra una storia breve: «prepariamo l'input → chiamiamo la funzione → verifichiamo il risultato». E il cervello smette di “decodificare” il codice e inizia semplicemente a leggerlo.
AAA come mini protocollo di dialogo con se stessi
AAA significa questo:
- Arrange — preparazione: dati, stato, oggetto che stiamo testando.
- Act — azione: una chiamata/passo principale.
- Assert — verifica: cosa ci aspettiamo esattamente di vedere.
E comodo tenere a mente uno schema del genere:
flowchart LR
A[Arrange: abbiamo preparato l'input e SUT] --> B[Act: abbiamo eseguito l'azione]
B --> C[Assert: abbiamo verificato l'aspettativa]
Il punto non e scrivere sempre i commenti // Arrange, // Act, // Assert (anche se per chi inizia aiuta molto), ma far si che nella testa il test si divida sempre in queste tre parti.
2. Arrange: prepariamo i dati in modo che il test si legga «al primo sguardo»
Nella parte Arrange rispondiamo alla domanda: «in quale mondo si svolge questo test?». Cioe quali valori di input, quali impostazioni iniziali e quale “eroe della storia” (SUT — System Under Test) partecipano allo scenario. Una buona parte Arrange e il piu possibile esplicita: non costringe il lettore a cercare i dati di input in tutto il file e a indovinare che cosa sia davvero importante.
SUT: un solo eroe per test
SUT e cio che stiamo testando: una funzione, un metodo o un tipo. Se nel tuo test ci sono dieci oggetti, tre servizi e due parser, allora si tratta o di un test di integrazione (oggi non lo stiamo trattando), oppure stai cercando per sbaglio di testare meta applicazione con un solo test. Per un unit test e utile tenere «un solo protagonista».
Immaginiamo che nella nostra applicazione CLI didattica LibraryCLI ci sia una piccola funzione di normalizzazione del comando: togliamo gli spazi e convertiamo in minuscolo.
import Foundation
public func normalizeCommand(_ input: String) -> String {
input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
Adesso il test.
import XCTest
import LibraryCLI
final class NormalizeCommandTests: XCTestCase {
func testNormalizeCommand_trimsAndLowercases() {
// Arrange
let input = " ADD "
// Act
let result = normalizeCommand(input)
// Assert
XCTAssertEqual(result, "add")
}
}
Qui Arrange e estremamente onesto: l'input " ADD " si vede direttamente con gli occhi. Questo e piu importante che “risparmiare” due righe.
Numeri e stringhe magiche: anche i test ne soffrono
I “valori magici” danneggiano non solo il codice di produzione, ma anche i test. Se scrivi:
XCTAssertEqual(normalizeCommand(" ADD "), "add")
e compatto, ma il cervello del lettore deve tenere in testa subito due valori: input e risultato atteso. Quando i test diventano molti, questo stanca. I let input = … e let expected = … trasformano il test in una mini specifica.
Un altro punto importante: Arrange non deve per forza coincidere con setUp(). setUp() e utile quando crea un “inizio pulito” (per esempio un nuovo repository vuoto o un nuovo parser). Ma se lo scenario dipende da dati specifici, e meglio tenerli direttamente nel test, cosi restano visibili.
3. Act: «una sola azione» e perche rende chiari i fallimenti
La parte Act risponde alla domanda: «che cosa facciamo esattamente?». E qui c'e una regola molto utile per chi inizia: lascia che Act sia breve e singolo. Una chiamata principale, un risultato principale. Non e un divieto di usare piu righe di codice, ma un divieto di avere un “scenario da 30 passi” in cui non si capisce quale passo si sia rotto.
Se nel tuo Act ci sono un ciclo, la creazione di dieci oggetti, la scrittura su file e una richiesta di rete, congratulazioni: hai scritto per sbaglio una mini applicazione dentro il test.
Act spesso sembra noioso — e va benissimo cosi
Un Act noioso e come un'istruzione noiosa: e noiosa perche e chiara. Per esempio, stiamo testando il parser piu semplice del comando add.
Supponiamo di avere questo tipo:
public enum Command: Equatable {
case add(title: String)
}
E la funzione di parsing:
public func parseAddCommand(_ input: String) -> Command? {
let parts = input.split(separator: " ").map(String.init)
guard parts.count >= 2 else { return nil }
guard parts[0].lowercased() == "add" else { return nil }
let title = parts.dropFirst().joined(separator: " ")
return .add(title: title)
}
Un test con un Act pulito:
import XCTest
import LibraryCLI
final class ParseAddCommandTests: XCTestCase {
func testParseAddCommand_whenValid_returnsCommand() {
// Arrange
let input = "add War and Peace"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "War and Peace"))
}
}
Qui l'Act e una sola riga. E quando il test fallira, saprai con certezza che il problema sta in parseAddCommand oppure nell'aspettativa, e non in “qualcosa a meta dello scenario”.
4. Assert: controlliamo il significato, non «come e stato fatto dentro»
La parte Assert risponde alla domanda: «qual e il risultato corretto?». E qui e molto facile iniziare a controllare cose inutili: variabili intermedie, passi interni, dettagli particolari dell'implementazione. Un principiante spesso pensa cosi: «piu controlli = piu affidabile». Nella pratica, i controlli “extra” rendono il test fragile: cambi l'implementazione senza cambiare il comportamento e i test iniziano a fallire.
«Un test — un'aspettativa principale»
Questo non significa «un solo XCTAssert… per test», perche a volte e utile verificare subito due fatti strettamente collegati. Ma il test dovrebbe verificare una sola regola di comportamento. Per esempio: «il comando valido add restituisce .add(title:)», e non «il comando valido add e anche qualcosa sugli spazi, e anche qualcosa sulla formattazione, e anche qualcosa sul logging».
I messaggi negli assert: non aver paura di aggiungere contesto
Quando i test diventano molti, i fallimenti uguali sembrano tutti uguali. Un messaggio aiuta.
XCTAssertEqual(command, .add(title: "War and Peace"), "input=\(input)")
E particolarmente utile quando esegui una serie di verifiche simili tramite helper.
5. Naming dei test: chiamiamo il test in modo che spieghi il fallimento
I nomi dei test sono la tua mini documentazione. Anzi: e una documentazione che leggi continuamente, perche l'IDE e la console del test runner mostrano proprio il nome del test fallito. Per questo un buon naming non e una questione di “bellezza”, ma uno strumento di diagnosi.
E importante ricordare che la scoperta dei test in XCTest e in gran parte legata a convenzioni e meccanismi del runtime (storicamente XCTest usa i metadati del runtime Objective-C per trovare i test case). Quindi il modo in cui li chiami influenza davvero sia cio che viene eseguito sia cio che vedi nel report.
Un'altra convenzione vive a livello di SwiftPM: i target di test di solito si trovano in Tests/ e hanno il suffisso Tests, e questa prevedibilita fa parte del modo in cui l'ecosistema si aspetta di vedere i tuoi test.
Formato pratico del nome: testSubject_whenCondition_expectedResult
In Swift i nomi dei metodi di solito si scrivono in lowerCamelCase, mentre nei test spesso si aggiungono underscore per far cogliere piu rapidamente il significato. Questo e uno dei rari casi in cui l'underscore nel nome non e un crimine contro l'umanita, ma un aiuto per il lettore.
Confrontiamo alcune varianti:
| Nome cattivo | Perche e cattivo | Nome buono | Perche e buono |
|---|---|---|---|
|
non dice nulla | |
e chiaro cosa si verifica e cosa ci si aspetta |
|
troppo generico | |
ci sono scenario e risultato |
|
non e chiaro «cosa add?» | |
si vede la condizione di errore |
L'idea e questa: il nome del test deve rispondere ad almeno due domande: che cosa stiamo testando (subject) e in quali condizioni (when/with/given). Il terzo pezzo (expected) e un bonus che spesso trasforma il nome in una specifica completa.
Given–When–Then come «voce interna» del test
A volte e utile tenere in testa Given–When–Then (e quasi la stessa cosa di AAA, solo espressa con parole). Nel test appare cosi:
| Concetto | Nel codice del test |
|---|---|
| Given (dato) | Arrange |
| When (quando) | Act |
| Then (allora) | Assert |
Non sei obbligato a scrivere queste parole nei commenti, ma se sei alle prime armi aiutano davvero a non trasformare il test in un pasticcio.
6. Pratica: contrasto, set di casi e mini helper
Contrasto: lo stesso significato, ma leggibilita diversa
Capire AAA e piu facile confrontando: stesso controllo, ma forma di scrittura diversa.
Versione 1: test senza struttura
import XCTest
import LibraryCLI
final class BadStyleTests: XCTestCase {
func testParse() {
XCTAssertEqual(parseAddCommand("add milk") , .add(title: " milk"))
}
}
In questo test ci sono subito diversi problemi. Primo: il nome non dice nulla: «parse cosa?». Secondo: non vedi a colpo d'occhio quale sia l'input e quale il risultato atteso — tutto e mescolato in una sola riga. Terzo: il risultato atteso e probabilmente sbagliato: vogliamo "milk", non " milk", ma non e ovvio.
Versione 2: lo stesso significato, ma AAA ti costringe a ragionare con cura
import XCTest
import LibraryCLI
final class ParseAddCommandWhitespaceTests: XCTestCase {
func testParseAddCommand_whenExtraSpaces_keepsTitleReadable() {
// Arrange
let input = "add milk"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"), "input=\(input)")
}
}
Anche se questo test fallisce, fallisce in modo “civile”: dal nome capisci il caso, e dal messaggio capisci quale input.
Una piccola “serie” di test attorno a una regola
Ora raccogliamo queste idee in un piccolo insieme di test attorno a una regola.
Esempio: un comando add senza titolo e un errore
import XCTest
import LibraryCLI
final class ParseAddCommandInvalidInputTests: XCTestCase {
func testParseAddCommand_whenMissingTitle_returnsNil() {
// Arrange
let input = "add"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertNil(command)
}
}
Dettaglio super importante: il test e corto. Non “dimostri” la sua importanza con la complessita. La dimostri con la chiarezza.
Esempio: il comando con un altro maiuscolo/minuscolo deve comunque funzionare
import XCTest
import LibraryCLI
final class ParseAddCommandCaseInsensitivityTests: XCTestCase {
func testParseAddCommand_whenUppercasedCommand_returnsAdd() {
// Arrange
let input = "ADD milk"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"))
}
}
Nota: anche il nome della test class e diventato “parlante”. Non e obbligatorio, ma quando il progetto cresce nomi cosi semplificano molto la navigazione.
Mini helper nei test: togliamo il copia/incolla senza scrivere una “seconda implementazione”
Il copia/incolla nei test e una cosa ambivalente. Da un lato, i test devono essere espliciti. Dall'altro, se cinque test differiscono per una sola riga di input, inizi a odiare la vita e rimandi l'aggiunta di nuovi casi. Il compromesso sono piccole funzioni helper che nascondono il rumore, ma non il significato.
Helper che aiuta la struttura, non che “ricalcola correttamente”
import XCTest
import LibraryCLI
final class NormalizeCommandHelperTests: XCTestCase {
private func assertNormalized(_ input: String, equals expected: String) {
let result = normalizeCommand(input)
XCTAssertEqual(result, expected, "input=\(input)")
}
func testNormalizeCommand_multipleInputs() {
assertNormalized(" ADD ", equals: "add")
assertNormalized(" list ", equals: "list")
}
}
Questo helper non ripete l'algoritmo di normalizzazione (non fa trim/lowercased da solo). Si limita a chiamare SUT e a fare l'assert con contesto. E importante: se l'helper comincia a calcolare da solo l'expected con lo stesso metodo, puoi ritrovarti nella situazione in cui “codice e test sbagliano allo stesso modo” — e i test restano felicemente verdi mentre l'applicazione brucia.
makeSUT() come modo per non spargere la creazione dell'oggetto
Se SUT e un tipo (per esempio un parser), conviene creare una funzione separata, in modo che Arrange resti leggibile:
import XCTest
import LibraryCLI
final class ParserTests: XCTestCase {
private func makeSUT() -> CommandParser {
CommandParser()
}
func testParser_whenValidAdd_returnsCommand() {
// Arrange
let sut = makeSUT()
let input = "add milk"
// Act
let command = sut.parse(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"))
}
}
Anche se per ora non usi setUp(), makeSUT() spesso rende i test piu ordinati. Ma mantieni il giusto equilibrio: se SUT si crea in una riga, a volte e piu semplice scriverlo direttamente in Arrange.
7. Errori tipici nell'organizzare i test con AAA e nella scelta dei nomi
Errore n. 1: il test diventa uno “scenario da mezza vita”, senza confini AAA.
E molto facile iniziare ad aggiungere al test “altre due azioni”: prima si crea un oggetto, poi si chiama un metodo, poi un secondo, poi un terzo, poi si confronta tutto. Alla fine il fallimento del test non si localizza e perdi tempo a leggere invece di diagnosticare. Si risolve con un trucco semplice: un test, una regola di comportamento, un Act principale.
Errore n. 2: i dati di input sono nascosti cosi in profondita che il test va eseguito in testa come un programma.
Quando Arrange viene sparso in setUp(), negli helper e in costanti casuali in cima al file, il test smette di essere documentazione. Diventa una mini missione. Una buona pratica per chi inizia: tenere gli input di scenario direttamente nel metodo di test sotto forma di let input = "...", anche se sembra che siano “righe in piu”.
Errore n. 3: nomi dei test come test1, testParse, testOk.
Questo naming uccide il senso dei test nel report. Quando in CI fallisce testOk, non hai voglia di correggere un bug: hai voglia di chiudere il portatile e andare nel bosco. Aiuta il modello testSubject_whenCondition_expectedResult, che trasforma il nome in una diagnosi.
Errore n. 4: Assert controlla i dettagli di implementazione e non il contratto di comportamento.
Per esempio, inizi a verificare quante volte dentro il parser viene chiamato split oppure quali token intermedi sono stati ottenuti, anche se all'esterno non si vede nulla del genere. Questi test si rompono durante il refactoring e ostacolano il miglioramento del codice. E meglio verificare il risultato finale (il valore restituito) e gli effetti osservabili importanti.
Errore n. 5: l'helper nei test diventa una “seconda implementazione dell'algoritmo”.
E la trappola classica: scrivi un helper che “calcola correttamente expected” usando lo stesso approccio del SUT. Se nell'algoritmo c'e un errore, l'helper ripetera lo stesso errore e i test resteranno verdi. Un buon helper nel test deve aiutare la struttura (etichette, creazione di SUT, XCTAssert… ripetuto), ma non deve calcolare il risultato con lo stesso metodo.
GO TO FULL VERSION