Ashutosh Billa

Mock Network Requests in iOS Unit Tests with URLProtocol

April 18, 2026 · 9 min read

Unit tests that hit real APIs are slow, brittle, and environment-dependent. The fix is to intercept network calls at the URLSession level and return whatever response you need — before the request ever leaves the device.

URLProtocol makes this possible. Register a custom subclass with your URLSession and it intercepts every outgoing request. In test code, you decide what each URL returns: a success body, an error status code, malformed JSON, or silence.

This post walks through building a mock networking layer with URLProtocol and Swift Testing — no third-party libraries required.


The Core Idea

URLSession doesn't talk directly to the network — it delegates request handling to a chain of URLProtocol subclasses. The system default handles HTTP/S. Your custom subclass sits in front of it, intercepting requests and returning stubbed responses instead.

The setup has three pieces:

  1. MockURLProtocol — subclasses URLProtocol, intercepts requests, returns stubs
  2. MockResponse — a value type holding the URL and a MockResult: either a successful HTTP response or a network-level error
  3. Test setup — injects a session configured with MockURLProtocol into the system under test

MockResponse

Start with the response model. MockResult separates two fundamentally different outcomes — a valid HTTP response (even a 4xx or 5xx) versus a network-level failure where no response arrives at all:

struct MockResponse {
    enum JSONType {
        case raw(String)    // Inline JSON string
        case file(String)   // Filename (loads .json from test bundle)
    }
 
    enum MockResult {
        case success(json: JSONType, statusCode: Int = 200)
        case failure(error: Error)
    }
 
    let urlString: String
    let result: MockResult
 
    var data: Data {
        guard case .success(let json, _) = result else { return Data() }
        switch json {
        case .raw(let jsonString):
            return jsonString.data(using: .utf8)!
        case .file(let fileName):
            return loadFromFile(named: fileName)
        }
    }
 
    var response: HTTPURLResponse {
        let statusCode = if case .success(_, let code) = result { code } else { 200 }
        return HTTPURLResponse(
            url: URL(string: urlString)!,
            statusCode: statusCode,
            httpVersion: nil,
            headerFields: nil
        )!
    }
 
    private func loadFromFile(named name: String) -> Data {
        let bundle = Bundle(for: MockURLProtocol.self)
        guard let url = bundle.url(forResource: name, withExtension: "json") else {
            fatalError("JSON file \(name).json not found in test bundle.")
        }
        return try! Data(contentsOf: url)
    }
}
  • .success — returns a valid HTTP response with a body and status code (defaults to 200). Use this for both happy-path responses and HTTP error codes like 401 or 404.
  • .failure — simulates a network-level error: no response, no data. Use this for connectivity failures, timeouts, and SSL errors.

The JSONType enum handles two common body formats:

  • .raw — for small payloads you want inline in the test file
  • .file — for large or shared responses stored as .json files in the test bundle

MockURLProtocol

The protocol subclass intercepts requests and looks up the registered stub by URL:

final class MockURLProtocol: URLProtocol {
 
    static var mockResponses: [String: MockResponse] = [:]
 
    static func reset() {
        mockResponses = [:]
    }
 
    static func setMockResponse(_ mocks: MockResponse...) {
        mocks.forEach { mockResponses[$0.urlString] = $0 }
    }
 
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
 
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
 
    override func startLoading() {
        guard let url = request.url,
              let mockResponse = MockURLProtocol.mockResponses[url.absoluteString] else {
            fatalError("No mock response registered for: \(request.url?.absoluteString ?? "unknown")")
        }
 
        switch mockResponse.result {
        case .success:
            client?.urlProtocol(self, didReceive: mockResponse.response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: mockResponse.data)
            client?.urlProtocolDidFinishLoading(self)
        case .failure(let error):
            client?.urlProtocol(self, didFailWithError: error)
            client?.urlProtocolDidFinishLoading(self)
        }
    }
 
    override func stopLoading() {}
}

Three things worth noting:

  • canInit returns true unconditionally. Every request through this session is intercepted. If a URL has no registered response, it crashes immediately — which is what you want. Silently letting unregistered requests through produces false positives.

  • canonicalRequest is a pass-through. There's nothing to normalize in tests. Return the request unchanged.

  • startLoading switches on MockResult. For .success, it builds an HTTPURLResponse with the given status code, sends the body data, then signals completion. For .failure, it forwards the error directly — no HTTP response is constructed. Either way, urlProtocolDidFinishLoading must always be called last, or the request hangs indefinitely.


Wiring It Into Tests

Rather than swapping a global session, each test struct creates its own ephemeral URLSession with MockURLProtocol registered and injects it into the system under test:

import Testing
 
@Suite("Feed API", .serialized)
struct FeedTests {
    private var sut: FeedViewModel
 
    lazy var session: URLSession = {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        return URLSession(configuration: configuration)
    }()
 
    init() {
        MockURLProtocol.reset()
        sut = FeedViewModel(session: session)
    }
 
    @Test("fetchFeed returns feed items on success")
    func fetchFeedSuccess() async {
        let feedSuccess = MockResponse(
            urlString: "https://api.example.com/feed",
            result: .success(json: .raw("""
            {
              "items": [
                {
                  "id": "post_1",
                  "title": "First Post",
                  "likes": 12,
                  "isLiked": false,
                  "createdAt": "2026-04-17T10:30:00Z"
                },
                {
                  "id": "post_2",
                  "title": "Another Post",
                  "likes": 5,
                  "isLiked": true,
                  "createdAt": "2026-04-17T09:15:00Z"
                }
              ],
              "nextCursor": "cursor_abc123",
              "hasMore": true
            }
            """))
        )
        MockURLProtocol.setMockResponse(feedSuccess)
 
        do {
            let feed = try await sut.fetchFeed()
 
            #expect(feed.items.count == 2)
            #expect(feed.nextCursor == "cursor_abc123")
            #expect(feed.hasMore == true)
        } catch {
            #expect(false)
        }
    }
 
    @Test("fetchFeed surfaces error on unauthorized response")
    func fetchFeedUnauthorized() async {
        let feedUnauthorized = MockResponse(
            urlString: "https://api.example.com/feed",
            result: .success(json: .raw("""
            {
              "error": {
                "code": "401",
                "message": "Invalid or expired token"
              }
            }
            """), statusCode: 401)
        )
        MockURLProtocol.setMockResponse(feedUnauthorized)
 
        do {
            let _ = try await sut.fetchFeed()
            #expect(false)
        } catch let error as NSError {
            #expect(error.code == 401)
        }
    }
 
    @Test("fetchFeed surfaces error on network failure")
    func fetchFeedNetworkFailure() async {
        let feedNetworkError = MockResponse(
            urlString: "https://api.example.com/feed",
            result: .failure(error: URLError(.notConnectedToInternet))
        )
        MockURLProtocol.setMockResponse(feedNetworkError)
 
        do {
            let _ = try await sut.fetchFeed()
            #expect(false)
        } catch let error as URLError {
            #expect(error.code == .notConnectedToInternet)
        }
    }
}

.ephemeral ensures the session carries no disk cache or cookie state between test runs. FeedViewModel still receives the session at init, which avoids relying on a global URLSession, but MockURLProtocol's static response store is shared across the process, so reset it between tests to prevent cross-test leakage.

Each test registers its stub, calls the production method, and asserts with #expect. No mocking frameworks, no stub servers, no network access.


Handling Multiple Endpoints

setMockResponse takes a variadic list, so you can register all the stubs for a flow in one call:

@Test("profile page loads feed and user info")
func fetchProfileAndFeed() async {
    let feedResponse = MockResponse(
        urlString: "https://api.example.com/feed",
        result: .success(json: .file("FeedResponse"))
    )
 
    let userProfileResponse = MockResponse(
        urlString: "https://api.example.com/users/me",
        result: .success(json: .file("CurrentUser"))
    )
 
    MockURLProtocol.setMockResponse(feedResponse, userProfileResponse)
 
    do {
        let feed = try await sut.fetchFeed()
        let profile = try await sut.fetchUserProfile()
 
        #expect(feed.items.count == 4)
        #expect(profile.username == "ashutoshbilla")
    } catch {
        #expect(false)
    }
}

Each URL is keyed separately in mockResponses, so requests for /feed and /users/me each get their own stub. Hitting a URL with no registered entry crashes immediately — it forces you to be explicit about every request your code makes.


Loading Responses from JSON Files

For large responses, keeping JSON inline in test code is noise. Store them as .json files in your test target and load by filename:

MockURLProtocol.setMockResponse(
    MockResponse(
        urlString: "https://api.example.com/feed",
        result: .success(json: .file("FeedResponse"))
    )
)

MockResponse loads the file from the test bundle at the time of the request. This also lets you reuse the same fixture across multiple test cases.

Get the JSON by copying the cURL command from your network logs, running it in terminal, and saving the output. The response logged in Xcode's console is often reformatted and not valid JSON — always use the raw cURL output instead.


Trade-offs and Limitations

This approach covers the most common test scenarios, but there are a few rough edges worth knowing about before you ship it.

URL matching is exact and fragile. mockResponses keys on url.absoluteString, which means small differences in how a URL is constructed will silently miss the stub and crash. Query parameter order, a missing or extra trailing slash, percent-encoding differences — any of these can cause a lookup failure that looks like a missing stub rather than a URL construction bug. If your production code builds URLs dynamically, double-check the exact string it produces before writing the stub.

mockResponses is global static state. Because MockURLProtocol.mockResponses is a class-level dictionary, stubs registered in one test bleed into the next if not cleared. Swift Testing runs all tests in parallel by default, so concurrent tests writing to the same dictionary can corrupt each other's stubs and produce non-deterministic failures. The .serialized trait on @Suite forces tests in that suite to run one at a time and eliminates the race:

@Suite("Feed API", .serialized)
struct FeedTests { ... }

Call MockURLProtocol.reset() in init() to clear stubs between tests. Without it, a stub registered in a previous test may still be present when the next one runs.

Network delays and timeouts. If you need to test timeout handling, extend MockResponse with an optional delay and call Task.sleep inside startLoading before returning the response.

Request validation. The current setup asserts on the response side only — it doesn't verify what the request looked like: URL, method, headers, or body. If request construction matters (e.g., verifying a retry attached a specific header), capture self.request inside startLoading and expose it for assertion.


Summary

The approach:

  1. MockResponse holds the URL and a MockResult — either a successful HTTP response or a network-level error
  2. MockURLProtocol intercepts all requests and looks up the stub by URL in mockResponses
  3. Each test struct creates an ephemeral session with MockURLProtocol and injects it into the SUT
  4. Each test registers its stubs via setMockResponse, calls the production code, and asserts with #expect

The result is reliable test coverage for networking behavior — success paths, error handling, malformed responses — with no real network calls, no test servers, and no third-party dependencies. Tests run fast and deterministically in any environment, including CI.


If you found this useful, you might like what I'm building:

Modus, DSLR-like manual control app for iPhone