cat swift/laboratorio-client-api-service-testabile.md

Lezione 63 95 min

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.