How to test network code without accessing the server?
Unit tests would need to be:
- Fast, so that they can be run without impeding productivity.
- Deterministic, meaning it’ll have the same result every time given there is no code change.
- Self-Contained, it’ll run on every developer’s machine and on a continuous integration server.
Many iOS applications are dependent to a server connected through the Internet. Either for business logic shared with other platform, central data storage for collaboration, or the app itself is to deliver service provided by the server. Therefore networking is a common — if not central — topic in iOS development.
But unit tests of networking code is challenging. How to create a fast, deterministic, and self-contained test of a component which purpose is to talk to another computer? Have a dedicated “test backend” for the test? Making it deterministic would be difficult — outages, changes, or any issue with the backend server would impact corresponding test. Create a “mini-backend” for the unit test? It won’t be “self-contained” would it? Depending on a running backend would make it a lot harder for the test to work in different developer’s machines (e.g. port number conflicts, executable dependencies, et cetera).
Plugins to the Rescue
Luckily Apple has a URL Loading System that you can plug into. You can intercept URL requests and then provide your own implementation on how that URL is handled. Originally this is meant for custom protocols — non-standard URL schemes for those schemes that are not handled by the system (Gopher, anyone?). However you can use it to override the system’s built-in protocol handlers — in turn to simulate backend responses. You don’t need a running server to test network code, but provide a custom URL handler instead. Your network code doesn’t need to change for the sake of testability — the mock backend can be transparent to your network code.
In both macOS and iOS (also implies iPadOS), custom protocols revolve around the URLProtocol
class. Subclass this to implement a custom URL scheme or provide an alternative implementations of how URLs with a certain pattern are handled. Custom URL handlers are not limited to customizing the schemes – you can choose to override handling based on host names, paths, or anything that can be identified from a URL object.
These are the two core URLProtocol
methods to override in your own subclass when implementing a custom protocol:
canInit(with:)
— class method that tells the system whether it can handle a given URL.startLoading()
— performs the work of loading the URL.
Be sure that your implementation of canInit(with:)
is wickedly fast. Because the system would call that in a chain along with other subclasses of URLProtocol
for every URL request. Make decisions on what is given in the URL, don’t modify anything (make the method idempotent), and don’t do any I/O. Otherwise you’ll risk slowing down all URL requests.
In turn startLoading()
would need to initiate processing of the URL and keep the URLProtocolClient
delegate object informed on the process:
- Call
urlProtocol(_: didReceive: cacheStoragePolicy:)
as soon as there is success in getting the data. - Call
urlProtocol(_: didLoad:)
on every incremental data received. - Call
urlProtocolDidFinishLoading(_:)
on successful completion. - Alternatively call
urlProtocol(_: didFailWithError:)
to report failure and stop early.
There other delegate methods of URLProtocolClient
. But the above four should cover the common case.
There are two ways you can put a URLProtocol
subclass into use:
- Global registration – enable it for all URL requests in the application.
- Per-session registration – only use it in specific
URLSession
objects.
Call the registerClass(_:)
class method to enable a URLProtocol
subclass globally. Alternatively include the class object inside protocolClasses
property of URLSessionConfiguration
when creating a URLSession
object that can use the customized URL loading protocol.
Unit Testing with URLProtocol
Unit testing network code starts with a URLProtocol
subclass that intercepts the network requests that the code-under-test is making. Instead of going to the backend, instead the URLProtocol
subclass would return a result that the backend is expected to provide given a certain request.
If you can, try to configure the custom URLRequest
subclass per-session instead of globally. It would make things easier to debug later on. Have your network class (or struct
) accept a URLSessionConfiguration
instance upon construction and use it to create all URLSession
objects that it needs. In turn, unit test suites would provide URLSessionConfiguration
containing the custom URLProtocol
subclasses when the network class is run under test.
Sample Test
The following is an example unit test, which tests for an HTTP not found error returned by the backend.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
func testFailure() { let testName = "not-found.json" let targetURL = baseURL.appendingPathComponent(testName) BlockTestProtocolHandler .register(url: targetURL) { (request: URLRequest ) -> (response: HTTPURLResponse , data: Data ?) in let response = HTTPURLResponse (url: request.url!, statusCode: 404, httpVersion: BlockTestProtocolHandler .httpVersion, headerFields: nil )! return (response, Data ()) } let fetchCompleted = XCTestExpectation (description: "Failure Fetch Completed" ) defer { self .wait( for : [fetchCompleted], timeout: 7) } let client = makeExampleNetworkClient() client.fetchItem(named: testName) { (data: Data ?, error: Error ?) in defer { fetchCompleted.fulfill() } XCTAssertNotNil (error, "Error expected" ) XCTAssertNil (data, "Data expected" ) if let cocoaError = error as NSError ? { XCTAssertEqual (cocoaError.domain, ExampleNetworkClient . ErrorDomain , "Unexpected error domain" ) XCTAssertEqual (cocoaError.code, ExampleNetworkClient . ErrorCode .invalidStatus.rawValue, "Unexpected error code" ) } } } |
- Lines 5–8 begins the test by registering a custom block handler which returns the HTTP 404 error into
BlockTestProtocolHandler
which is a customURLProtocol
subclass. - At line 15 the under-test object gets created.
- Lines 16–25 invokes the method under test ad asserts its output.
- Since this is an asynchronous code, lines 11–13 blocks the test until the callback result handler gets invoked.
Under-test objects are created via the makeExampleNetworkClient()
method of the unit test suite. This is where the custom URLSessionConfiguration
object configures the custom URL handlers into it.
1
2
3
4
5
6
7
|
func makeExampleNetworkClient() -> ExampleNetworkClient { let config = URLSessionConfiguration .ephemeral config.protocolClasses = [ BlockTestProtocolHandler . self ] return ExampleNetworkClient (baseURL: baseURL, configuration: config) } |
You can find the full example project from my Github account.
Next Steps
Start unit-testing your network code. Notably code which parses JSON or XML coming from a backend. A good strategy is to take an example output and save it as a resource file along with the corresponding unit test case. Then the URLProtocol
subclass for unit testing can return this resource file instead as part of the test case.
When you commit an XML or JSON file into a source repository, remember to normalize and pretty-print it beforehand. Normalizing a JSON file means to sort dictionaries by its keys (but it doesn’t mean re-ordering arrays) and then pretty-print it. Thus changing a key or value would show as a line change in the corresponding diff. There should be a similar process to prepare inclusion of XML files in source control.
That is all for now. Please let me know if you have more tips on unit testing I/O code.
2 thoughts on “How to Unit Test Network Code in Swift”
You must log in to post a comment.