cat swift/testare-codice-asincrono-swift-6.md

Lezione 59 60 min

Testare codice asincrono in Swift 6

Scrivere test affidabili per async/await, Task, cancellation e actor senza dipendere dal caso o dalla rete reale.

Il codice asincrono non è difficile solo perché usa await. È difficile perché introduce tempo, ordine, cancellazione e isolamento.

Un buon test async deve controllare questi fattori il più possibile.

Test async di base

Con Swift Testing:

import Testing

@Test
func caricaProdotto() async throws {
  let repository = StubProductRepository(product: .book)
  let feature = ProductFeature(repository: repository)

  let product = try await feature.load(id: .bookID)

  #expect(product == .book)
}

Il test può essere async throws. Non serve aspettare callback o usare semafori.

Non testare la rete reale

Un test unitario non dovrebbe dipendere da:

  • connessione internet;
  • backend disponibile;
  • token validi;
  • latenza;
  • ordine delle risposte;
  • dati reali mutevoli.

Questa dipendenza rende il test lento e intermittente.

Meglio iniettare un client:

protocol HTTPClient: Sendable {
  func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

Stub:

struct StubHTTPClient: HTTPClient {
  let data: Data
  let response: HTTPURLResponse

  func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
    (data, response)
  }
}

Testare ordine e parallelismo

Non tutti i test devono verificare il parallelismo. Spesso basta verificare il risultato.

Quando l’ordine conta, rendilo esplicito.

actor RecordingClient: HTTPClient {
  private(set) var requestedPaths: [String] = []
  let responses: [String: Data]

  init(responses: [String: Data]) {
    self.responses = responses
  }

  func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
    requestedPaths.append(request.url?.path ?? "")

    let data = responses[request.url?.path ?? ""] ?? Data()
    return (data, HTTPURLResponse.ok)
  }
}

Poi:

let paths = await client.requestedPaths
#expect(paths.contains("/products/p-1"))

Se il codice usa TaskGroup, non assumere l’ordine delle risposte a meno che l’API lo prometta.

Testare cancellation

La cancellazione e comportamento, non dettaglio.

Esempio di servizio:

struct ProductLoader {
  let repository: ProductRepository

  func load(ids: [Product.ID]) async throws -> [Product] {
    try await withThrowingTaskGroup(of: Product.self) { group in
      for id in ids {
        group.addTask {
          try Task.checkCancellation()
          return try await repository.product(id: id)
        }
      }

      var products: [Product] = []
      for try await product in group {
        products.append(product)
      }
      return products
    }
  }
}

Test concettuale:

@Test
func loadPropagaCancellation() async {
  let loader = ProductLoader(repository: SlowProductRepository())

  let task = Task {
    try await loader.load(ids: [.bookID, .keyboardID])
  }

  task.cancel()

  await #expect(throws: CancellationError.self) {
    try await task.value
  }
}

La parte importante: il codice deve cooperare con la cancellazione usando Task.checkCancellation() o chiamate async cancellabili.

Evitare sleep fragili

Questo è fragile:

try await Task.sleep(for: .milliseconds(100))
#expect(await cache.count == 1)

Il test passa o fallisce in base alla macchina.

Meglio usare una dipendenza controllabile:

protocol Sleeper: Sendable {
  func sleep(for duration: Duration) async throws
}

Nel codice reale:

struct TaskSleeper: Sleeper {
  func sleep(for duration: Duration) async throws {
    try await Task.sleep(for: duration)
  }
}

Nel test:

actor ImmediateSleeper: Sleeper {
  private(set) var durations: [Duration] = []

  func sleep(for duration: Duration) async throws {
    durations.append(duration)
  }
}

Ora puoi testare retry e backoff senza aspettare davvero.

Testare actor

Gli actor isolano stato. I test devono rispettare await.

actor ProductCache {
  private var storage: [Product.ID: Product] = [:]

  func value(for id: Product.ID) -> Product? {
    storage[id]
  }

  func store(_ product: Product) {
    storage[product.id] = product
  }
}

Test:

@Test
func cacheRestituisceValoreSalvato() async {
  let cache = ProductCache()

  await cache.store(.book)

  let product = await cache.value(for: .bookID)
  #expect(product == .book)
}

Non forzare l’actor a comportarsi come una classe sincrona. Il test deve riflettere il modello di isolamento.

MainActor nei test

Se un tipo è isolato al MainActor, il test deve esserlo:

@MainActor
@Test
func viewModelAggiornaStato() async throws {
  let viewModel = ProductViewModel(repository: StubProductRepository(product: .book))

  await viewModel.load(id: .bookID)

  #expect(viewModel.state == .loaded(.book))
}

Questo evita warning e comportamenti ambigui.

Testare AsyncSequence

Per flussi di eventi, non aspettare “per un po’”. Consuma un numero noto di valori.

@Test
func streamEmetteStatiInOrdine() async throws {
  let stream = ProductStatusStream(values: [
    .loading,
    .loaded(.book)
  ])

  var iterator = stream.makeAsyncIterator()

  let first = await iterator.next()
  let second = await iterator.next()

  #expect(first == .loading)
  #expect(second == .loaded(.book))
}

Il test deve sapere quando fermarsi.

Errori comuni

Evita:

  • Task { } dentro il test senza aspettarne il risultato;
  • sleep arbitrari;
  • rete reale;
  • clock reale;
  • stato globale condiviso;
  • classi mock mutabili non isolate;
  • verifiche sull’ordine quando il codice è intenzionalmente concorrente;
  • catturare self in task nei test senza capire isolamento.

Checklist

Un test async solido:

  • usa async throws;
  • controlla le dipendenze esterne;
  • non aspetta tempo reale se non necessario;
  • verifica cancellation quando il caso d’uso la promette;
  • usa actor per stato mutabile condiviso;
  • marca @MainActor quando testa UI state;
  • controlla risultati, errori e stati osservabili;
  • non lascia task pendenti.

Esercizio

Prendi il retry del Modulo 7.

Scrivi test per:

  1. successo al primo tentativo;
  2. successo dopo due fallimenti;
  3. errore finale dopo esaurimento tentativi;
  4. cancellazione prima del tentativo successivo;
  5. backoff registrato senza dormire davvero.

Obiettivo: nessun test deve usare Task.sleep con durata reale superiore a pochi millisecondi.