When you wire an iOS app to a language model backend, you need a way to exercise that networking code without hitting a live server. swift urlprotocol llm api testing gives you full control over the HTTP layer in XCTest, letting you assert request shape and fake responses deterministically. This approach beats mocking at the model layer because it tests the exact bytes your app sends and receives.
Step 1: Subclass URLProtocol to intercept requests
URLProtocol is the extension point URLSession uses to handle loading. A test stub overrides canInit, canonicalRequest, and startLoading to short-circuit the network.
final class StubURLProtocol: URLProtocol {
static var stubResponse: (Data, HTTPURLResponse)?
static var lastRequest: URLRequest?
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
StubURLProtocol.lastRequest = request
if let (data, response) = StubURLProtocol.stubResponse {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
} else {
let err = NSError(domain: "Stub", code: 0, userInfo: nil)
client?.urlProtocol(self, didFailWithError: err)
}
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
Reset the static state in your test setUp and tearDown to avoid cross-test leakage.
Step 2: Configure a URLSession that uses the stub
You cannot inject a protocol into the shared URLSession.shared. Build a session with a URLSessionConfiguration copy and set protocolClasses.
extension URLSession {
static func stubbed() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubURLProtocol.self]
return URLSession(configuration: config)
}
}
This session routes every request through your stub. Real network calls never leave the process.
Step 3: Write an LLM client that accepts the session
Dependency injection is non-negotiable here. The client should take a URLSession and a base URL. Below is a minimal OpenAI-compatible chat completion call.
struct LLMClient {
let session: URLSession
let baseURL: URL
func complete(messages: [String], model: String) async throws -> String {
var req = URLRequest(url: baseURL.appendingPathComponent("v1/chat/completions"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = [
"model": model,
"messages": messages.map { ["role": "user", "content": $0] }
]
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await session.data(for: req)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let choices = json["choices"] as! [[String: Any]]
let message = choices[0]["message"] as! [String: Any]
return message["content"] as! String
}
}
If you point this at an OpenAI-compatible gateway such as n4n.ai, which fronts 240+ models behind one endpoint, the same request shaping tests apply without modification.
Step 4: Assert the outgoing request in a test
Capture the request in the stub and verify the JSON your client produced. This catches field name typos and missing headers before they reach a provider.
final class LLMClientTests: XCTestCase {
var session: URLSession!
override func setUp() {
super.setUp()
StubURLProtocol.stubResponse = (
try! JSONSerialization.data(withJSONObject: ["choices": [["message": ["content": "ok"]]]]),
HTTPURLResponse(url: URL(string: "https://example.com/v1/chat/completions")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
)
session = .stubbed()
}
override func tearDown() {
StubURLProtocol.stubResponse = nil
StubURLProtocol.lastRequest = nil
super.tearDown()
}
func testRequestShape() async throws {
let client = LLMClient(session: session, baseURL: URL(string: "https://example.com")!)
_ = try await client.complete(messages: ["hi"], model: "gpt-4o")
let req = try XCTUnwrap(StubURLProtocol.lastRequest)
XCTAssertEqual(req.httpMethod, "POST")
let body = try JSONSerialization.jsonObject(with: req.httpBody!) as! [String: Any]
XCTAssertEqual(body["model"] as? String, "gpt-4o")
let messages = body["messages"] as? [[String: Any]]
XCTAssertEqual(messages?.first?["content"] as? String, "hi")
}
}
Run swift test (or Product > Test in Xcode). A green check on testRequestShape confirms your client serializes correctly.
Step 5: Fake a streaming response
Production LLM calls often use Server-Sent Events. Your stub can deliver chunked lines exactly like a real socket would. Override startLoading to write multiple didLoad calls.
override func startLoading() {
StubURLProtocol.lastRequest = request
let response = HTTPURLResponse(url: request.url!, statusCode: 200,
httpVersion: nil, headerFields: ["Content-Type": "text/event-stream"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
let chunks = [
"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n",
"data: [DONE]\n\n"
]
for c in chunks {
client?.urlProtocol(self, didLoad: c.data(using: .utf8)!)
}
client?.urlProtocolDidFinishLoading(self)
}
Parse these in the client with a line scanner. Testing against the stub proves your SSE loop handles partial frames and the [DONE] sentinel.
Step 6: Validate header forwarding
Some gateways honor cache-control or routing hints. If your app sets OpenAI-Organization or a custom X-Route header, assert it.
req.setValue("cache", forHTTPHeaderField: "X-Cache-Control")
// later in test:
XCTAssertEqual(req.value(forHTTPHeaderField: "X-Cache-Control"), "cache")
n4n.ai forwards provider cache-control hints, so if you send those headers you can verify them in the same stub without touching the network.
Step 7: Test failure paths
Set stubResponse to nil to simulate a transport error, or return a 429 with a Retry-After header to exercise backoff. Your client should surface a typed error, not a raw URLError.
StubURLProtocol.stubResponse = (
Data(),
HTTPURLResponse(url: URL(string: "https://example.com/v1/chat/completions")!,
statusCode: 429, httpVersion: nil, headerFields: ["Retry-After": "1"])!
)
Assert that your code either retries or throws a domain-specific error. This is where swift urlprotocol llm api testing earns its keep: you reproduce flaky provider behavior in milliseconds.
Step 8: Run and verify success
Execute the suite from the command line for CI:
swift test --filter LLMClientTests
Or use the Xcode test navigator. Success criteria:
testRequestShapepasses, proving the POST body matches the API contract.- A streaming test consumes all chunks and concatenates “Hello world”.
- Error tests confirm your retry or fail path triggers.
If those pass, you have locked down the network boundary. Any future regression in URL construction or header handling breaks the build instead of shipping to users.
Why not mock the client protocol?
You could define LLMService and mock it in tests, but that skips the serialization code. swift urlprotocol llm api testing sits one layer below, exercising URLRequest building and JSONSerialization for real. You still test your parsing logic against actual response shapes. The cost is a few dozen lines of stub boilerplate, paid back the first time a provider changes a field name.
Keep the stub in a @testable target, and resist the urge to add conditional logic for different endpoints. A single stub that records the last request and returns a queued response is enough for the majority of LLM client tests.