Build Deterministic Offline iOS API Tests with URLProtocol

Build Deterministic Offline iOS API Tests with URLProtocol

Once remote builds depend on a live test API, the same commit can pass in the morning and fail in the afternoon: test data changes, tokens expire, the gateway rate-limits requests, or the API simply slows down temporarily. Worse, the only evidence left behind is often a decoding error, making it impossible to determine what the client actually received. The solution is not to add more retries, but to split network-layer validation into two categories: use deterministic local responses for most regression tests, and reserve live environments for a small number of contract tests.

Define the boundaries of offline testing

URLProtocol sits in the URLSession request pipeline and can return a custom response before a request reaches the network. It is well suited to verifying four things: whether the HTTP method and path are correct, whether the headers and body are complete, whether models decode successfully, and whether the business layer maps status codes to explicit errors.

It does not prove that the live service is currently reachable, nor can it detect changes to gateway configuration. Tests should therefore be divided into layers:

Layer Data source Primary checks Run frequency
Unit regression Local JSON fixtures Decoding and error mapping Every commit
Network-layer regression URLProtocol End-to-end request and response flow Every commit
Contract testing Controlled test API Field and authentication contracts On a schedule or before release

The goal of offline regression testing is to eliminate irrelevant variability, not to fabricate a request that always succeeds. Make 404, 429, 500, empty responses, and malformed JSON deterministic test cases as well.

Inject a dedicated URLSession

Do not register the protocol globally in tests, and do not let application code access URLSession.shared directly. Inject a session into APIClient, using the default configuration in production and an ephemeral configuration in tests. This prevents disk caches, cookies, and stale connection state from carrying over.

final class StubURLProtocol: URLProtocol {
    static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        request.url?.host == "api.test.invalid"
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        guard let handler = Self.handler else {
            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
            return
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

func makeTestSession() -> URLSession {
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [StubURLProtocol.self]
    configuration.timeoutIntervalForRequest = 3
    return URLSession(configuration: configuration)
}

canInit must restrict interception by host or a custom request header. If it returns true unconditionally, it may also intercept requests from other URLSession instances in the test process, making failures difficult to diagnose.

Maintain response fixtures as code

Store fixtures under Fixtures/API/v1/ in the test target, with names based on the resource and scenario, such as projects-success.json, projects-empty.json, and projects-malformed.json. Do not save complete traffic captures that contain dynamic tokens, email addresses, or internal URLs.

Before committing, you can sort keys consistently and validate the syntax:

find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
  tmp="${file}.tmp"
  jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done

Even successful cases should assert the request itself. Checking only the final model can hide bugs such as sending POST instead of GET, omitting a version header, or encoding query parameters twice.

StubURLProtocol.handler = { request in
    XCTAssertEqual(request.httpMethod, "GET")
    XCTAssertEqual(request.url?.path, "/v1/projects")
    XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")

    let data = try Data(contentsOf: fixtureURL("projects-success"))
    let response = HTTPURLResponse(
        url: try XCTUnwrap(request.url),
        statusCode: 200,
        httpVersion: "HTTP/1.1",
        headerFields: ["Content-Type": "application/json"]
    )!
    return (response, data)
}

Validate each failure category separately

At minimum, cover transport, HTTP, and content failures. Simulate a transport failure by throwing URLError(.timedOut). An HTTP failure should return a real status code and a structured error body. For a content failure, return 200 with missing fields, incorrect types, or truncated JSON. The business layer should not collapse all three into the same “unknown error.”

Timeout tests should not actually wait for dozens of seconds. Have the handler throw the corresponding error immediately, then verify that the view model enters a retryable state. For 429 responses, also verify that the client reads Retry-After, but do not actually sleep during the test. Extract backoff calculation into a pure function and test its inputs and outputs separately.

Set the handler to nil after each test. If the test suite runs in parallel, a single static handler can be overwritten by another test case. The safest starting point is to run this suite serially. If parallel execution is necessary, map request IDs to response closures and protect the registry with a lock or actor.

Run reliably on a cloud Mac

On a DplyMini cloud Mac, pin the Xcode selection first, then run with the same workspace, scheme, and test plan every time. A dedicated physical machine is not a virtual machine, but tests should still isolate DerivedData explicitly so that different branches do not share intermediate build artifacts.

set -euo pipefail

sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan NetworkRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$PWD/.derived-data" \
  -resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"

Before the first run, use xcodebuild -showdestinations to verify which simulator names are available in the current environment. Do not copy a device name from a personal machine directly into the script. On failure, retain the xcresult bundle, test logs, and corresponding fixture version rather than saving only the last few console lines.

Final acceptance should verify four conditions: the offline suite still completes with external network access disabled; repeated runs produce consistent results; fixture changes are clear during code review; and a live contract-test failure does not prevent developers from determining whether client logic has regressed. Once these four conditions are met, network tests stop depending on API luck and become maintainable engineering assets.

Frequently asked questions

Can URLProtocol tests replace integration tests against a real API?

No. They reliably cover client request construction, decoding, and error mapping, but a small real-environment contract suite is still needed to catch gateway, authentication, and server schema changes.

Why should tests inject a dedicated URLSession?

An injected ephemeral session limits interception to the system under test and avoids shared caches, cookies, or global protocol registration affecting unrelated tests.

How should parallel tests avoid overwriting a shared handler?

Run the suite serially for the simple implementation. For parallel execution, store responses by request identifier and protect the registry with a lock or actor.

Dedicated physical Mac mini

Choose a cloud Mac rental period based on your task

Rent either M4 configuration by the day, week, month, or quarter. Node availability and real-time details are provided by the console.

Choose a configuration and order