cat swift/refactor-api-service-testabile.md
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:
- prendi un service con
URLSession.shared; - estrai
HTTPClient; - aggiungi
RequestBuilder; - sposta DTO e mapping in infrastruttura;
- crea
ProductRepository; - crea
LoadProductUseCase; - scrivi test repository con
StubHTTPClient; - scrivi test use case con fake repository.
Obiettivo: nessun test del caso d’uso deve costruire una URL.