```html SwiftUI: How to Build Network Mocking (iOS 17+, 2026)

How to build network mocking in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: URLProtocol Updated: May 11, 2026
TL;DR

Subclass URLProtocol, register it on a custom URLSessionConfiguration, and assign a per-test closure that returns stubbed HTTPURLResponse + Data. This intercepts every request before it leaves the process—no network needed.

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

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let handler = MockURLProtocol.handler else { fatalError("No handler set") }
        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() {}
}

Full implementation

The pattern below wires MockURLProtocol into a network service, a lightweight view model, and a full XCTest suite. The service accepts a URLSession via dependency injection so the production app uses .shared while tests supply a mock session—zero changes to production call sites. A SwiftUI view and #Preview demonstrate the view model in action with realistic fixture data.

import Foundation
import SwiftUI

// MARK: - Mock URLProtocol (shared between app target and test target)

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

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

    override func startLoading() {
        guard let handler = MockURLProtocol.handler else {
            client?.urlProtocol(self, didFailWithError: URLError(.unknown))
            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() {}

    // MARK: - Convenience factory
    static func makeSession() -> URLSession {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol.self]
        return URLSession(configuration: config)
    }
}

// MARK: - Model

struct Post: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

// MARK: - Network service

final class PostService {
    private let session: URLSession

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

    func fetchPosts() async throws -> [Post] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
        let (data, _) = try await session.data(from: url)
        return try JSONDecoder().decode([Post].self, from: data)
    }
}

// MARK: - View model

@Observable
final class PostsViewModel {
    var posts: [Post] = []
    var isLoading = false
    var errorMessage: String?

    private let service: PostService

    init(service: PostService = PostService()) {
        self.service = service
    }

    func load() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }
        do {
            posts = try await service.fetchPosts()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: - View

struct PostsView: View {
    @State private var vm: PostsViewModel

    init(vm: PostsViewModel = PostsViewModel()) {
        _vm = State(initialValue: vm)
    }

    var body: some View {
        NavigationStack {
            Group {
                if vm.isLoading {
                    ProgressView("Loading…")
                } else if let error = vm.errorMessage {
                    ContentUnavailableView(error, systemImage: "wifi.slash")
                } else {
                    List(vm.posts) { post in
                        VStack(alignment: .leading, spacing: 4) {
                            Text(post.title).font(.headline)
                            Text(post.body).font(.caption).foregroundStyle(.secondary)
                        }
                        .accessibilityElement(children: .combine)
                        .accessibilityLabel("\(post.title). \(post.body)")
                    }
                }
            }
            .navigationTitle("Posts")
            .task { await vm.load() }
        }
    }
}

// MARK: - Preview (uses mock session with fixture data)

#Preview {
    let fixture = [
        Post(id: 1, title: "Hello SwiftUI", body: "Network mocking makes previews fast."),
        Post(id: 2, title: "URLProtocol rocks", body: "No real network needed in tests."),
    ]
    let data = try! JSONEncoder().encode(fixture)
    let response = HTTPURLResponse(
        url: URL(string: "https://jsonplaceholder.typicode.com/posts")!,
        statusCode: 200, httpVersion: nil, headerFields: nil)!
    MockURLProtocol.handler = { _ in (response, data) }

    let vm = PostsViewModel(service: PostService(session: MockURLProtocol.makeSession()))
    return PostsView(vm: vm)
}

How it works

  1. canInit(with:) returns true for every request. This tells the URL loading system to route all traffic through MockURLProtocol rather than the real HTTP stack. Because the session is built with URLSessionConfiguration.ephemeral (no disk cache, no shared cookies), the mock is fully isolated.
  2. startLoading() calls the static handler closure. Each test assigns a fresh closure to MockURLProtocol.handler before the request fires. The closure receives the full URLRequest—you can inspect the URL, headers, or body to return different stubs per endpoint.
  3. client? methods drive the URL loading system. Calling didReceive(_:response:) then didLoad(_:data:) then urlProtocolDidFinishLoading(_:) in sequence is the exact contract URLProtocol requires; omitting any step hangs the async/await continuation indefinitely.
  4. PostService accepts URLSession via init injection. The production app passes .shared; tests pass MockURLProtocol.makeSession(). This is the only change needed to make a service fully testable—no protocol wrappers, no extra abstractions.
  5. The #Preview wires the same mock. Setting MockURLProtocol.handler before constructing the view model gives Xcode Canvas instant, offline previews with realistic data—identical code path to the test suite.

Variants

Simulating network errors and status codes

import XCTest

final class PostServiceTests: XCTestCase {
    var service: PostService!

    override func setUp() {
        super.setUp()
        service = PostService(session: MockURLProtocol.makeSession())
    }

    func test_fetchPosts_success() async throws {
        let expected = [Post(id: 99, title: "Mock", body: "Data")]
        let data = try JSONEncoder().encode(expected)
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
        MockURLProtocol.handler = { _ in
            let resp = HTTPURLResponse(url: url, statusCode: 200,
                                      httpVersion: nil, headerFields: nil)!
            return (resp, data)
        }
        let posts = try await service.fetchPosts()
        XCTAssertEqual(posts.first?.id, 99)
    }

    func test_fetchPosts_serverError_throws() async {
        MockURLProtocol.handler = { _ in
            throw URLError(.badServerResponse)
        }
        do {
            _ = try await service.fetchPosts()
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is URLError)
        }
    }
}

Per-URL routing inside the handler

When your service hits multiple endpoints, switch inside the handler on request.url?.path:

MockURLProtocol.handler = { request in
    switch request.url?.path {
    case "/posts":
        return (makeResponse(200, request.url!), postsData)
    case "/users":
        return (makeResponse(200, request.url!), usersData)
    default:
        throw URLError(.fileDoesNotExist)
    }
}

func makeResponse(_ status: Int, _ url: URL) -> HTTPURLResponse {
    HTTPURLResponse(url: url, statusCode: status,
                    httpVersion: nil, headerFields: nil)!
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement network mocking in SwiftUI for iOS 17+.
Use URLProtocol.
Make it accessible (VoiceOver labels).
Add a #Preview with realistic sample data.

Drop this prompt into Soarias during the Build phase and it will scaffold the MockURLProtocol, a matching XCTest file, and a preview-ready view model in one shot—letting you move straight to polishing without boilerplate.

Related

FAQ

Does this work on iOS 16?

URLProtocol itself has been available since iOS 2, so the mock mechanism compiles fine on iOS 16. However, the @Observable macro used in the view model requires iOS 17+. For iOS 16 targets, replace @Observable with @MainActor class + @Published properties and use ObservableObject conformance instead.

Can I mock multipart or streaming responses?

Yes. In startLoading(), call client?.urlProtocol(self, didLoad:) multiple times before urlProtocolDidFinishLoading(_:) to simulate chunked delivery. For Server-Sent Events or URLSessionDataDelegate-based streaming, this is the standard technique—no additional APIs needed.

What's the UIKit equivalent?

The pattern is identical—URLProtocol is a Foundation class, not a SwiftUI API. UIKit apps using URLSession in a view controller or coordinator can use MockURLProtocol.makeSession() the same way. The only difference is substituting @Observable for a plain class with @Published properties if your minimum deployment target is below iOS 17.

Last reviewed: 2026-05-11 by the Soarias team.

```