Skip to main content
  1. Blog/
  2. Swift/

Mock HTTP Requests of URLSession Using Swift on iOS/macOS

Testing networking code has always been a challenge. We don’t want unit tests hitting real APIs — they’d be slow and dependent on external services. The solution? URLProtocol — Apple’s built-in mechanism for intercepting and stubbing HTTP requests.

In this article, we’ll learn how to use URLProtocol to mock HTTP requests in Swift, enabling us to write fast, reliable unit tests for the networking layer.

Why Mock HTTP Requests?
#

Before diving into the implementation, let’s understand why mocking network requests is essential:

  1. Speed — Real network calls are slow. Mocked responses are instant.
  2. Reliability — Tests won’t fail because a server is down or slow.
  3. Isolation — Test client side code’s behavior, not the backend’s.
  4. Edge Cases — Easily simulate errors, timeouts, and unusual responses.
  5. Offline Development — Work without an internet connection or backend availability.
  6. Deterministic Results — Same input always produces the same output.

Understanding URLProtocol
#

URLProtocol is an abstract class that handles the loading of protocol-specific URL data. When we register a custom URLProtocol subclass, it intercepts URL requests before they reach the network. This gives us complete control over what response to return.

Here’s the flow:

URLSession Request → URLProtocol (Intercepted) → Return Mocked Response

Instead of the request going to the actual server, URLProtocol subclass intercepts it and returns whatever response we configure.

Creating a Mock URLProtocol
#

Let’s build a reusable MockURLProtocol class that we can use across unit tests.

Step 1: Subclass URLProtocol
#

import Foundation

final class MockURLProtocol: URLProtocol {

    // Dictionary mapping URL patterns to mock responses
    static var mockResponses: [String: MockResponse] = [:]

    // Fallback response when no specific mock is configured
    static var defaultResponse: MockResponse?

    // Determines if this protocol can handle the given request
    override class func canInit(with request: URLRequest) -> Bool {
        // Handle all HTTP/HTTPS requests
        return true
    }

    // Returns a canonical version of the request
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    // Start loading the request (this is where we return our mock)
    override func startLoading() {
        guard let url = request.url else {
            client?.urlProtocol(self, didFailWithError: MockError.invalidURL)
            return
        }

        // Find matching mock response
        let mockResponse = Self.mockResponses[url.absoluteString] ?? Self.defaultResponse

        guard let response = mockResponse else {
            client?.urlProtocol(self, didFailWithError: MockError.noMockFound)
            return
        }

        // Simulate network delay if specified
        if response.delay > 0 {
            Thread.sleep(forTimeInterval: response.delay)
        }

        // Return error if configured
        if let error = response.error {
            client?.urlProtocol(self, didFailWithError: error)
            client?.urlProtocolDidFinishLoading(self)
            return
        }

        // Create and return the HTTP response
        if let httpResponse = HTTPURLResponse(
            url: url,
            statusCode: response.statusCode,
            httpVersion: "HTTP/1.1",
            headerFields: response.headers
        ) {
            client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
        }

        // Return the response data
        if let data = response.data {
            client?.urlProtocol(self, didLoad: data)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    // Stop loading (required override)
    override func stopLoading() {

    }
}

// MARK: - Supporting Types
struct MockResponse {
    let statusCode: Int
    let data: Data?
    let headers: [String: String]
    let error: Error?
    let delay: TimeInterval

    init(
        statusCode: Int = 200,
        data: Data? = nil,
        headers: [String: String] = ["Content-Type": "application/json"],
        error: Error? = nil,
        delay: TimeInterval = 0
    ) {
        self.statusCode = statusCode
        self.data = data
        self.headers = headers
        self.error = error
        self.delay = delay
    }

    // Convenience method for JSON responses
    static func json(_ json: Any, statusCode: Int = 200) -> MockResponse {
        let data = try? JSONSerialization.data(withJSONObject: json)
        return MockResponse(statusCode: statusCode, data: data)
    }

    // Convenience method for Codable responses
    static func encodable<T: Encodable>(_ value: T, statusCode: Int = 200) -> MockResponse {
        let data = try? JSONEncoder().encode(value)
        return MockResponse(statusCode: statusCode, data: data)
    }

    // Convenience method for error responses
    static func error(_ error: Error) -> MockResponse {
        return MockResponse(error: error)
    }
}

enum MockError: Error {
    case invalidURL
    case noMockFound
    case networkError
    case timeout
}

Step 2: Configure URLSession for Testing
#

To use MockURLProtocol, we need to register it with a URLSession configuration:

extension URLSession {
    // Creates a URLSession configured to use MockURLProtocol
    static var mocked: URLSession {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        return URLSession(configuration: configuration)
    }
}

Writing Unit Tests with Mocked Responses
#

Now let’s see how to use MockURLProtocol in actual tests.

Given a Network Service
#

Suppose we have a simple API service:

struct User: Codable {
    let id: Int
    let name: String
    let email: String
}

protocol APIServiceProtocol {
    func fetchUser(id: Int) async throws -> User
}

final class APIService: APIServiceProtocol {
    private let session: URLSession
    private let baseURL: URL

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

    func fetchUser(id: Int) async throws -> User {
        let url = baseURL.appendingPathComponent("users/\(id)")
        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.invalidResponse
        }

        return try JSONDecoder().decode(User.self, from: data)
    }
}

enum APIError: Error {
    case invalidResponse
    case notFound
}

Test HTTP Response
#

import Testing
@testable import AppUnderTest

@Suite("APIService Tests")
struct APIServiceTests {

    // Forced unwrapped only for this tutorial purpose. Not recommended in an actual app.
    let baseURL = URL(string: "https://api.example.com")!

    init() {
        // Clear any previous mocks before each test
        MockURLProtocol.mockResponses.removeAll()
        MockURLProtocol.defaultResponse = nil
    }

    @Test("Fetch user returns decoded user")
    func fetchUserSuccess() async throws {
        let expectedUser = User(id: 1, name: "John Doe", email: "john@example.com")
        let url = "https://api.example.com/users/1"

        MockURLProtocol.mockResponses[url] = .encodable(expectedUser)
        let service = APIService(session: .mocked, baseURL: baseURL)
        let user = try await service.fetchUser(id: 1)

        #expect(user.id == 1)
        #expect(user.name == "John Doe")
        #expect(user.email == "john@example.com")
    }
}

Simulating Slow Networks
#

@Test("Handle slow network response")
func slowNetworkResponse() async throws {
    let url = "https://api.example.com/users/1"
    let user = User(id: 1, name: "John", email: "john@example.com")

    // Simulate 2 second delay
    MockURLProtocol.mockResponses[url] = MockResponse(
        statusCode: 200,
        data: try JSONEncoder().encode(user),
        delay: 2.0
    )

    let service = APIService(session: .mocked, baseURL: baseURL)
    let result = try await service.fetchUser(id: 1)

    #expect(result.name == "John")
}

Testing Multiple Endpoints
#

@Test("Fetch multiple resources")
func fetchMultipleResources() async throws {
    // Mock different endpoints
    MockURLProtocol.mockResponses["https://api.example.com/users/1"] =
        .encodable(User(id: 1, name: "Alice", email: "alice@test.com"))

    MockURLProtocol.mockResponses["https://api.example.com/users/2"] =
        .encodable(User(id: 2, name: "Bob", email: "bob@test.com"))

    let service = APIService(session: .mocked, baseURL: baseURL)

    let alice = try await service.fetchUser(id: 1)
    let bob = try await service.fetchUser(id: 2)

    #expect(alice.name == "Alice")
    #expect(bob.name == "Bob")
}

Best Practices
#

When using URLProtocol for mocking, keep these practices in mind:

  1. Clear mocks between tests — Always reset mockResponses in the test setup to avoid test pollution.

  2. Use dependency injection — Pass URLSession as a dependency so we can inject the mocked session in tests.

  3. Test the unhappy paths — Don’t just test success cases. Test 4xx errors, 5xx errors, network failures, and malformed responses.

  4. Keep mocks realistic — Use response data that matches the actual API structure.

  5. Consider using protocol abstraction — Define a protocol for the network layer to make testing even easier.

Limitations of URLProtocol Mocking
#

While URLProtocol is powerful, it has some limitations:

  • Requires code changes — We need to use a custom URLSession configuration in tests.
  • Global state — Mock responses are stored in static properties, requiring careful cleanup.
  • No UI for non-developers — QA engineers can’t easily modify mocks without code changes.
  • Limited to unit tests — Not suitable for manual testing or QA workflows.

Beyond Unit Tests - Monitoring, Logging and Mocking HTTP Requests
#

While URLProtocol is excellent for unit testing, real-world development often requires more flexibility. What if our QA team needs to test different API scenarios? What if we want to mock responses during manual testing without recompiling?

This is where tools like NetworkSpectator become invaluable. It’s a feature rich Swift library I created for monitoring, logging and mocking HTTP requests. With NetworkSpectator, we can create mocks both programmatically (perfect for tests) and through a built-in UI — allowing testers to stub different responses, simulate errors, and validate business logic without touching code or using external proxy tools. It also provides realtime network monitoring and log export capabilities, making it a comprehensive solution for debugging and testing.

Conclusion
#

URLProtocol is a powerful, native solution for mocking HTTP requests in Swift. By intercepting requests at the URL loading system level, we can write fast, reliable unit tests that don’t depend on external services.

Key takeaways:

  • Subclass URLProtocol to intercept and mock network requests
  • Register mock protocol with a custom URLSessionConfiguration
  • Use dependency injection to swap real and mocked sessions

With these techniques, networking tests will be faster and more reliable.

Links#