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:
- Speed — Real network calls are slow. Mocked responses are instant.
- Reliability — Tests won’t fail because a server is down or slow.
- Isolation — Test client side code’s behavior, not the backend’s.
- Edge Cases — Easily simulate errors, timeouts, and unusual responses.
- Offline Development — Work without an internet connection or backend availability.
- 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 ResponseInstead 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:
Clear mocks between tests — Always reset
mockResponsesin the test setup to avoid test pollution.Use dependency injection — Pass
URLSessionas a dependency so we can inject the mocked session in tests.Test the unhappy paths — Don’t just test success cases. Test 4xx errors, 5xx errors, network failures, and malformed responses.
Keep mocks realistic — Use response data that matches the actual API structure.
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
URLSessionconfiguration 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
URLProtocolto 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.