8.
RESTful Networking
Written by Joshua Greene
In this chapter, you’ll learn how to TDD a RESTful networking client. Specifically, you’ll:
- Set up the networking client.
- Ensure the correct endpoint is called.
- Handle networking errors, valid responses and invalid responses.
- Dispatch results to a response queue.
Get excited! TDD networking awesomeness is coming your way.
Getting started
Navigate to the starter directory for this chapter. You’ll find it has a DogPatch subdirectory containing DogPatch.xcodeproj. Open this project file in Xcode and take a look.
You’ll see a few files waiting for you. The important ones for this chapter are:
-
Controllers/ListingsViewController.swift displays the fetched
DogsorError. -
Models/Dog.swift is the model that represents each pup.
-
Networking is an empty folder for now. You’ll add the networking client and related types here.
Build and run the app, and the following error-message screen will greet you:
If you pull down to refresh, the activity indicator will animate but won’t ever finish.
Open ListingsViewController.swift. You’ll see tableView(_:numberOfRowsInSection:) returns the max of viewModels.count or one, if it isn’t currently refreshing.
Similarly, tableView(_:cellForRowAt:) checks if viewModels.count is greater than zero, which will always be false because the app isn’t setting viewModels right now. Rather, you need to create these from a network response.
However, there’s a comment for // TODO: Write this within refreshData(), so the app isn’t making any network calls…
Your job is now clear! You need to write the logic to make networking calls. While you could make a one-off network call directly within ListingsViewController, this view controller would quickly become very large.
A better option is to create a separate networking client that handles all of the networking logic, which happens to be the focus of this chapter!
Setting up the networking client
Before you write any production code, you first need to write a failing test.
Within DogPatchTests/Cases/Networking, create a new Swift File called DogPatchClientTests.swift. Replace its contents with the following, ignoring the compiler error for now:
@testable import DogPatch
import XCTest
class DogPatchClientTests: XCTestCase {
var sut: DogPatchClient!
}
Here, you create a new test class for DogPatchClientTests with a single property for sut of type DogPatchClient. However, since you haven’t created DogPatchClient, this code doesn’t compile. Compiler errors count as test failures, so you can now write production code.
Within DogPatch/Networking, create a new Swift File called DogPatchClient.swift and replace its contents with the following:
import Foundation
class DogPatchClient {
}
Here, you declare a new class for DogPatchClient and thereby resolve the compiler error. There’s nothing to refactor, so you simply move on to your first test.
Open DogPatchClientTests.swift and add the following below the declaration for sut, again ignoring the compiler error:
func test_init_sets_baseURL() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
// when
sut = DogPatchClient(baseURL: baseURL)
}
Ultimately, you want to test that the baseURL passed into the initializer matches sut.baseURL. However, you haven’t created this initializer, so this doesn’t compile.
To fix this, add the following to DogPatchClient:
let baseURL = URL(string: "https://example.com/")!
init(baseURL: URL) {
}
You declare baseURL, set it to an arbitrary value for now and then create init(baseURL:), which is enough to get the test to compile. But you haven’t asserted anything yet.
In DogPatchClientTests, add the following to the end of the test method:
// then
XCTAssertEqual(sut.baseURL, baseURL)
You assert sut.baseURL equals baseURL passed to the initializer. Build and run the unit tests. As expected, this test fails.
To get this to pass, replace the line for let baseURL = within DogPatchClient with:
let baseURL: URL
Then add the following to init(baseURL:):
self.baseURL = baseURL
You set baseURL from the passed-in argument into the initializer. Build and run your tests, and this now passes. There isn’t anything to refactor, so simply continue.
You also need a property for URLSession, which you’ll use to make the networking calls.
Add the following test right after the previous one, again ignoring the compiler error:
func test_init_sets_session() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
let session = URLSession.shared
// when
sut = DogPatchClient(baseURL: baseURL, session: session)
}
The purpose of this test is to expand the initializer to accept a session argument. Like before, you haven’t declared session on DogPatchClient, so this doesn’t compile. To fix it, add the following property after baseURL on DogPatchClient:
let session: URLSession = URLSession(configuration: .default)
Next, update the method signature for init(baseURL:) to:
init(baseURL: URL, session: URLSession)
This lets test_init_sets_session() compile, but it breaks test_init_sets_baseURL(). To fix this, add this line right below let baseURL within test_init_sets_baseURL():
let session = URLSession.shared
Then update the line for sut = to:
sut = DogPatchClient(baseURL: baseURL, session: session)
Your tests now compile again, but you haven’t added an assertion to test_init_sets_session(). Add the following to the end of that test method:
// then
XCTAssertEqual(sut.session, session)
You assert that sut.session and session are equal.
Build and run your tests. As expected, this test fails. To make it pass, change the property declaration for session on DogPatchClient to:
let session: URLSession
Then add this line to the end of the initializer:
self.session = session
Build and run the tests, and see they both now pass. This time, you do have some refactoring to do.
The first several lines within test_init_sets_baseURL() and test_init_sets_session() are exactly the same. To fix this, first add the following properties at the top of the class, right before var sut:
var baseURL: URL!
var session: URLSession!
Next, add these two methods right after the properties:
override func setUp() {
super.setUp()
baseURL = URL(string: "https://example.com/api/v1/")!
session = URLSession.shared
sut = DogPatchClient(baseURL: baseURL, session: session)
}
override func tearDown() {
baseURL = nil
session = nil
sut = nil
super.tearDown()
}
You set each of the properties within setUp and nil each of the properties within tearDown, which helps you eliminate the duplication in your tests.
Replace the entire contents of test_init_sets_baseURL() with:
XCTAssertEqual(sut.baseURL, baseURL)
Then replace the contents of test_init_sets_session() with:
XCTAssertEqual(sut.session, session)
Build and run the tests. You’ll see they all pass.
Excellent job! You declared two properties!
OK, maybe it’s not that exciting… However, these properties are essential to making networking calls, and now you can write that code!
TDDing the networking call
You need to make a GET request to fetch a list of Dog objects from the server. You’ll break this down into several smaller tasks:
- Mocking
URLSesssion. - Calling the right URL.
- Handling error responses.
- Deserializing models on success.
- Handling invalid responses.
Mocking URLSession
To keep your tests fast and repeatable, don’t make real networking calls in them. Instead of using a real URLSession, you’ll create a MockURLSession that will let you verify behavior but won’t make network calls. You’ll pass this into the initializer for DogPatchClient and use it like you’d use a real URLSession.
You might be tempted to create this by subclassing and overriding URLSession. However, if you try this, you’ll find its init has been deprecated, and its other initializers are marked public instead of open. Consequently, you can’t effectively subclass URLSession!
Fortunately, there’s another solution. Instead of using URLSession directly, you’ll create a URLSessionProtocol and update DogPatchClient to use it. In production code, you’ll make URLSession conform to URLSessionProtocol and pass this into DogPatchClient. In unit tests, you’ll make MockURLSession conform and pass it instead.
OK, you’ve got the theory down! Time to write the code.
Creating the session protocols
Within DogPatch/Networking, create a new Swift File called URLSessionProtocol.swift and replace its contents with:
import Foundation
protocol URLSessionProtocol: AnyObject {
func makeDataTask(
with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionTaskProtocol
}
protocol URLSessionTaskProtocol: AnyObject {
func resume()
}
You declare URLSessionProtocol with a single required method, makeDataTask(with:completionHandler:), which returns a URLSessionTaskProtocol instead of a real URLSessionTask directly. This lets you mock URLSessionTask and verify its behavior. URLSessionTaskProtocol also has a single required method, resume(), to start the networking task.
But wait, aren’t you missing unit tests? Nope! Protocols don’t have concrete behavior, so there’s nothing to test.
Conforming to the session protocols
You next need to make URLSession conform to URLSessionProtocol, and URLSessionTask conform to URLSessionTaskProtocol. Because URLSessionProtocol uses URLSessionTaskProtocol, start by making URLSessionTask conform first.
Since URLSessionTask does require concrete implementation code, you’ll need to write tests for it.
Under DogPatchClient/Cases/Networking, create a new Swift File called URLSessionProtocolTests.swift and replace its contents with:
@testable import DogPatch
import XCTest
class URLSessionProtocolTests: XCTestCase {
var session: URLSession!
override func setUp() {
super.setUp()
session = URLSession(configuration: .default)
}
override func tearDown() {
session = nil
super.tearDown()
}
func test_URLSessionTask_conformsTo_URLSessionTaskProtocol() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.dataTask(with: url)
// then
XCTAssertTrue((task as AnyObject) is URLSessionTaskProtocol)
}
}
You create a new test case for URLSessionProtocolTests with a single property, session, and a single unit test that verifies URLSessionTask conforms to URLSessionTaskProtocol. Since URLSessionTask doesn’t have any non-deprecated initializers, you can’t create it directly. Instead, you call session.dataTask(with:) to create one.
Build and run the tests, and you’ll see this test fails as intended. To make it pass, add the following to the end of URLSessionProtocol.swift:
extension URLSessionTask: URLSessionTaskProtocol { }
You extend URLSessionTask to make it conform to URLSessionTaskProtocol. Since URLSessionTask already implements resume(), you don’t need to add it here.
Build and rerun the tests, and see them pass. There’s nothing to refactor, so just continue.
Technical note: If you’re familiar with the ins and outs of
URLSession, you know thatdataTask(with:)returns aURLSessionDataTask, andURLSessionTaskis its superclass.By making
URLSessionTaskconform toURLSessionTaskProtocol, instead ofURLSessionDataTask, you also get conformance for all subclasses, including both public and internal types thatURLSessioncreates and returns. Consequently, it’s a more flexible and better design.How could you have figured this out by yourself? By using TDD and trial-and-error! However, this process isn’t essential to the core concepts in this chapter, so I omitted it for brevity’s sake.
Add this test after the previous one in URLSessionProtocolTests:
func test_URLSession_conformsTo_URLSessionProtocol() {
XCTAssertTrue((session as AnyObject) is URLSessionProtocol)
}
You validate that session, an instance of URLSession, conforms to URLSessionProtocol. Build and run tests, and you’ll see this fails as expected.
To make it pass, add the following to the end of URLSessionProtocol.swift:
extension URLSession: URLSessionProtocol {
func makeDataTask(
with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionTaskProtocol {
let url = URL(string: "http://fake.example.com")!
return dataTask(with: url,
completionHandler: { _, _, _ in } )
}
}
You extend URLSession to conform to URLSessionProtocol and implement makeDataTask by calling dataTask with dummy values. Build and run the tests, and you’ll see they all pass. There’s still nothing to refactor, so continue.
Next, you need to verify makeDataTask calls dataTask with the passed-in url. Add this test after the last one:
func test_URLSession_makeDataTask_createsTaskWithPassedInURL() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in })
as! URLSessionTask
// then
XCTAssertEqual(task.originalRequest?.url, url)
}
You assert the passed-in url matches the url on task.originalRequest. Build and run the tests to confirm this indeed fails.
To make it pass, replace the contents of makeDataTask with:
return dataTask(with: url, completionHandler: { _, _, _ in } )
Here, you update the return statement to use the passed-in url. Build and run the tests. The last one will now pass.
Now there’s duplicated code in URLSessionProtocolTests: You create the same url in two different tests. To fix this, declare this new property, right below session:
var url: URL!
In setUp(), add this line right after setting the session, to create the url:
url = URL(string: "https://example.com")!
Then in tearDown(), add this right after setting session, to nil out the url:
url = nil
Delete the // given and let url lines from both test_URLSessionTask_conformsTo_URLSessionTaskProtocol and test_URLSession_makeDataTask_createsTaskWithPassedInURL to get rid of the duplication. Build and run the tests, and see that they all continue to pass.
Next, add this test right after the last one:
func test_URLSession_makeDataTask_createsTaskWithPassedInCompletion() {
// given
let expectation =
expectation(description: "Completion should be called")
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in expectation.fulfill() })
as! URLSessionTask
task.cancel()
// then
waitForExpectations(timeout: 0.2, handler: nil)
}
This test verifies the completionHandler is set correctly. As such, you create an expectation and fulfill it when the completionHandler is called. By calling task.cancel(), you cause the completionHandler to execute. You verify the expectation is fulfilled via waitForExpecations.
Build and run the tests, and you’ll see this test fails. To make it pass, update the contents of makeDataTask with:
return dataTask(with: url,
completionHandler: completionHandler)
You update the return statement to use the passed-in completionHandler. Build and run the tests, and see them all pass.
Creating and using the session mocks
Next, you need to create test types for MockURLSession and MockURLSessionTask.
Under Test Types/Mocks, create a new Swift File called MockURLSession.swift. Replace its contents with:
@testable import DogPatch
import Foundation
// 1
class MockURLSession: URLSessionProtocol {
func makeDataTask(
with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionTaskProtocol {
return MockURLSessionTask(
completionHandler: completionHandler,
url: url)
}
}
// 2
class MockURLSessionTask: URLSessionTaskProtocol {
var completionHandler: (Data?, URLResponse?, Error?) -> Void
var url: URL
init(completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void,
url: URL) {
self.completionHandler = completionHandler
self.url = url
}
// 3
func resume() {
}
}
Here’s what you did:
-
You declare
MockURLSessionas conforming toURLSessionProtocoland create aMockURLSessionTaskfrommakeDataTask. -
You create
MockURLSessionTaskas conforming toURLSessionTaskProtocol, declare properties forurlandcompletionHandlerand set these within its initializer. This lets you use these in your tests. -
You implement
resumeas an empty method for now.
Instead of passing a real URLSession into DogPatchClient, you’ll pass an instance of MockURLSession.
To make it clear this is a mock, back in DogPatchClientTests.swift, right-click the session property, select Refactor -> Rename and change its name to mockSession. Next, replace the var mockSession line with the following, ignoring the compiler errors for now:
var mockSession: MockURLSession!
This code changes the mockSession variables type to MockURLSession, but it breaks the tests in a few places. First, you need to update where you set this property within setUp().
Replace the mockSession = line with:
mockSession = MockURLSession()
Next, within DogPatchClient.swift, you need to update the type of session. First, replace the let session line with:
let session: URLSessionProtocol
Next, replace the init(baseURL: URL, session: URLSession) declaration with:
init(baseURL: URL, session: URLSessionProtocol)
Back in DogPatchClientTests.swift, replace the contents of test_init_sets_session with:
XCTAssertTrue(sut.session === mockSession)
You use the identity operator === to assert that sut.session and mockSession are the same instance.
These changes resolve all of the compiler errors. Build and run the tests and verify they all pass.
Calling the right URL
Now, use MockURLSession to validate behavior within your tests!
Still in DogPatchClientTests, add the following test after the last one, ignoring the compiler error:
func test_getDogs_callsExpectedURL() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionTask
}
This test verifies getDogs calls a specific URL. However, it doesn’t compile because you haven’t declared getDogs yet in the production code. Add this after the other methods in DogPatchClient:
func getDogs(completion: @escaping
([Dog]?, Error?) -> Void) -> URLSessionTaskProtocol {
return session.makeDataTask(with: baseURL) { _, _, _ in }
}
This method calls session.makeDataTask(with:completionHandler:) using baseURL and an empty closure as dummy values. You need an assertion to verify the right URL is called. Open DogPatchClientTests.swift and add this to the end of test_getDogs_callsExpectedURL():
// then
XCTAssertEqual(mockTask.url, getDogsURL)
Build and run the tests, and you’ll see this fails. You can now write the code to call the correct URL.
Open DogPatchClient.swift, and replace the contents of getDogs(completion:) with the following:
let url = URL(string: "dogs", relativeTo: baseURL)!
return session.makeDataTask(with: url) { _, _, _ in }
Build and run the tests, and see them all pass.
URLSession doesn’t start a networking task after it’s created. Instead, you must call resume on the task. You need a test that verifies that you called this.
Before you write this test, in MockURLSession.swift, replace resume() on MockURLSessionTask with the following:
var calledResume = false
func resume() {
calledResume = true
}
You declare a new property for calledResume, which defaults to false, and set it to true within resume(). Now, you can write a test that uses this.
Open DogPatchClientTests.swift, and add the following after the last test:
func test_getDogs_callsResumeOnTask() {
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionTask
// then
XCTAssertTrue(mockTask.calledResume)
}
Build and run, and you’ll see this test fails as expected. To make it pass, in DogPatchClientTests.swift, replace the return line within getDogs(completion:) on DogPatchClient with:
let task = session.makeDataTask(with: url) {
data, response, error in
}
task.resume()
return task
Build and run your tests. You’ll see they pass now. There’s nothing to refactor so you can continue!
Handling error responses
Next, you need to handle error responses. Two scenarios indicate an error occurred:
-
The server returns an HTTP status code besides 200. This endpoint always returns 200 on success. If another status code returns, the request failed.
-
The request may never reach the server, may timeout or another error condition may happen at the networking layer. In this case, the
errorwill be set.
Start by writing a test that checks for the first scenario. Add the following test after the last one:
func test_getDogs_givenResponseStatusCode500_callsCompletion() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error
} as! MockURLSessionTask
mockTask.completionHandler(nil, response, nil)
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
XCTAssertNil(receivedError)
}
Here’s what you did:
- Within
given, you createresponseusinggetDogsURLand an HTTP status of 500 indicating a failure. - Within
when, you create variables to hold whether the completion closure was called and the return values. Then you call thecompletionHandleron themockTask. - Within
then, you assert the completion handler was called, and the received values for dogs and error arenil.
Build and run your tests. As expected, this test will fail because the completion handler isn’t called.
To fix this, add the following inside the closure for session.dataTask(with: url) within getDogs on DogPatchClient:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200 else {
completion(nil, error)
return
}
This guard statement checks that the status code is the expected 200 result and calls the completion handler if it isn’t.
Build and run your tests. Your test now passes. Do you see anything to refactor? Yep, getDogsURL is precisely the same in two tests.
To remove this duplication, add the following computed property right after the sut declaration in DogPatchClientTests:
var getDogsURL: URL {
return URL(string: "dogs", relativeTo: baseURL)!
}
Then delete the entire given section from test_getDogs_callsExpectedURL, and delete the let getDogsURL line from test_getDogs_givenResponseStatusCode500_callsCompletion.
Build and run your tests. They’ll continue to pass.
Next, you’ll deal with the other error scenario, when an error returns. Add the following test case to check for this:
func test_getDogs_givenError_callsCompletionWithError() throws {
// given
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let expectedError = NSError(domain: "com.DogPatchTests",
code: 42)
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error as NSError?
} as! MockURLSessionTask
mockTask.completionHandler(nil, response, expectedError)
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
let actualError = try XCTUnwrap(receivedError as NSError?)
XCTAssertEqual(actualError, expectedError)
}
Here’s what you did:
-
Within
given, you create aresponsewith astatusCodeof200and anexpectedError. It’s unlikely that you’ll have a “success” response code of 200 and also an error. However, perhaps the server is behaving incorrectly, or you’ve run into an edge case of some sort in the real world. Hey, server developers aren’t perfect either. Pragmatically though, this ensures your previousguardon thestatusCodedoesn’t trigger in this case. -
Within
when, you set variables to check whether the completion was called and what values were received. Then, you call thecompletionHandleron themockTaskwith theresponseandexpectedErrorfrom before. -
Within
then, you assert the completion is called, the received dogs areniland the error matches what you expect.
Build and run your tests. The assertions for calledCompletion and unwrapping receivedError fail, which makes sense since you haven’t written this code yet.
You can also temporarily change the assignment of receivedDogs to an empty array to prove that XCTAssertNil(receivedDogs) fails, but be sure to set this property back to nil before continuing.
To make all the asserts pass, replace the entire guard line within getDogs on DogPatchClient with:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil else {
Build and run your tests. They’ll all pass now. However, now there’s a lot of code duplication between this test and the previous one.
To fix this, pull out a helper method for the common code. Add the following method right after tearDown, since it’s called from several tests:
func whenGetDogs(
data: Data? = nil,
statusCode: Int = 200,
error: Error? = nil) ->
(calledCompletion: Bool, dogs: [Dog]?, error: Error?) {
let response = HTTPURLResponse(url: getDogsURL,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil)
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
let mockTask = sut.getDogs() { dogs, error in
calledCompletion = true
receivedDogs = dogs
receivedError = error as NSError?
} as! MockURLSessionTask
mockTask.completionHandler(data, response, error)
return (calledCompletion, receivedDogs, receivedError)
}
Here’s how this works:
-
This method accepts inputs for
data,statusCodeanderror. As a convenience, you also provide appropriate default values for each. It returns a tuple with values forcalledCompletion,dogsanderror. -
It creates the
responseusinggetDogsURLand the passed-instatusCode. -
Then, it creates local variables, calls
getDogsonsutand calls thecompletionHandleronmockTask, as the previous tests did. -
Finally, the method returns the tuple created from the local variables for
calledCompletion,receivedDogsandreceivedError.
You can use this method to remove the duplicate code from your tests. First, replace the contents of test_getDogs_givenResponseStatusCode500_callsCompletion with:
// when
let result = whenGetDogs(statusCode: 500)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
XCTAssertNil(result.error)
This method is simplified because the bulk of the work now happens within whenGetDogs.
Next, replace the contents of test_getDogs_givenError_callsCompletionWithError with this:
// given
let expectedError = NSError(domain: "com.DogPatchTests",
code: 42)
// when
let result = whenGetDogs(error: expectedError)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
let actualError = try XCTUnwrap(result.error as NSError?)
XCTAssertEqual(actualError, expectedError)
This method is also simplified and now only handles the parts unique to setting up the expectedError and testing that it returns correctly.
Build and run the tests, and they’ll all continue to pass. That was a great refactor, and your upcoming tests will make good use of this helper method!
Deserializing models on success
You’re finally ready to handle the happy-path case: handling a successful response.
Before you do, there’s a convenience extension already in the project that you should know about. Under DogPatchTests/Test Types/Extensions, open Data+JSONFile.swift. You’ll see fromJSON(fileName:file:line:), a static method for getting Data from a file.
This method is for tests. If the file can’t be found, then it will fail an assertion and throw an exception. For example, this might happen if the file wasn’t added or the wrong file name is input into the method.
Further, a kind developer colleague already provided a test data file called GET_Dogs_Response.json for you within DogPatchTests/Data. You’re welcome. ;]
Armed with this infomation, you’re ready to write the happy-path test! Add the following right after the previous test in DogPatchClientTests:
func test_getDogs_givenValidJSON_callsCompletionWithDogs()
throws {
// given
let data =
try Data.fromJSON(fileName: "GET_Dogs_Response")
let decoder = JSONDecoder()
let dogs = try decoder.decode([Dog].self, from: data)
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertEqual(result.dogs, dogs)
XCTAssertNil(result.error)
}
Here’s what this code does:
-
First, you create
databy callingData.fromJSONwith the given JSON filename. -
You create a new
decoderof typeJSONDecoderand use it to decode thedata. This is possible becauseDogalready conforms toDecodableand has tests verifying it works in DogTests.swift. -
Then, you call
whenGetDogslike the other tests, but this time, you passdatainto it. -
Finally, you assert that the completion is called,
dogsis equal to theresult.dogsand theresult.errorisnil.
Build and run your tests and, as expected, you’ll see that this test fails. To make it pass, replace the guard statement within getDogs(completion:) in DogPatchClient with:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil,
let data = data else {
The difference here is that you add let data as the condition for the guard to pass.
Then add the following after the guard block’s closing curly brace:
let decoder = JSONDecoder()
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs, nil)
The try! statement here looks dangerous, and it definitely is! However, this is the minimum amount of code to make the test pass, and it’s an indicator that you need another test.
Build and run the unit tests, and they’ll all pass. There isn’t any refactoring to do, but you need to get rid of that try!.
Under what condition would this try! be a problem? If the server returned a 200 response, but the JSON couldn’t be parsed into Dogs, this would cause the app to crash.
Fortunately, this is exactly the type of problem that unit tests can catch and help you prevent. Add the following test after the previous test to produce this exact scenario:
func test_getDogs_givenInvalidJSON_callsCompletionWithError()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_MissingValuesResponse")
var expectedError: NSError!
let decoder = JSONDecoder()
do {
_ = try decoder.decode([Dog].self, from: data)
} catch {
expectedError = error as NSError
}
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
let actualError = try XCTUnwrap(result.error as NSError?)
XCTAssertEqual(actualError.domain, expectedError.domain)
XCTAssertEqual(actualError.code, expectedError.code)
}
Here’s a code breakdown:
-
You set the
datafromGET_Dogs_MissingValuesResponse. This is a valid JSON array, but it’s missing anidthat’s required to deserialize aDogobject. -
Then, you create a
decoderof typeJSONDecoderand attempt to deserialize thedata. You capture the error that’s thrown asexpectedError. -
You call
whenGetDogs, and assert that the completion was called, the returned dogs areniland the error has the samedomainandcodeas theexpectedError. You must cast toNSErrorbecauseErrorobjects aren’t directly comparable. By casting toNSError, you can compare thedomainandcodefor the errors to one another, which is “good enough” to show it’s the same error.
Build and run the tests. Not only does this test fail, it crashes! Well, it’s good you caught this doing TDD rather than after the code shipped to production, right?
To fix this issue, replace these lines within DogPatchClient:
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs)
With this code instead:
do {
let dogs = try decoder.decode([Dog].self, from: data)
completion(dogs, nil)
} catch {
completion(nil, error)
}
Build and rerun your unit tests. See that they all now pass.
Dispatching to a response queue
Your DogPatchClient handles networking like a boss! There’s just one problem: You’ve been mocking URLSessionTask to avoid making real networking calls, but unfortunately, you’ve also masked a behavior of URLSessionTask.
URLSessionTask calls its closure on a background queue, which is problematic because the app needs to perform UI operations using the Dogs or Error result, which happens on the Main queue.
While you could leave it to the caller to dispatch to the main queue, this only pushes the problem down the line and makes the networking client harder to use. A better design is to have DogPatchClient accept a responseQueue and handle dispatching. You can even do this without breaking your existing unit tests by making the responseQueue optional.
Adding a response queue
Add the following test right after test_init_sets_session(), ignoring the compiler error for now:
func test_init_sets_responseQueue() {
// given
let responseQueue = DispatchQueue.main
// when
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: responseQueue)
}
Since you haven’t defined responseQueue on DogPatchClient, this test currently compiles. Ah, you did this dance earlier! ;]
To fix the error, add the following property to DogPatchClient after the others:
let responseQueue: DispatchQueue? = nil
Then replace the signature for init with this, ignoring the resulting compiler error in the tests:
init(baseURL: URL,
session: URLSessionProtocol,
responseQueue: DispatchQueue?)
The tests don’t compile because you need to update setting sut in setUp. Replace that line with:
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: nil)
Finally, add this code to the end of test_init_sets_responseQueue():
// then
XCTAssertEqual(sut.responseQueue, responseQueue)
Build and run the tests, and as expected, this test fails. To fix it, replace the let responseQueue line within DogPatchClient with:
let responseQueue: DispatchQueue?
Then this line within init:
self.responseQueue = responseQueue
Build and rerun your tests, and they now pass.
Updating the mocks
Next, you need to update MockURLSession and MockURLSessionTask to call the completion handler on a dispatch queue. In MockURLSession.swift, add this new property to MockURLSession:
var queue: DispatchQueue? = nil
Next, add this method right below it:
func givenDispatchQueue() {
queue = DispatchQueue(label: "com.DogPatchTests.MockSession")
}
The existing tests don’t need this queue, so you only call this for the new tests you’ll add next.
You also need to change the initializer for MockURLSessionTask. Replace it with the following, ignoring the compiler error for now:
init(completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void,
url: URL,
queue: DispatchQueue?)
Then, replace this line within init:
self.completionHandler = completionHandler
With this code instead:
if let queue = queue {
self.completionHandler = { data, response, error in
queue.async() {
completionHandler(data, response, error)
}
}
} else {
self.completionHandler = completionHandler
}
If a queue passes into the initializer, you set self.completionHandler to dispatch asynchronously to queue before calling completionHandler. This is similar to the way a real URLDataTask dispatches to a dispatch queue.
To fix the compiler error, replace the return statement within makeDataTask on MockURLSession with:
return MockURLSessionTask(
completionHandler: completionHandler,
url: url,
queue: queue)
This code passes the queue into the new initializer on MockURLSessionTask.
Build and run your unit tests. Since none of the tests depend on which queue the completion handler is called, they all continue to pass.
Handling dispatch scenarios
Next, you need to verify that completionHandler dispatches to the responseQueue, which should happen in these cases:
- An HTTP status code indicates a failure response.
- An HTTP error is received.
- A valid JSON response is received and successfully deserialized.
- An invalid JSON response is received, and deserialization fails.
For the first case, in DogPatchClientTests.swift, add the following test after the existing ones:
func test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(
description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(nil, response, nil)
// then
waitForExpectations(timeout: 0.1) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
Here’s how this code works:
- In the
givensection, you callmockSession.givenDispatchQueueto set thequeueonmockSession. It, in turn, uses this to create aMockURLSessionTask. You also create thesut, passing in.mainas theresponseQueue. Finally, you create anexpectation, which you’ll use later to wait for thecompletionHandlerto be called.
Technically, you could use any responseQueue. Pragmatically, you need to dispatch the completion handler to the main queue. Sadly, iOS makes it difficult to check which queue the code is currently running on… Oh, Apple! Don’t you know we need this for unit tests?!
Fortunately, it’s easy to validate that the current Thread is the main thread, and the main dispatch queue always runs on the main thread. Hence, your tests rely on this fact to validate the code was “dispatched to the main queue.”
In reality, of course, you’re technically checking that the code runs on the main Thread. However, short of Apple making it easier to test and validate which dispatch queue is in use, this is “good enough.”
- In
when, you first create a local variable forthreadand then callsut.getDogs(). In its completion handler, you setthreadand fulfill theexpectation.
You then create a response variable with an error status code of 500 and use this to call the completionHandler.
- In
then, you callwaitForExpectationsto wait on theexpectationto be fulfilled. Inside the wait handler, you assert that thethreadis the main thread.
Whoo, that was a bit of a whopper test! Build and run the unit tests, and you’ll see that this test fails because you aren’t currently dispatching to the responseQueue on DogPatchClient.
To fix this, first replace this line within getDogs(completion:) on DogPatchClient:
let task = session.makeDataTask(with: url) {
data, response, error in
With the following code, ignoring the warning:
let task = session.makeDataTask(with: url) { [weak self]
data, response, error in
guard let self = self else { return }
By using [weak self] and guard let self in this manner, you avoid creating the strong reference cycle that’s possible if you instead reference self directly.
Next, inside the guard let response closure, replace the first instance of this code, leaving the other two completion sites unchanged:
completion(nil, error)
With this code:
guard let responseQueue = self.responseQueue else {
completion(nil, error)
return
}
responseQueue.async {
completion(nil, error)
}
This code checks if the responseQueue is set and dispatches the call to the completion if so.
Build and run the unit tests, and watch them all pass. There’s nothing to refactor yet, so you can move on to testing the next scenario: ensuring an HTTP error dispatches on the response queue.
Add the following test after the previous one:
func test_getDogs_givenError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(
description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let error = NSError(domain: "com.DogPatchTests", code: 42)
mockTask.completionHandler(nil, response, error)
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
This test is very similar to the previous one. However, in the when section, you pass an error into the mockTask.completionHandler.
Build and run your tests, and, surprisingly, this test actually passes! What’s up with that?
In getDogs, you’ll see that the check for an error and the HTTP status code are part of the same guard statement, which looks like this:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil,
let data = data else {
As a consequence, this coincidentally already dispatches the error to the responseQueue.
Does this mean this test isn’t useful? No, it’s still useful. If you refactor this code later, and this check isn’t combined in the same guard like it currently is, you still want to ensure that the error dispatches on the responseQueue. So, leave this test as is and move on to refactoring.
There is indeed code to refactor here!
There’s a huge amount of duplicated code between these two tests. To fix this, add the following helper method towards the top of the file, right after whenGetDogs(...):
func verifyGetDogsDispatchedToMain(data: Data? = nil,
statusCode: Int = 200,
error: Error? = nil,
line: UInt = #line) {
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
let expectation = self.expectation(
description: "Completion wasn't called")
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(data, response, error)
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread, line: line)
}
}
This method accepts inputs for data, statusCode and error. These will vary depending on the actual behavior the test wants to verify. It also accepts an input for line, which ensures XCTAssertTrue attributes a failure to the test method’s line number instead of this helper method itself.
Now you can use this helper method to get rid of the duplicated code. Replace the contents of test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue with:
verifyGetDogsDispatchedToMain(statusCode: 500)
Next, replace the contents of test_getDogs_givenError_dispatchesToResponseQueue with:
// given
let error = NSError(domain: "com.DogPatchTests", code: 42)
// then
verifyGetDogsDispatchedToMain(error: error)
That’s much more readable and compact! Build and run the unit tests, and see they continue to pass.
The next test scenario you need to cover is ensuring a valid response dispatches to the response queue. Add the following test after the last one:
func test_getDogs_givenGoodResponse_dispatchesToResponseQueue()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_Response")
// then
verifyGetDogsDispatchedToMain(data: data)
}
Nice! You make great use of your helper methods to write compact tests. Build and run the tests, and you’ll see this test fails.
To fix this, replace this line within getDogs on DogPatchClient
completion(dogs, nil)
With the following:
guard let responseQueue = self.responseQueue else {
completion(dogs, nil)
return
}
responseQueue.async {
completion(dogs, nil)
}
Similar to how you handled the error, this code checks for a responseQueue and dispatches dogs to it if so.
Build and run the tests. They now pass. However, now there’s duplicate logic in DogPatchClient that you need to eliminate. To do so, add the following helper method right after getDogs:
private func dispatchResult<Type>(
models: Type? = nil,
error: Error? = nil,
completion: @escaping (Type?, Error?) -> Void) {
guard let responseQueue = responseQueue else {
completion(models, error)
return
}
responseQueue.async {
completion(models, error)
}
}
You can use this method with any model because it uses a generic Type and accepts inputs for models, error and completion. Regardless of inputs, it always checks if there’s a responseQueue and dispatches the completion to it. If there’s no responeQueue, it merely calls the completion with the inputs.
You can use this code to get rid of the duplicate code now. First, replace this code:
guard let responseQueue = self.responseQueue else {
completion(nil, error)
return
}
responseQueue.async {
completion(nil, error)
}
With the following:
self.dispatchResult(error: error, completion: completion)
Next, replace these lines:
guard let responseQueue = self.responseQueue else {
completion(dogs, nil)
return
}
responseQueue.async {
completion(dogs, nil)
}
With the following:
self.dispatchResult(models: dogs, completion: completion)
Build and run your tests, and they still pass.
Finally, you need to verify that if an invalid JSON response is received, it’s also dispatched to the response queue. Add the following test:
func test_getDogs_givenInvalidResponse_dispatchesToResponseQueue()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_MissingValuesResponse")
// then
verifyGetDogsDispatchedToMain(data: data)
}
Build and run your tests, and you’ll see this fails as anticipated. To fix this, replace this line within getDogs in DogPatchClient:
completion(nil, error)
With the following:
self.dispatchResult(error: error, completion: completion)
Build and run your tests, and see them all pass. You did a great job refactoring already, so there’s also nothing left to do refactor-wise here.
And guess what else? You just completed TDDing your networking client! Great job!
Key points
In this chapter, you learned how to do TDD for a networking client. Here’s a recap of what you learned:
-
Avoid making real networking calls in your unit tests by mocking
URLSessionandURLSessionTask. -
Do TDD for GET requests easily by breaking them into several smaller tasks: Calling the right URL, handling HTTP status errors, handling valid and invalid responses.
-
Be careful about mocking
URLSessionTask’s dispatch behavior to an internal queue. You can work around this by creating your own dispatch queue on your mocks. -
Dispatch to a response queue to make it easier for consumers to use your networking client.
You’re one step closer to displaying those cute pups onscreen! In the next chapter, you’ll learn how to do TDD for consuming the networking client in your view controller.