When building apps that rely on external WebServices, developers typically face the challenge of how to test and preview features without relying on a live production environment. Standard approaches usually involve maintaining a dedicated development server or creating complex “faked” service instances. While both are valid, they often introduce significant overhead and architectural boilerplate.
An elegant alternative or/and addition is to fake network responses at the URLSession level. This approach moves the mocking logic outside of your specific API clients, keeping your architecture lean and allowing you easily to simulate API behaviors that aren’t even live in production yet.
Setup
Note: This example utilizes a JSON-based communication structure for a theoretical web service.
HTPP Client
To start, we need a robust foundation. We define an IHttpClientBase protocol to serve as the “workhorse” for performing raw requests, followed by a more user-friendly IHttpClient wrapper.
IHttpClientBase is designed to be modular by allowing us to stack behaviours like retries, authentication, custom caching policies, etc by wrapping base implementation inside behaviour (middleware) one.
protocol IHttpClientBase {
func data<Output: Encodable, Input: Decodable>(
_ inputType: Input.Type,
path: String,
httpMethod: HttpMethod,
queryParameters: URLRequestQueryParameters?,
httpHeaders: URLRequestHttpHeaders?,
output: Output
) async -> Result<Input, HttpClientError>
}
protocol IHttpClient: IHttpClientBase {
func data<Input: Decodable>(
_ inputType: Input.Type,
path: String,
httpMethod: HttpMethod,
queryParameters: URLRequestQueryParameters?,
httpHeaders: URLRequestHttpHeaders?
) async -> Result<Input, HttpClientError>
func data(
path: String,
httpMethod: HttpMethod,
queryParameters: URLRequestQueryParameters?,
httpHeaders: URLRequestHttpHeaders?
) async -> Result<Int, HttpClientError>
func data<Output: Encodable>(
path: String,
httpMethod: HttpMethod,
queryParameters: URLRequestQueryParameters?,
httpHeaders: URLRequestHttpHeaders?,
output: Output
) async -> Result<Int, HttpClientError>
}
struct HttpClientBase: IHttpClientBase {
private let baseUrl: URL
private let urlSession: URLSession
private let urlRequestDecorators: [URLRequestDecorator]?
init?(baseUrl: String, urlSession: URLSession = URLSession.shared, urlRequestDecorators: [URLRequestDecorator]? = nil) {
guard let url = URL(string: baseUrl) else {
return nil
}
self.baseUrl = url
self.urlSession = urlSession
self.urlRequestDecorators = urlRequestDecorators
}
// ... body ..
}
Web Service
To demonstrate this in action, we’ll use the https://jsonplaceholder.typicode.com/ API. We define a simple IDemoAPI protocol and an implementation that is based on our IHttpClient.
protocol IDemoAPI {
func fetchPosts() async throws -> [Post]
func fetchAlbums() async throws -> [Album]
func fetchToDos() async throws -> [ToDo]
}
struct DemoAPIClient: IDemoAPI {
private let httpClient: any IHttpClient
init(httpClient: any IHttpClient) {
self.httpClient = httpClient
}
func fetchPosts() async throws -> [Post] {
let result = await httpClient.data([Post].self, path: "posts/", httpMethod: .get, queryParameters: nil, httpHeaders: nil)
switch result {
case .success(let posts):
return posts
case .failure(let error):
throw error
}
}
func fetchAlbums() async throws -> [Album] {
let result = await httpClient.data([Album].self, path: "albums/", httpMethod: .get, queryParameters: nil, httpHeaders: nil)
switch result {
case .success(let albums):
return albums
case .failure(let error):
throw error
}
}
func fetchToDos() async throws -> [ToDo] {
let result = await httpClient.data([ToDo].self, path: "todos/", httpMethod: .get, queryParameters: nil, httpHeaders: nil)
switch result {
case .success(let todos):
return todos
case .failure(let error):
throw erro
}
}
}
Implementing MockableURLProtocol
The core logic relies on URLProtocol. By providing a custom url protocol into a URLSessionConfiguration and creating custom URLSession based on this configuration, we can intercept outgoing requests. Instead of hitting the network, our custom protocol looks for a registered “mock” and returns that data instead.
Key methods to override in URLProtocol:
canInit(with: URLRequest): Determines if this protocol should handle the specific request.startLoading(with: URLRequest): Where the actual mocking occurs, you manually notify the client of the response and data.stopLoading(): Cleans up the task.
Below is the complete implementation of a mocked URLProtocol.
nonisolated public enum URLProtocolMockResponse {
case error(error: Error)
case data(responseCode: Int, data: Data?, httpVersion: String?, headerFields: [String:String]?)
public static func getData(responseCode: Int, data: Data? = nil, httpVersion: String? = nil, headerFields: [String:String]? = nil) -> URLProtocolMockResponse {
.data(responseCode: responseCode, data: data, httpVersion: httpVersion, headerFields: headerFields)
}
}
nonisolated public protocol IURLProtocolResponseMock {
func isApplicable(for request: URLRequest) -> Bool
func getResponse(for request: URLRequest) -> URLProtocolMockResponse
}
nonisolated public struct URLProtocolResponseMockSimple: IURLProtocolResponseMock {
public let response: URLProtocolMockResponse
public let url: URL
public init(url: URL, response: URLProtocolMockResponse) {
self.response = response
self.url = url
}
public init(url: String, responseCode: Int, data: Data? = nil, httpVersion: String? = nil, headerFields: [String:String]? = nil) {
self.url = URL(string: url)!
self.response = .data(responseCode: responseCode, data: data, httpVersion: httpVersion, headerFields: headerFields)
}
public func isApplicable(for request: URLRequest) -> Bool {
request.url == url
}
public func getResponse(for request: URLRequest) -> URLProtocolMockResponse {
response
}
}
nonisolated public struct URLProtocolResponseMockWildcard: IURLProtocolResponseMock {
private let _isApplicable: (URLRequest) -> Bool
private let _getResponse: (URLRequest) -> URLProtocolMockResponse
init(isApplicable: @escaping (URLRequest) -> Bool, getResponse: @escaping (URLRequest) -> URLProtocolMockResponse) {
self._isApplicable = isApplicable
self._getResponse = getResponse
}
public func isApplicable(for request: URLRequest) -> Bool {
_isApplicable(request)
}
public func getResponse(for request: URLRequest) -> URLProtocolMockResponse {
_getResponse(request)
}
}
nonisolated public class MockableURLProtocol: URLProtocol {
private static let logTag = "MockableURLProtocol"
nonisolated(unsafe) public static var wildcardResponseMocks: [any IURLProtocolResponseMock] = []
nonisolated(unsafe) public static var urlResponseMocks: [URL: any IURLProtocolResponseMock] = [:]
nonisolated(unsafe) public static var allowUnmockedRequest: Bool = false
public static func clearResponseMocks() {
urlResponseMocks.removeAll()
wildcardResponseMocks.removeAll()
}
public static func registerWildcardMocks(_ mocks: any IURLProtocolResponseMock...) {
mocks.forEach {mock in
wildcardResponseMocks.append(mock)
}
}
public static func registerUrlMocks(_ mocks: (url: URL, any IURLProtocolResponseMock)...) {
mocks.forEach { (url, mock) in
urlResponseMocks[url] = mock
}
}
public static func registerSimpleMocks(_ mocks: URLProtocolResponseMockSimple...) {
mocks.forEach {mock in
urlResponseMocks[mock.url] = mock
}
}
override public class func canInit(with request: URLRequest) -> Bool {
print(MockableURLProtocol.logTag, "canInit", request)
for mock in wildcardResponseMocks {
if mock.isApplicable(for: request) {
return true
}
}
if let url = request.url {
return urlResponseMocks[url] != nil
}
if !allowUnmockedRequest {
print(logTag, "No mock for URLRequest", request)
fatalError()
}
return true
}
override public class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override public func startLoading() {
print(MockableURLProtocol.logTag, "startLoading", request)
guard
let client = self.client,
let url = self.request.url,
let mock = MockableURLProtocol.urlResponseMocks[url] ?? MockableURLProtocol.wildcardResponseMocks.first(where: {$0.isApplicable(for: request)})
else {
print(MockableURLProtocol.logTag, "startLoading failed", request)
fatalError()
}
let response = mock.getResponse(for: request)
if case .error(let error) = response {
client.urlProtocol(self, didFailWithError: error)
client.urlProtocolDidFinishLoading(self)
return
}
guard case .data(let responseCode, let data, let httpVersion, let headerFields) = response else {
print(MockableURLProtocol.logTag, "Unable to decode mock response", request)
fatalError()
}
guard let httpResponse = HTTPURLResponse(url: url, statusCode: responseCode, httpVersion: httpVersion, headerFields: headerFields) else {
print(MockableURLProtocol.logTag, "Unable to create HTTPURLResponse", request)
fatalError()
}
client.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed)
if let data {
client.urlProtocol(self, didLoad: data)
}
client.urlProtocolDidFinishLoading(self)
}
override public func stopLoading() {}
}
Usage Example
Now we can create a specialized version of API client that serves faked network data.
let demoApiMockHttpDataAlbums =
"""
[
{
"userId": 1,
"id": 1,
"title": "FAKE NETWORK MOCK DATA"
},
// ...
]
""".data(using: .utf8)!
let demoApiMockBaseUrl = "mock://demo-api"
func instantiateDemoApiMockBaseHttpClient() -> any IHttpClientBase {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockableURLProtocol.self]
config.requestCachePolicy = .reloadIgnoringLocalCacheData
MockableURLProtocol.clearResponseMocks()
MockableURLProtocol.allowUnmockedRequest = false
MockableURLProtocol.registerSimpleMocks(
.init(url: "\(demoApiMockBaseUrl)/posts/", responseCode: 200, data: demoApiMockHttpDataPosts),
.init(url: "\(demoApiMockBaseUrl)/todos/", responseCode: 200, data: demoApiMockHttpDataToDos),
.init(url: "\(demoApiMockBaseUrl)/albums/", responseCode: 200, data: demoApiMockHttpDataAlbums),
)
return HttpClientBase(baseUrl: demoApiMockBaseUrl, urlSession: .init(configuration: config))!
}
func instantiateDemoApiMockBasedOnFakeNetworkData() -> any IDemoAPI {
DemoAPIClient(httpClient: HttpClient(baseHttpClient: instantiateDemoApiMockBaseHttpClient()))
}
This DemoApiClient with mocked network data can be used in SwiftUI previews, during testing and to mock API endpoints that are not yet production-ready.
Preview example
#Preview("Fake Network") {
PreviewConatiner {
DemoView(model: DemoViewModel(apiClient: instantiateDemoApiMockBasedOnFakeNetworkData()))
}
}
This approach simplifies your architecture by eliminating the need for service-level mocks. It establishes a ‘single source of truth’ for mock data, streamlining maintenance across tests and previews.
Full source code is available here: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/tree/main/04%20-%20HttpClient/04%20-%20HttpClient