cat swift/testare-errori-edge-case-contratti.md

Lezione 6055 min

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:

  1. prodotto trovato;
  2. prodotto mancante;
  3. status code 500;
  4. JSON con prezzo negativo;
  5. categoria sconosciuta;
  6. cancellazione;
  7. cache scaduta;
  8. risposta vuota.

Obiettivo: ogni test deve avere un nome che descrive il comportamento, non il metodo interno chiamato.