cat swift/testare-errori-edge-case-contratti.md
Testare errori, edge case e contratti
Progettare test che verificano fallimenti, invarianti e contratti pubblici invece di limitarsi al percorso felice.
Un sistema non è testato davvero finché hai verificato solo il percorso felice.
Nel codice Swift professionale devi testare:
- errori attesi;
- input limite;
- stati impossibili;
- conversioni fallite;
- contratti pubblici;
- regressioni su bug già scoperti.
Errori come comportamento
Se un errore è parte dell’API, va testato.
enum CartError: Error, Equatable {
case emptyCart
case invalidQuantity
case productUnavailable(Product.ID)
}
Test:
@Test
func checkoutDiCarrelloVuotoFallisce() async {
let checkout = CheckoutService(repository: EmptyCartRepository())
await #expect(throws: CartError.emptyCart) {
try await checkout.pay()
}
}
Il test non dice solo “fallisce”. Dice come fallisce.
Errori non Equatable
Non tutti gli errori sono Equatable, specialmente quando contengono un errore sottostante.
Puoi ispezionare con do/catch:
@Test
func statusCode404DiventaNotFound() async throws {
let client = StubHTTPClient(statusCode: 404, data: Data())
let repository = HTTPProductRepository(client: client)
do {
_ = try await repository.product(id: "missing")
Issue.record("Expected notFound error")
} catch ProductRepositoryError.notFound(let id) {
#expect(id == "missing")
} catch {
Issue.record("Unexpected error: \\(error)")
}
}
Il test resta preciso anche se l’errore non è confrontabile direttamente.
Testare invarianti
Un’invariante è una regola che deve essere sempre vera.
Esempio:
struct Quantity: Equatable {
let value: Int
init?(_ value: Int) {
guard value > 0 else {
return nil
}
self.value = value
}
}
Test:
@Test(arguments: [-10, -1, 0])
func quantitaNonAccettaValoriNonPositivi(value: Int) {
#expect(Quantity(value) == nil)
}
@Test(arguments: [1, 2, 10])
func quantitaAccettaValoriPositivi(value: Int) {
#expect(Quantity(value)?.value == value)
}
I test parametrici sono perfetti per bordi e invarianti.
Edge case reali
Gli edge case non sono solo numeri strani.
Per un client API:
204 No Content;- JSON vuoto;
- array vuoto;
- campo mancante;
- campo
null; - status code 429;
- risposta 200 con payload errore;
- timeout;
- cancellazione;
- data scaduta in cache;
- clock intorno alla mezzanotte;
- stringhe con emoji o caratteri composti;
- paginazione senza
next.
Il corso deve insegnare a cercare questi casi nel dominio, non a copiarli da una lista.
Contratti pubblici
Un contratto pubblico è ciò che chi usa il tuo codice può aspettarsi.
Esempio:
protocol ProductRepository {
func product(id: Product.ID) async throws -> Product
}
Domande di contratto:
- cosa succede se l’id non esiste?
- l’errore è stabile?
- il risultato è sempre nel dominio?
- il metodo può restituire prodotti disabilitati?
- la cancellazione viene rispettata?
- il metodo usa cache o sempre rete?
I test devono proteggere queste promesse.
Test di mapping
Il mapping da DTO a dominio è un punto ad alto rischio.
@Test
func productDTORifiutaPrezzoNegativo() throws {
let dto = ProductDTO(
id: "p-1",
name: "Book",
priceCents: -100,
category: "books"
)
#expect(throws: MappingError.invalidPrice) {
try dto.toDomain()
}
}
Qui il test evita che un dato esterno sporco entri nel dominio.
Test di decoding
Usa JSON piccoli ma realistici:
let json = """
{
"id": "p-1",
"name": "Swift Book",
"price_cents": 2990,
"category": "books"
}
""".data(using: .utf8)!
Test:
@Test
func decodificaProductDTO() throws {
let dto = try JSONDecoder().decode(ProductDTO.self, from: json)
#expect(dto.id == "p-1")
#expect(dto.priceCents == 2990)
}
Non testare JSONDecoder in se. Testa la tua configurazione, le tue CodingKeys, le tue assunzioni.
Regression test
Quando trovi un bug, prima o dopo la correzione crea un test che lo rappresenti.
Esempio:
Bug: il client trattava 304 Not Modified come errore generico.
Test: 304 con cache valida restituisce valore cached.
Un regression test è memoria tecnica. Evita che la stessa ferita si riapra tra tre mesi.
Evitare test troppo speculari
Questo test non protegge molto:
#expect(price.total == price.subtotal + price.tax)
Se il test ricopia la stessa formula dell’implementazione senza caso significativo, può passare anche quando il comportamento è concettualmente sbagliato.
Meglio usare esempi concreti:
#expect(invoice.total == Money.eur(122)) // 100 + IVA 22%
Checklist
Per ogni feature chiedi:
- qual è il percorso felice?
- quali input devono essere rifiutati?
- quali dati esterni sono sporchi?
- quali errori fanno parte dell’API?
- quali errori devono restare dettagli interni?
- quali invarianti non devono mai rompersi?
- quale bug passato merita un regression test?
- quali contratti pubblici devono restare stabili?
Esercizio
Sul repository prodotti, scrivi test per:
- prodotto trovato;
- prodotto mancante;
- status code 500;
- JSON con prezzo negativo;
- categoria sconosciuta;
- cancellazione;
- cache scaduta;
- risposta vuota.
Obiettivo: ogni test deve avere un nome che descrive il comportamento, non il metodo interno chiamato.