cat swift/mock-fake-stub-test-double.md
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:
- uno
StubHTTPClientche restituisce JSON valido; - uno
FailingHTTPClientche lancia errore di trasporto; - uno
SpyCacheche registra salvataggi e letture; - un
InMemoryProductRepositoryper testare casi d’uso senza HTTP.
Poi scrivi tre test:
- prodotto decodificato correttamente;
- errore di rete mappato;
- cache aggiornata dopo risposta valida.