How to mock any network call with URLProtocol
You’re more of a video kind of person? I’ve got you covered! Here’s a video with the same content than this article 🍿
Have you ever heard of a type called URLProtocol
?
This type is not widely known and that’s a shame because it’s super useful!
It basically enables you to mock any network call, without the need to make any change to your existing code 👌
So let’s see how it works!
First let’s take a look at this code:
This is a typical example of how a network call is implemented in an iOS app.
As you would expect, the network call is made using a URLSession
.
And then its result is decoded with the framework Decodable
.
But in its current form, this code doesn’t provide a way to replace the actual network call by a mocked version that would return some predefined data.
And this a quite a strong limitation, because without this possibility you can’t write reliable unit tests for the class UserAPI
.
However, you can notice that it’s possible to inject a custom URLSession
when we initialize a UserAPI
:
And as it turns out, thanks to URLProtocol
, this possibility will be enough to implement a mocked network call!
So let’s move to our testing target and start actually using URLProtocol
!
URLProtocol
is an abstract class that we can subclass in order to implement a type that will be able to intercept network calls made through a URLSession
.
And when a call is intercepted, the subclass of URLProtocol
will be able to manually inject a predefined response to that call.
So let’s implement it!
First, we need to define two methods called canInit(with request:)
and canonicalRequest(for request:)
.
I won’t go into the details of how they work, but by returning true
for the first one and the unmodified request
for the second one, we’re saying that we want MockURLProtocol
to intercept all the requests made by a URLSession
.
Then we define a requestHandler
.
This closure will take as its input a request
and will return an HTTPURLResponse
and some Data
.
As you can imagine, it’s through this closure that we will define the mocked data that we want the call to return.
Finally, we need to implement the methods startLoading()
and stopLoading()
.
Inside startLoading()
we assert that a requestHandler
has indeed been provided.
And then we get the mocked response
and data
from the handler
and we send them to the URLSession
.
For the method stopLoading()
, we can leave an empty implementation because we don’t need to do anything special if the network call gets canceled.
And now we’re ready to use MockURLProtocol
to write a unit test for our class UserAPI
!
All we need to do is to create a custom URLSession
and add our MockURLProtocol
to its protocolClasses
property.
Then, we inject that custom URLSession
into our instance of UserAPI
.
And from that, implementing a test is rather straightforward.
First, we create the mockData
.
Then we set a closure for the requestHandler
that returns this mockData
and that also asserts that the URL called is the correct one.
And finally, we make the API call and we assert that the parsed result we get is indeed the one that we expect!
One last thing, we also implement the tearDown()
method so that we set the requestHandler back to nil
after each test.
And that’s it, thanks to the class URLProtocol
we’ve been able to inject a mock into a network call without needing to make any change to our existing code!
All that was needed was the ability to inject a custom URLSession
and if that’s not the case with your code, all you need is to add it as an argument to the initializer 😌
That’s all for this article, I hope you’ve enjoyed learning about URLProtocol
and that you will be able to use it inside your own project!
Here’s the code if you want to experiment with it:
// UserAPI.swift
import Foundation
struct User: Decodable {
let firstName: String
let lastName: String
}
final class UserAPI {
let endpoint = URL(string: "https://my-api.com")!
let session: URLSession
let decoder: JSONDecoder
init(
session: URLSession = URLSession.shared,
decoder: JSONDecoder = JSONDecoder()
) {
self.session = session
self.decoder = decoder
}
func fetchUser() async throws -> User {
return try await request(url: endpoint.appendingPathComponent("user/me"))
}
private func request<T>(url: URL) async throws -> T where T: Decodable {
let (data, _) = try await session.data(from: url)
return try decoder.decode(T.self, from: data)
}
}
// MockURLProtocol.swift
import XCTest
class MockURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
XCTFail("No request handler provided.")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
XCTFail("Error handling the request: \(error)")
}
}
override func stopLoading() {}
}
// UserAPITests.swift
import Foundation
import XCTest
@testable import MyApp
class UserAPITests: XCTestCase {
lazy var session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}()
lazy var api: UserAPI = {
UserAPI(session: session)
}()
override func tearDown() {
MockURLProtocol.requestHandler = nil
super.tearDown()
}
func testFetchUser() async throws{
let mockData = """
{
"firstName": "Vincent",
"lastName": "Pradeilles"
}
""".data(using: .utf8)!
MockURLProtocol.requestHandler = { request in
XCTAssertEqual(request.url?.absoluteString, "https://my-api.com/user/me")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, mockData)
}
let result = try await api.fetchUser()
XCTAssertEqual(result.firstName, "Vincent")
XCTAssertEqual(result.lastName, “Pradeilles")
}
}