Building Screens Ahead of the Backend: an API Mock Kit on URLProtocol

Intercepting requests under URLSession so screens, networking, decoding, and error handling all run for real, with switchable loading, 401, 500, timeout, and offline states, and what a mock still can't tell you.

On this page
  1. Which layer to fake
  2. Routes that don’t forgive mistakes
  3. Not just the happy path
  4. How the app switches
  5. Tests
  6. Looking back

I was building a new section of an old UIKit app: an overview, a list, and a detail page. I wanted the screens and all their states done early rather than waiting on real endpoints, and hard-coding fake data into view models would only mean rewriting that code later.

So what I wanted was for the screens, view models, networking, JSON decoding, and error handling to all be real, with only one thing faked: where the data comes from.

The result was a local Swift package.

Which layer to fake

There are a few common places to put a mock:

  • Hard-code data in the view model. Fastest, but networking, decoding, and error mapping never run.
  • Swap a fake behind a service protocol. Better, but it still bypasses networking, so decoding problems stay hidden.
  • Intercept requests underneath URLSession. Everything above is real, including the project’s Alamofire stack, decoding, and status-code handling. The request just never leaves the device.

I chose the third. URLProtocol is the hook Foundation provides for this. Here’s the shape of mine, trimmed down:

final class MockURLProtocol: URLProtocol {
    private let queue = DispatchQueue(label: "MockURLProtocol")
    private let lock = NSLock()
    private var isStopped = false

    override class func canInit(with request: URLRequest) -> Bool {
        let scheme = request.url?.scheme?.lowercased()
        return scheme == "http" || scheme == "https"
    }

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

    override func startLoading() {
        do {
            let result = try MockKit.result(for: request)
            queue.asyncAfter(deadline: .now() + .milliseconds(result.delayMilliseconds)) { [weak self] in
                self?.deliver(result)
            }
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {
        lock.lock()
        isStopped = true
        lock.unlock()
    }

    private func deliver(_ result: MockResult) {
        lock.lock()
        let stopped = isStopped
        lock.unlock()
        guard !stopped else { return }

        switch result.outcome {
        case .response(let mock):
            guard let url = request.url,
                  let response = HTTPURLResponse(url: url, statusCode: mock.statusCode,
                                                 httpVersion: "HTTP/1.1", headerFields: mock.headers) else {
                client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
                return
            }
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: mock.body)
            client?.urlProtocolDidFinishLoading(self)
        case .failure(let error):
            client?.urlProtocol(self, didFailWithError: error)
        }
    }
}

The delay and the stop check matter. Without a delay, a loading state is never visible. Without the stop check, a cancelled request could still deliver a response.

There’s one integration point: the network manager creates a second session and installs the protocol first in its configuration. Only modules that opt in use that session, so everything else keeps going to the real network.

Request path: the view model calls a provider and the network layer. The live session reaches the real server; the mock session is intercepted by MockURLProtocol, routed by variant to local JSON or an error. Decoding and error handling run the same in both cases.

Routes that don’t forgive mistakes

Each endpoint is a route: a method, a path that can contain parameters, and exact query items.

struct MockRequestPattern {
    let method: String
    let path: String // e.g. "/api/v1/items/{itemId}"
    let query: [String: String]
}

The part I’d point out is the query. A route only matches if the query items match exactly, and a repeated or valueless item doesn’t match at all. A mock that accepts any query would hide a request built with the wrong parameters, and you’d only find out on the real API.

Path parameters can be substituted into the data file name, so different IDs can return different JSON. Those files live in the package’s resources and are read through Bundle.module. Empty paths, absolute paths, and paths with .. are rejected, and a missing file is an error, not a quiet success.

The routes began in a JSON config, and the next day I moved them into Swift. Route definitions now get checked by the compiler, and each route has to define a default variant, or it fails a precondition when it’s created. That second check is at runtime, and a custom variant name is still just a string, so Swift caught some mistakes here, not all of them.

Not just the happy path

Loading, signed-out, server error, timeout, and offline are hard to reproduce on demand, so a route can have several variants:

struct MockVariant: Hashable, ExpressibleByStringLiteral {
    let rawValue: String
    init(stringLiteral value: String) { rawValue = value }

    static let `default`: MockVariant = "default"
    static let loading: MockVariant = "loading"           // same data, 750 ms delay
    static let unauthorized: MockVariant = "unauthorized" // 401 + error body
    static let serverError: MockVariant = "serverError"   // 500 + error body
    static let timeout: MockVariant = "timeout"           // URLError(.timedOut)
    static let offline: MockVariant = "offline"           // URLError(.notConnectedToInternet)
}

A variant either returns a status and body, or fails with a URLError, as if the request never reached a server. Switching is one line:

MockKit.activate(.serverError, for: .itemOverview)

Not every route needs every variant. In the first version, only the overview route had all six, and the list and detail routes had default and loading. Variant overrides are guarded by a lock, because tests run in parallel and would otherwise step on each other.

How the app switches

View models don’t know about the mock. A provider decides on a data source when it’s created and passes that to the network layer, which picks a session:

enum APISource: String { case real, mock }

struct ItemProvider {
    let source: APISource

    func fetchOverview() -> Observable<ItemOverview> {
        network.request(.itemOverview, session: source == .mock ? .mock : .live)
    }
}

The source defaults to mock during development and can be overridden at runtime. A later change forced release builds to use the real source and ignore any saved override, so a debugging choice can’t leak into a production build. I should have had that from the start.

Tests

The package has its own tests in Swift Testing, covering route matching, path parameters, each variant’s status codes and errors, missing files, and invalid paths. If the mock tool isn’t reliable, neither is anything built on it.

@Test func serverErrorVariantReturns500() throws {
    MockKit.activate(.serverError, for: .itemOverview)
    let request = URLRequest(url: URL(string: "https://example.com/api/v1/items/overview")!)
    let result = try MockKit.result(for: request)
    guard case .response(let mock) = result.outcome else {
        Issue.record("Expected a response")
        return
    }
    #expect(mock.statusCode == 500)
}

The first version had 3 routes, 16 tests, and about 660 lines. As the section grew, it reached 6 routes, 18 tests, and about 740 lines.

Looking back

The value is simple. I could finish screens, interactions, and error states early, and show them, and those screens were already going through the real networking and decoding code. What this can’t tell you is whether the backend will match. A mock is only as right as the contract you copied into it, so moving to real endpoints still needs real testing.

What could be better:

  • Switching variants means changing code. A debug menu would be nicer.
  • URLProtocol only intercepts sessions configured with it. If another library creates its own session, its requests aren’t caught.
  • It covers one section’s endpoints. Rolling it out further would need more thought about organizing routes and the cost of maintaining data files.