Building a Testable Network Layer with Alamofire

A protocol-oriented approach to HTTP networking in Swift using async/await, dependency injection, and proper error handling

Building a Testable Network Layer - Protocol-oriented HTTP networking

Building a Testable Network Layer with Alamofire

A well-designed network layer is foundational to any iOS application that communicates with a backend API. This article demonstrates how to build a reusable, testable HTTP client using Alamofire, Swift Concurrency, and protocol-oriented design.

Design Goals

Before writing code, let’s establish what we’re building toward:

  1. Async/await first — Native Swift Concurrency for clean, readable code
  2. Protocol-based abstraction — Enable dependency injection and unit testing
  3. Centralized authentication — Automatic JWT attachment via request interceptors
  4. Type-safe requests — Generic methods with Codable constraints
  5. Single import boundary — Alamofire isolated to one file for easy replacement

The Protocol

Start with a protocol that defines the contract for any HTTP client implementation:

import Foundation

protocol HTTPClient: Sendable {
    func get<T: Decodable>(_ endpoint: String) async throws -> T
    func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
    func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
    func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T
    func delete<T: Decodable>(_ endpoint: String) async throws -> T
}

This protocol allows you to:

  • Swap implementations (Alamofire, URLSession, mock client)
  • Inject dependencies into view models
  • Write unit tests with fake network responses

The Implementation

Here’s the Alamofire-backed implementation:

import Alamofire
import Foundation

final class NetworkClient: HTTPClient, @unchecked Sendable {
    private let baseURL: URL
    private let session: Session
    private let tokenProvider: TokenProvider

    init(
        baseURL: URL,
        tokenProvider: TokenProvider,
        session: Session = .default
    ) {
        self.baseURL = baseURL
        self.tokenProvider = tokenProvider
        self.session = session
    }

    func get<T: Decodable>(_ endpoint: String) async throws -> T {
        try await request(endpoint, method: .get)
    }

    func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        try await request(endpoint, method: .post, body: body)
    }

    func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        try await request(endpoint, method: .put, body: body)
    }

    func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        try await request(endpoint, method: .patch, body: body)
    }

    func delete<T: Decodable>(_ endpoint: String) async throws -> T {
        try await request(endpoint, method: .delete)
    }

    private func request<T: Decodable>(
        _ endpoint: String,
        method: HTTPMethod,
        body: (any Encodable)? = nil
    ) async throws -> T {
        let url = baseURL.appendingPathComponent(endpoint)

        let task = session.request(
            url,
            method: method,
            parameters: body.map { AnyEncodable($0) },
            encoder: JSONParameterEncoder.default,
            interceptor: AuthInterceptor(tokenProvider: tokenProvider)
        )
        .validate()
        .serializingDecodable(T.self)

        let response = await task.response

        switch response.result {
        case .success(let value):
            return value
        case .failure(let error):
            throw NetworkError(afError: error, response: response.response)
        }
    }
}

Authentication Interceptor

The interceptor handles JWT attachment automatically for every request:

import Alamofire
import Foundation

protocol TokenProvider: Sendable {
    func currentToken() async -> String?
}

final class KeychainTokenProvider: TokenProvider {
    func currentToken() async -> String? {
        // In production, use Keychain instead of UserDefaults
        UserDefaults.standard.string(forKey: "auth_token")
    }
}

final class AuthInterceptor: RequestInterceptor {
    private let tokenProvider: TokenProvider

    init(tokenProvider: TokenProvider) {
        self.tokenProvider = tokenProvider
    }

    func adapt(
        _ urlRequest: URLRequest,
        for session: Session,
        completion: @escaping (Result<URLRequest, Error>) -> Void
    ) {
        Task {
            var request = urlRequest
            if let token = await tokenProvider.currentToken() {
                request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
            }
            completion(.success(request))
        }
    }
}

Note: For production apps, store tokens in the Keychain using a library like KeychainAccess, not UserDefaults.

Error Handling

Wrap Alamofire errors into a domain-specific error type:

import Alamofire
import Foundation

enum NetworkError: Error, LocalizedError {
    case unauthorized
    case notFound
    case serverError(statusCode: Int)
    case decodingFailed(Error)
    case networkUnavailable
    case unknown(Error)

    init(afError: AFError, response: HTTPURLResponse?) {
        switch response?.statusCode {
        case 401:
            self = .unauthorized
        case 404:
            self = .notFound
        case 500...599:
            self = .serverError(statusCode: response?.statusCode ?? 500)
        default:
            if case .responseSerializationFailed(let reason) = afError,
               case .decodingFailed(let decodingError) = reason {
                self = .decodingFailed(decodingError)
            } else if case .sessionTaskFailed(let urlError as URLError) = afError,
                      urlError.code == .notConnectedToInternet {
                self = .networkUnavailable
            } else {
                self = .unknown(afError)
            }
        }
    }

    var errorDescription: String? {
        switch self {
        case .unauthorized:
            return "Your session has expired. Please sign in again."
        case .notFound:
            return "The requested resource was not found."
        case .serverError(let code):
            return "Server error (\(code)). Please try again later."
        case .decodingFailed:
            return "Failed to process the server response."
        case .networkUnavailable:
            return "No internet connection. Please check your network settings."
        case .unknown:
            return "An unexpected error occurred."
        }
    }
}

Type Erasure Helper

For encoding generic request bodies:

struct AnyEncodable: Encodable {
    private let encode: (Encoder) throws -> Void

    init<T: Encodable>(_ value: T) {
        encode = value.encode
    }

    func encode(to encoder: Encoder) throws {
        try encode(encoder)
    }
}

Usage in SwiftUI

With the protocol-based design, view models accept the client as a dependency:

import SwiftUI

struct Item: Codable, Identifiable {
    let id: String
    let name: String
}

@MainActor
final class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    @Published private(set) var error: NetworkError?
    @Published private(set) var isLoading = false

    private let client: HTTPClient

    init(client: HTTPClient) {
        self.client = client
    }

    func loadItems() async {
        isLoading = true
        error = nil

        do {
            items = try await client.get("/items")
        } catch let networkError as NetworkError {
            error = networkError
        } catch {
            self.error = .unknown(error)
        }

        isLoading = false
    }
}

struct ItemListView: View {
    @StateObject private var viewModel: ItemListViewModel

    init(client: HTTPClient) {
        _viewModel = StateObject(wrappedValue: ItemListViewModel(client: client))
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else if let error = viewModel.error {
                    ContentUnavailableView(
                        "Unable to Load",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error.localizedDescription)
                    )
                } else {
                    List(viewModel.items) { item in
                        Text(item.name)
                    }
                }
            }
            .navigationTitle("Items")
            .task {
                await viewModel.loadItems()
            }
        }
    }
}

Testing

The protocol abstraction makes unit testing straightforward:

final class MockHTTPClient: HTTPClient {
    var getResult: Any?
    var shouldThrow: NetworkError?

    func get<T: Decodable>(_ endpoint: String) async throws -> T {
        if let error = shouldThrow { throw error }
        guard let result = getResult as? T else {
            fatalError("Mock not configured for type \(T.self)")
        }
        return result
    }

    func post<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        fatalError("Not implemented")
    }

    func put<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        fatalError("Not implemented")
    }

    func patch<T: Decodable, U: Encodable>(_ endpoint: String, body: U) async throws -> T {
        fatalError("Not implemented")
    }

    func delete<T: Decodable>(_ endpoint: String) async throws -> T {
        fatalError("Not implemented")
    }
}

@Test func loadItems_success() async {
    let mockClient = MockHTTPClient()
    mockClient.getResult = [Item(id: "1", name: "Test")]

    let viewModel = await ItemListViewModel(client: mockClient)
    await viewModel.loadItems()

    #expect(await viewModel.items.count == 1)
    #expect(await viewModel.error == nil)
}

@Test func loadItems_networkError() async {
    let mockClient = MockHTTPClient()
    mockClient.shouldThrow = .networkUnavailable

    let viewModel = await ItemListViewModel(client: mockClient)
    await viewModel.loadItems()

    #expect(await viewModel.items.isEmpty)
    #expect(await viewModel.error == .networkUnavailable)
}

Dependency Setup

Configure the client at app launch:

import SwiftUI

@main
struct MyApp: App {
    private let httpClient: HTTPClient

    init() {
        guard let baseURL = URL(string: Configuration.apiBaseURL) else {
            fatalError("Invalid API base URL")
        }

        httpClient = NetworkClient(
            baseURL: baseURL,
            tokenProvider: KeychainTokenProvider()
        )
    }

    var body: some Scene {
        WindowGroup {
            ItemListView(client: httpClient)
        }
    }
}

Further Considerations

This implementation covers the essentials, but production apps may also need:

  • Token refresh — Implement RequestRetrier to handle 401 responses and refresh expired tokens
  • Request cancellation — Store and cancel in-flight Task instances when views disappear
  • Retry logic — Add exponential backoff for transient failures
  • Request/response logging — Use Alamofire’s EventMonitor for debugging
  • Offline support — Cache responses and queue requests when offline

The protocol-oriented foundation makes adding these features straightforward without changing calling code.

Related posts

View all posts
Building a Testable Network Layer with Alamofire

Building a Testable Network Layer with Alamofire

A protocol-oriented approach to HTTP networking in Swift using async/await, dependency injection, and proper error handling

Decodable SwiftData

Decodable SwiftData

A proof-of-concept for building offline-first iOS apps with Codable conforming models

Realtime SwiftUI + Vapor

Realtime SwiftUI + Vapor

Build a real-time chatroom with Vapor and SwiftUI using WebSockets