cat swift/testare-codice-asincrono-swift-6.md
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;sleeparbitrari;- rete reale;
- clock reale;
- stato globale condiviso;
- classi mock mutabili non isolate;
- verifiche sull’ordine quando il codice è intenzionalmente concorrente;
- catturare
selfin 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
@MainActorquando testa UI state; - controlla risultati, errori e stati osservabili;
- non lascia task pendenti.
Esercizio
Prendi il retry del Modulo 7.
Scrivi test per:
- successo al primo tentativo;
- successo dopo due fallimenti;
- errore finale dopo esaurimento tentativi;
- cancellazione prima del tentativo successivo;
- backoff registrato senza dormire davvero.
Obiettivo: nessun test deve usare Task.sleep con durata reale superiore a pochi millisecondi.