How to build network mocking in SwiftUI
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
-
canInit(with:) returns true for every request. This tells the URL loading system to route all traffic through
MockURLProtocolrather than the real HTTP stack. Because the session is built withURLSessionConfiguration.ephemeral(no disk cache, no shared cookies), the mock is fully isolated. -
startLoading() calls the static handler closure. Each test assigns a fresh closure to
MockURLProtocol.handlerbefore the request fires. The closure receives the fullURLRequest—you can inspect the URL, headers, or body to return different stubs per endpoint. -
client? methods drive the URL loading system. Calling
didReceive(_:response:)thendidLoad(_:data:)thenurlProtocolDidFinishLoading(_:)in sequence is the exact contractURLProtocolrequires; omitting any step hangs theasync/awaitcontinuation indefinitely. -
PostService accepts URLSession via init injection. The production app passes
.shared; tests passMockURLProtocol.makeSession(). This is the only change needed to make a service fully testable—no protocol wrappers, no extra abstractions. -
The #Preview wires the same mock. Setting
MockURLProtocol.handlerbefore 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
-
Using URLSessionConfiguration.default instead of .ephemeral. The default configuration shares a URL cache and cookie store between tests, causing false positives when a real response is cached from a previous run. Always use
.ephemeralfor mock sessions. -
Forgetting to call urlProtocolDidFinishLoading(_:). Omitting this call means the
async/awaitcontinuation never resumes and your test hangs until XCTest times it out (30 s default). Always call all threeclient?methods in order, even on the error path. -
Static handler state leaking between tests. Because
MockURLProtocol.handleris static, a failing test that doesn't reset it poisons subsequent tests. Set it insetUp()or clear it intearDown()(MockURLProtocol.handler = nil) to keep tests hermetic. -
Not adding MockURLProtocol to the app target for previews. If the file lives only in the test target, the
#Previewmacro can't compile it. Either add it to both targets in Xcode or use a conditional compilation flag (#if DEBUG).
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.