cat swift/refactor-api-service-testabile.md

Lezione 62 65 min

Refactor di un API service fragile

Prendere un service Swift funzionante ma fragile e trasformarlo in codice modulare, testabile e compatibile con concurrency moderna.

In questa lezione partiamo da codice realistico: funziona, ma è difficile da testare.

final class ProductAPIService {
  func loadProduct(id: String) async throws -> Product {
    let url = URL(string: "https://api.shop.dev/products/\\(id)")!
    var request = URLRequest(url: url)
    request.httpMethod = "GET"
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let http = response as? HTTPURLResponse else {
      throw APIError.invalidResponse
    }

    guard http.statusCode == 200 else {
      throw APIError.statusCode(http.statusCode)
    }

    let dto = try JSONDecoder().decode(ProductDTO.self, from: data)

    return Product(
      id: dto.id,
      name: dto.name,
      price: Price(dto.priceCents)!
    )
  }
}

Problemi:

  • URLSession.shared è nascosta;
  • base URL hardcoded;
  • decoder creato dentro;
  • mapping forzato con !;
  • errori tecnici e dominio mischiati;
  • impossibile testare senza rete o hack;
  • nessuna gestione di cancellazione esplicita;
  • classe reference senza reale bisogno.

Passo 1: estrarre HTTPClient

protocol HTTPClient: Sendable {
  func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

Adattatore reale:

struct URLSessionHTTPClient: HTTPClient {
  let session: URLSession

  init(session: URLSession = .shared) {
    self.session = session
  }

  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)
  }
}

Ora il service non dipende direttamente da URLSession.

Passo 2: introdurre Endpoint

struct Endpoint: Sendable {
  let path: String
  let queryItems: [URLQueryItem]

  init(path: String, queryItems: [URLQueryItem] = []) {
    self.path = path
    self.queryItems = queryItems
  }
}

Request builder:

struct RequestBuilder: Sendable {
  let baseURL: URL

  func request(for endpoint: Endpoint) throws -> URLRequest {
    let url = baseURL.appending(path: endpoint.path)

    guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
      throw NetworkError.invalidURL
    }

    components.queryItems = endpoint.queryItems.isEmpty ? nil : endpoint.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
  }
}

Il modo in cui si costruisce una richiesta diventa testabile separatamente.

Passo 3: separare 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 sicuro:

extension ProductDTO {
  func toDomain() throws -> Product {
    guard let price = Price(priceCents) else {
      throw MappingError.invalidPrice
    }

    return Product(id: id, name: name, price: price)
  }
}

Il ! sparisce. Il dato esterno viene validato.

Passo 4: introdurre repository

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

Implementazione HTTP:

struct HTTPProductRepository: ProductRepository {
  private let client: HTTPClient
  private let requestBuilder: RequestBuilder
  private let decoder: JSONDecoder

  init(
    client: HTTPClient,
    requestBuilder: RequestBuilder,
    decoder: JSONDecoder
  ) {
    self.client = client
    self.requestBuilder = requestBuilder
    self.decoder = decoder
  }

  func product(id: Product.ID) async throws -> Product {
    try Task.checkCancellation()

    let request = try requestBuilder.request(
      for: Endpoint(path: "/products/\\(id.rawValue)")
    )

    let (data, response) = try await client.data(for: request)

    guard response.statusCode == 200 else {
      throw ProductRepositoryError.statusCode(response.statusCode)
    }

    let dto = try decoder.decode(ProductDTO.self, from: data)
    return try dto.toDomain()
  }
}

Passo 5: caso d’uso

Il caso d’uso esprime comportamento applicativo.

struct LoadProductUseCase: Sendable {
  private let repository: ProductRepository

  init(repository: ProductRepository) {
    self.repository = repository
  }

  func execute(id: Product.ID) async throws -> ProductDetails {
    let product = try await repository.product(id: id)

    return ProductDetails(
      title: product.name,
      formattedPrice: product.price.formatted()
    )
  }
}

Qui non appare HTTP.

Passo 6: testare senza rete

Stub client:

struct StubHTTPClient: HTTPClient {
  let data: Data
  let response: HTTPURLResponse

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

Test repository:

@Test
func repositoryDecodificaProdotto() async throws {
  let json = """
  {
    "id": "p-1",
    "name": "Swift Book",
    "price_cents": 2990
  }
  """.data(using: .utf8)!

  let repository = HTTPProductRepository(
    client: StubHTTPClient(data: json, response: .ok),
    requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!),
    decoder: JSONDecoder()
  )

  let product = try await repository.product(id: Product.ID("p-1"))

  #expect(product.name == "Swift Book")
  #expect(product.price == Price(2990))
}

Passo 7: testare il caso d’uso

Fake repository:

struct FakeProductRepository: ProductRepository {
  let product: Product

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

Test:

@Test
func useCaseFormattaDettagliProdotto() async throws {
  let useCase = LoadProductUseCase(
    repository: FakeProductRepository(product: .book)
  )

  let details = try await useCase.execute(id: .bookID)

  #expect(details.title == "Swift Book")
  #expect(details.formattedPrice == "29,90 EUR")
}

Questo test non sa nulla di JSON.

Risultato del refactor

Prima:

ProductAPIService
  URLSession.shared
  URL hardcoded
  JSONDecoder
  DTO
  mapping
  dominio

Dopo:

LoadProductUseCase
  -> ProductRepository protocol
       -> HTTPProductRepository
            -> HTTPClient
            -> RequestBuilder
            -> ProductDTO.toDomain()

Il codice è più lungo, ma ogni pezzo ha un motivo.

Quando fermarsi

Non separare all’infinito.

Fermati quando:

  • puoi testare le regole senza infrastruttura;
  • puoi sostituire rete/cache nei test;
  • gli errori sono comprensibili;
  • il mapping è isolato;
  • il codice pubblico è leggibile;
  • aggiungere un caso non richiede modificare cinque strati inutili.

Checklist

Nel refactor di un service chiedi:

  • quali dipendenze sono nascoste?
  • quali decisioni tecniche posso iniettare?
  • il DTO entra nel dominio?
  • ci sono force unwrap evitabili?
  • quale protocollo serve davvero?
  • quali errori deve vedere il chiamante?
  • come testo successo, fallimento e cancellazione?

Esercizio

Refactor guidato:

  1. prendi un service con URLSession.shared;
  2. estrai HTTPClient;
  3. aggiungi RequestBuilder;
  4. sposta DTO e mapping in infrastruttura;
  5. crea ProductRepository;
  6. crea LoadProductUseCase;
  7. scrivi test repository con StubHTTPClient;
  8. scrivi test use case con fake repository.

Obiettivo: nessun test del caso d’uso deve costruire una URL.