cat swift/mock-fake-stub-test-double.md

Lezione 58 50 min

Mock, fake e stub: test double con intenzione

Distinguere i principali test double in Swift e usarli per verificare comportamento senza rendere i test fragili.

Nei test si usa spesso la parola “mock” per indicare qualunque cosa finta. Ma fake, stub e mock non hanno lo stesso scopo.

La distinzione è utile per scrivere test più chiari.

Stub

Uno stub restituisce dati preparati.

struct StubProductRepository: ProductRepository {
  let product: Product

  func product(id: Product.ID) async throws -> Product {
    product
  }
}

Lo usi quando il test non vuole verificare la dipendenza, ma solo dare al sistema una risposta controllata.

Esempio:

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

  let card = try await feature.card(for: .bookID)

  #expect(card.title == "Swift in Practice")
}

Lo stub risponde. Non giudica.

Fake

Un fake è una implementazione semplificata ma funzionante.

actor InMemoryProductRepository: ProductRepository {
  private var products: [Product.ID: Product]

  init(products: [Product]) {
    self.products = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  }

  func product(id: Product.ID) async throws -> Product {
    guard let product = products[id] else {
      throw ProductError.notFound(id)
    }

    return product
  }

  func save(_ product: Product) async {
    products[product.id] = product
  }
}

Questo fake conserva stato e simula un repository reale, ma senza database.

Lo usi quando vuoi testare flussi più lunghi:

  • creazione;
  • lettura;
  • aggiornamento;
  • cache;
  • scenari multi-step.

In Swift 6, un fake con stato condiviso può essere un actor, così resta sicuro nei test async.

Mock

Un mock verifica interazioni.

actor MockAnalytics: AnalyticsClient {
  private(set) var events: [AnalyticsEvent] = []

  func track(_ event: AnalyticsEvent) {
    events.append(event)
  }
}

Test:

@Test
func tracciaEventoQuandoProdottoVieneAperto() async throws {
  let analytics = MockAnalytics()
  let feature = ProductFeature(
    repository: StubProductRepository(product: .book),
    analytics: analytics
  )

  _ = try await feature.openProduct(id: .bookID)

  let events = await analytics.events
  #expect(events == [.productOpened(id: .bookID)])
}

Qui il comportamento atteso è proprio l’interazione con analytics.

Spy

Uno spy registra ciò che succede, ma spesso non ha aspettative incorporate.

actor SpyHTTPClient: HTTPClient {
  private(set) var requests: [URLRequest] = []
  let response: (Data, HTTPURLResponse)

  init(response: (Data, HTTPURLResponse)) {
    self.response = response
  }

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

Dopo l’esecuzione, il test ispeziona:

let requests = await client.requests
#expect(requests.first?.httpMethod == "GET")

Spy e mock sono vicini. La differenza pratica: lo spy osserva, il mock spesso codifica una aspettativa.

Quando verificare interazioni

Verificare ogni chiamata interna rende i test fragili.

Meglio verificare output:

#expect(order.total == Money.eur(90))

Verifica interazioni quando l’interazione è il comportamento:

  • inviare analytics;
  • salvare in cache;
  • chiamare logout;
  • invalidare token;
  • schedulare una notifica;
  • scrivere un file;
  • pubblicare un evento.

Non verificare interazioni solo perché puoi.

Errori controllati

Uno stub può anche fallire:

struct FailingProductRepository: ProductRepository {
  let error: Error

  func product(id: Product.ID) async throws -> Product {
    throw error
  }
}

Oppure con un enum più leggibile:

enum RepositoryBehavior {
  case success(Product)
  case failure(Error)
}

struct ControlledProductRepository: ProductRepository {
  let behavior: RepositoryBehavior

  func product(id: Product.ID) async throws -> Product {
    switch behavior {
    case .success(let product):
      return product
    case .failure(let error):
      throw error
    }
  }
}

Questo rende il test esplicito.

Test double e Sendable

In Swift 6, i test double usati da codice concorrente devono essere progettati bene.

Per test double senza stato:

struct StubClock: Sendable {
  let now: Date
}

Per test double con stato mutabile:

actor MockLogger: Logger {
  private(set) var messages: [String] = []

  func log(_ message: String) {
    messages.append(message)
  }
}

Un class mutabile condiviso tra task può generare problemi di data race. Un actor spesso è la scelta più semplice.

Evitare test double onnipotenti

Questo è un cattivo segnale:

final class MegaMockService {
  var productResult: Result<Product, Error>?
  var searchResult: Result<[Product], Error>?
  var saveResult: Result<Void, Error>?
  var deleteResult: Result<Void, Error>?
  var calledMethods: [String] = []
}

Troppa configurazione rende ogni test un piccolo programma.

Meglio creare test double piccoli e mirati:

struct ProductFoundRepository: ProductRepository { ... }
struct ProductNotFoundRepository: ProductRepository { ... }
actor SpyCache: ProductCache { ... }

Il nome del test double deve aiutare a leggere il test.

Checklist

Scegli:

  • stub quando ti serve una risposta fissa;
  • fake quando ti serve comportamento semplificato ma reale;
  • mock quando devi verificare una interazione;
  • spy quando vuoi registrare e ispezionare;
  • actor quando il test double conserva stato in test async;
  • protocolli piccoli per evitare test double enormi.

Esercizio

Sul client API del Modulo 7, crea:

  1. uno StubHTTPClient che restituisce JSON valido;
  2. uno FailingHTTPClient che lancia errore di trasporto;
  3. uno SpyCache che registra salvataggi e letture;
  4. un InMemoryProductRepository per testare casi d’uso senza HTTP.

Poi scrivi tre test:

  • prodotto decodificato correttamente;
  • errore di rete mappato;
  • cache aggiornata dopo risposta valida.