cat swift/laboratorio-client-api-service-testabile.md
Laboratorio: client API service testabile
Laboratorio del Modulo 8: rifattorizzare un client API in architettura testabile con dependency injection, fake, stub e test async.
In questo laboratorio trasformiamo un client API in una piccola architettura testabile.
Partiamo dal risultato atteso:
let product = try await loadProduct.execute(id: Product.ID("p-1"))
Chi usa il caso d’uso non conosce:
- URL;
- DTO;
URLSession;- status code;
- decoder;
- cache;
- retry.
Obiettivo del laboratorio
Costruire:
Domain/
Product.swift
Price.swift
ProductError.swift
Application/
ProductRepository.swift
LoadProductUseCase.swift
Infrastructure/
HTTPClient.swift
URLSessionHTTPClient.swift
RequestBuilder.swift
HTTPProductRepository.swift
ProductDTO.swift
ProductCache.swift
Tests/
LoadProductUseCaseTests.swift
HTTPProductRepositoryTests.swift
ProductCacheTests.swift
Il criterio di successo non è la quantità di file. È la testabilità del comportamento.
Dominio
public struct Product: Equatable, Sendable, Identifiable {
public let id: ID
public let name: String
public let price: Price
public struct ID: RawRepresentable, Equatable, Hashable, Sendable {
public let rawValue: String
public init(_ rawValue: String) {
self.rawValue = rawValue
}
public init(rawValue: String) {
self.rawValue = rawValue
}
}
}
Prezzo:
public struct Price: Equatable, Comparable, Sendable {
public let cents: Int
public init?(_ cents: Int) {
guard cents >= 0 else {
return nil
}
self.cents = cents
}
}
Errore di dominio:
public enum ProductError: Error, Equatable, Sendable {
case notFound(Product.ID)
case invalidProduct
}
Application
Protocollo:
public protocol ProductRepository: Sendable {
func product(id: Product.ID) async throws -> Product
}
Caso d’uso:
public struct LoadProductUseCase: Sendable {
private let repository: ProductRepository
public init(repository: ProductRepository) {
self.repository = repository
}
public func execute(id: Product.ID) async throws -> Product {
try await repository.product(id: id)
}
}
Per ora è sottile. Va bene. Sta creando un confine.
Infrastructure: HTTPClient
public protocol HTTPClient: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
Implementazione live:
public struct URLSessionHTTPClient: HTTPClient {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
return (data, http)
}
}
Errore tecnico:
public enum NetworkError: Error, Sendable {
case invalidURL
case invalidResponse
case statusCode(Int)
case decoding
case mapping
case transport
}
RequestBuilder
public struct RequestBuilder: Sendable {
private let baseURL: URL
public init(baseURL: URL) {
self.baseURL = baseURL
}
public func get(path: String, queryItems: [URLQueryItem] = []) throws -> URLRequest {
let url = baseURL.appending(path: path)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
throw NetworkError.invalidURL
}
components.queryItems = queryItems.isEmpty ? nil : queryItems
guard let finalURL = components.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: finalURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}
}
DTO e mapping
struct ProductDTO: Decodable, Sendable {
let id: String
let name: String
let priceCents: Int
enum CodingKeys: String, CodingKey {
case id
case name
case priceCents = "price_cents"
}
}
Mapping:
extension ProductDTO {
func toDomain() throws -> Product {
guard let price = Price(priceCents) else {
throw NetworkError.mapping
}
return Product(
id: Product.ID(id),
name: name,
price: price
)
}
}
Repository HTTP
public struct HTTPProductRepository: ProductRepository {
private let client: HTTPClient
private let requestBuilder: RequestBuilder
private let decoder: JSONDecoder
public init(
client: HTTPClient,
requestBuilder: RequestBuilder,
decoder: JSONDecoder = JSONDecoder()
) {
self.client = client
self.requestBuilder = requestBuilder
self.decoder = decoder
}
public func product(id: Product.ID) async throws -> Product {
try Task.checkCancellation()
let request = try requestBuilder.get(path: "/products/\\(id.rawValue)")
do {
let (data, response) = try await client.data(for: request)
switch response.statusCode {
case 200:
let dto = try decoder.decode(ProductDTO.self, from: data)
return try dto.toDomain()
case 404:
throw ProductError.notFound(id)
default:
throw NetworkError.statusCode(response.statusCode)
}
} catch is CancellationError {
throw CancellationError()
} catch let error as ProductError {
throw error
} catch let error as NetworkError {
throw error
} catch is DecodingError {
throw NetworkError.decoding
} catch {
throw NetworkError.transport
}
}
}
Cache actor
public actor ProductCache {
private var storage: [Product.ID: Product] = [:]
public init() {}
public func value(for id: Product.ID) -> Product? {
storage[id]
}
public func store(_ product: Product) {
storage[product.id] = product
}
}
Repository con cache:
public struct CachedProductRepository: ProductRepository {
private let base: ProductRepository
private let cache: ProductCache
public init(base: ProductRepository, cache: ProductCache) {
self.base = base
self.cache = cache
}
public func product(id: Product.ID) async throws -> Product {
if let cached = await cache.value(for: id) {
return cached
}
let product = try await base.product(id: id)
await cache.store(product)
return product
}
}
Test double
Risposta HTTP:
extension HTTPURLResponse {
static func response(statusCode: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: URL(string: "https://example.com")!,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
}
}
Stub:
struct StubHTTPClient: HTTPClient {
let data: Data
let statusCode: Int
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
(data, .response(statusCode: statusCode))
}
}
Failing:
struct FailingHTTPClient: HTTPClient {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
throw URLError(.notConnectedToInternet)
}
}
Spy:
actor SpyHTTPClient: HTTPClient {
private(set) var requests: [URLRequest] = []
let data: Data
let statusCode: Int
init(data: Data, statusCode: Int) {
self.data = data
self.statusCode = statusCode
}
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(request)
return (data, .response(statusCode: statusCode))
}
}
Test repository: successo
@Test
func repositoryRestituisceProdottoValido() async throws {
let json = """
{
"id": "p-1",
"name": "Swift Book",
"price_cents": 2990
}
""".data(using: .utf8)!
let repository = HTTPProductRepository(
client: StubHTTPClient(data: json, statusCode: 200),
requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
)
let product = try await repository.product(id: Product.ID("p-1"))
#expect(product.id == Product.ID("p-1"))
#expect(product.name == "Swift Book")
#expect(product.price == Price(2990))
}
Test repository: not found
@Test
func repositoryMappa404InNotFound() async {
let repository = HTTPProductRepository(
client: StubHTTPClient(data: Data(), statusCode: 404),
requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
)
await #expect(throws: ProductError.notFound(Product.ID("missing"))) {
try await repository.product(id: Product.ID("missing"))
}
}
Test repository: decoding
@Test
func repositoryMappaJSONInvalidoInDecoding() async {
let repository = HTTPProductRepository(
client: StubHTTPClient(data: Data("{}".utf8), statusCode: 200),
requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
)
await #expect(throws: NetworkError.decoding) {
try await repository.product(id: Product.ID("p-1"))
}
}
Test use case
struct FakeProductRepository: ProductRepository {
let result: Result<Product, Error>
func product(id: Product.ID) async throws -> Product {
try result.get()
}
}
Test:
@Test
func useCaseRestituisceProdottoDalRepository() async throws {
let expected = Product(
id: Product.ID("p-1"),
name: "Swift Book",
price: Price(2990)!
)
let useCase = LoadProductUseCase(
repository: FakeProductRepository(result: .success(expected))
)
let product = try await useCase.execute(id: Product.ID("p-1"))
#expect(product == expected)
}
Test cache
@Test
func cachedRepositoryUsaCacheDopoPrimoCaricamento() async throws {
let json = """
{
"id": "p-1",
"name": "Swift Book",
"price_cents": 2990
}
""".data(using: .utf8)!
let spy = SpyHTTPClient(data: json, statusCode: 200)
let httpRepository = HTTPProductRepository(
client: spy,
requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
)
let repository = CachedProductRepository(
base: httpRepository,
cache: ProductCache()
)
_ = try await repository.product(id: Product.ID("p-1"))
_ = try await repository.product(id: Product.ID("p-1"))
let requests = await spy.requests
#expect(requests.count == 1)
}
Criteri di completamento
Il laboratorio è completato quando:
- il caso d’uso non importa infrastruttura;
- il repository HTTP è testato senza rete;
- il mapping rifiuta dati invalidi;
- il 404 diventa errore di dominio;
- il 500 resta errore tecnico;
- la cache evita una seconda richiesta;
- la cancellazione non viene convertita in errore generico;
- i test async non usano sleep arbitrari;
- i test double hanno nomi chiari.
Estensioni possibili
Per rendere il laboratorio più realistico:
- aggiungi scadenza alla cache;
- aggiungi retry solo per status code 5xx;
- aggiungi logging come spy;
- aggiungi
SearchProductsUseCase; - separa i target SwiftPM;
- documenta i protocolli pubblici con DocC.
Questo è il passaggio chiave del corso: lo studente non sta solo consumando una API, sta costruendo codice che può cambiare senza crollare.