HTTP in Swift, Part 5: Testing and Mocking
Part 5 in a series on building a Swift HTTP framework:
- HTTP in Swift, Part 1: An Intro to HTTP
- HTTP in Swift, Part 2: Basic Structures
- HTTP in Swift, Part 3: Request Bodies
- HTTP in Swift, Part 4: Loading Requests
- HTTP in Swift, Part 5: Testing and Mocking
- HTTP in Swift, Part 6: Chaining Loaders
- HTTP in Swift, Part 7: Dynamically Modifying Requests
- HTTP in Swift, Part 8: Request Options
- HTTP in Swift, Part 9: Resetting
- HTTP in Swift, Part 10: Cancellation
- HTTP in Swift, Part 11: Throttling
- HTTP in Swift, Part 12: Retrying
- HTTP in Swift, Part 13: Basic Authentication
- HTTP in Swift, Part 14: OAuth Setup
- HTTP in Swift, Part 15: OAuth
- HTTP in Swift, Part 16: Composite Loaders
- HTTP in Swift, Part 17: Brain Dump
- HTTP in Swift, Part 18: Wrapping Up
We’ve seen how a single method can provide the basis for loading a request over the network.
However, networks are also one of the biggest points of failure when developing an app, especially when it comes to unit tests. When we write unit tests, we want tests to be repeatable: no matter how many times we execute them, we should always get the same result. If our tests involve live network connections, we can’t guarantee that. For all the reasons our actual network requests fail, so might our unit tests.
Thus, we use mock objects to mimic the network connection, but in reality provide a consistent and repeatable façade via which we can provide fake data.
Since we have abstracted our network interface to a single method, mocking it is quite simple. Here’s an HTTPLoading
implementation that always returns a 200 OK
response:
public class MockLoader: HTTPLoading {
public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
let urlResponse = HTTPURLResponse(url: request.url!, statusCode: HTTPStatus(rawValue: 200), httpVersion: "1.1", headerFields: nil)!
let response = HTTPResponse(request: request, response: urlResponse, body: nil)
completion(.success(response))
}
}
We can provide an instance of MockLoader
anywhere we need an HTTPLoading
value, and any request sent to it will result in a 200 OK
response, albeit with a nil body
.
When we’re writing unit tests with a mocked network connection, we are not testing the network code itself. By mocking the network layer, we remove the network as a variable, which means the network is not the thing being tested: unit tests examine the variable of an experiment.
We’ll illustrate this principle using the StarWarsAPI
class we stubbed out in the previous post:
public class StarWarsAPI {
private let loader: HTTPLoading
public init(loader: HTTPLoading = URLSession.shared) {
self.loader = loader
}
public func requestPeople(completion: @escaping (...) -> Void) {
var r = HTTPRequest()
r.host = "swapi.dev"
r.path = "/api/people"
loader.load(request: r) { result in
// TODO: interpret the result
completion(...)
}
}
}
The tests for this class will validate its behavior: we want to make sure that it behaves correctly under different situations. For example, we want to make sure that the requestPeople()
method behaves correctly when it gets a 200 OK
response or a 404 Not Found
response or a 500 Internal Server Error
. We mimic those scenarios using our MockLoader
. These tests will give us the confidence to evolve the implementation of StarWarsAPI
without breaking existing functionality.
In order to meet these demands, our MockLoader
needs to:
- Guarantee that the requests coming in are the ones we’re expecting in our tests
- Provide a custom response for each request
My personal version of MockLoader
looks roughly like this:
public class MockLoader: HTTPLoading {
// typealiases help make method signatures simpler
public typealias HTTPHandler = (HTTPResult) -> Void
public typealias MockHandler = (HTTPRequest, HTTPHandler) -> Void
private var nextHandlers = Array<MockHandler>()
public override func load(request: HTTPRequest, completion: @escaping HTTPHandler) {
if nextHandlers.isEmpty == false {
let next = nextHandlers.removeFirst()
next(request, completion)
} else {
let error = HTTPError(code: .cannotConnect, request: request)
completion(.failure(error))
}
}
@discardableResult
public func then(_ handler: @escaping MockHandler) -> Mock {
nextHandlers.append(handler)
return self
}
}
This MockLoader
allows me to provide individualized implementations of how to respond to successive requests. For example:
func test_sequentialExecutions() {
let mock = MockLoader()
for i in 0 ..< 5 {
mock.then { request, handler in
XCTAssert(request.path, "/\(i)")
handler(.success(...))
}
}
for i in 0 ..< 5 {
var r = HTTPRequest()
r.path = "/\(i)"
mock.load(r) { result in
XCTAssertEqual(result.response?.statusCode, .ok)
}
}
}
If we were to use this MockLoader
while writing tests for our StarWarsAPI
class, it might look something like this (I’ve left out the XCTestExpectations
because they’re not directly relevant to this discussion):
class StarWarsAPITests: XCTestCase {
let mock = MockLoader()
lazy var api: StarWarsAPI = { StarWarsAPI(loader: mock) }()
func test_200_OK_WithValidBody() {
mock.then { request, handler in
XCTAssertEqual(request.path, "/api/people")
handler(.success(/* 200 OK with some valid JSON */))
}
api.requestPeople { ...
// assert that "StarWarsAPI" correctly decoded the response
}
}
func test_200_OK_WithInvalidBody() {
mock.then { request, handler in
XCTAssertEqual(request.path, "/api/people")
handler(.success(/* 200 OK but some mangled JSON */))
}
api.requestPeople { ...
// assert that "StarWarsAPI" correctly realized the response was bad JSON
}
}
func test_404() {
mock.then { request, handler in
XCTAssertEqual(request.path, "/api/people")
handler(.success(/* 404 Not Found */))
}
api.requestPeople { ...
// assert that "StarWarsAPI" correctly produced an error
}
}
func test_DroppedConnection() {
mock.then { request, handler in
XCTAssertEqual(request.path, "/api/people")
handler(.failure(/* HTTPError of some kind */))
}
api.requestPeople { ...
// assert that "StarWarsAPI" correctly produced an error
}
}
...
}
When we write tests like this, we treat our StarWarsAPI
as a “black box”: given specific input conditions, does it always produce the expected output result?
Our HTTPLoading
abstraction makes swapping out the implementation of the networking stack a simple change. All we do is pass in a MockLoader
to the initializer instead of a URLSession
. The key here is realizing that by making our StarWarsAPI
dependent on an interface (HTTPLoading
) and not a concretion (URLSession
), we have enormously enhanced its utility and made it easier to use (and test) in isolation.
This reliance on behavioral definition over a specific implementation will serve us well as we implement the rest of our framework. In the next post, we’ll change HTTPLoading
into a class and add a single property that will provide the foundation for just about every possible networking behavior we can imagine.