8.
RESTful Networking
Written by Joshua Greene
You’ll learn how to TDD a RESTful networking client in this chapter. Specifically, you will:
- 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, and 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 have already been added for you. Here are the important ones for this chapter:
-
Controllers/ListingsViewController.swift contains the view controller that displays the fetched
DogsorError. -
Models/Dog.swift contains the
Dogmodel that represents each pup.
You’ll also see an empty group for Networking. This contains the networking client and related types.
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 it will never finish.
Open ListingsViewController.swift, and you’ll see tableView(_:numberOfRowsInSection:) is hardcoded to return 1.
Within tableView(_:cellForRowAt:), it performs a check to see if viewModels.count is greater than zero. This will always be false because the app isn’t setting the viewModels. Rather, it needs to create these from a networking response.
However, there’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 write this as a one-off networking 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 – this is 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!
}
You’ve created a new test class for DogPatchClientTests with a single property for sut of type DogPatchClient. Since you haven’t actually created DogPatchClient, however, 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 {
}
You’ve declared a new class for DogPatchClient and this, in turn, fixes the compiler error. There’s nothing to refactor, so you can simply move onto your first test method.
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)
}
You’d ultimately like to test that the baseURL, which is passed into the initializer, matches sut.baseURL. However, you haven’t actually created this initializer, so this doesn’t compile. To fix this, open DogPatchClient.swift add the following to DogPatchClient:
let baseURL = URL(string: "https://example.com/")!
init(baseURL: URL) {
}
You here declare the baseURL, set it to an arbitrary value for now and then create init(baseURL:). This is enough to get the test to compile, but you haven’t actually asserted anything yet. Open DogPatchClientTests.swift and add the following to the end of the test method:
// then
XCTAssertEqual(sut.baseURL, baseURL)
This assertion sets the expectation that sut.baseURL should equal the argument passed to the initializer. Build and run the unit tests, and you’ll see this test fails as expected. To get this to pass, replace the line for let baseURL = within DogPatchClient with the following:
let baseURL: URL
Next, add the following to init(baseURL:):
self.baseURL = baseURL
Now the baseURL instance property is set by the initializer. Build and run your tests, and this should now pass. There isn’t anything to refactor, so you can continue.
You’re also going to need a property for URLSession, which you’ll use to making 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 set another property. Just like before, you haven’t declared the property for session, so this doesn’t compile. To fix this, add the following property right after baseURL in DogPatchClient:
let session: URLSession = URLSession(configuration: .default)
Next, update the method signature for init(baseURL:) to the following:
init(baseURL: URL, session: URLSession)
This allows test_init_sets_session() to compile, but it breaks test_init_sets_baseURL(). To fix this, add this line right below the let baseURL line within test_init_sets_baseURL():
let session = URLSession.shared
Next, update the line for sut = to the following:
sut = DogPatchClient(baseURL: baseURL, session: session)
Your tests should now compile again, but you haven’t actually added an assertion to test_init_sets_session(). Add the following to the end of the test method:
// then
XCTAssertEqual(sut.session, session)
Build and run your tests and, as expected, this test fails. To make it pass, change the property declaration for session within DogPatchClient to the following:
let session: URLSession
Then, add this line to the end of the initializer:
self.session = session
Build and run the tests, and they should 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 you nil each within tearDown. This sets you up to reduce the redundancy of your two tests.
You can now get rid of the duplicate logic within the test methods. Replace the contents of test_init_sets_baseURL() with the following:
XCTAssertEqual(sut.baseURL, baseURL)
Then, replace the contents of test_init_sets_session() with the following:
XCTAssertEqual(sut.session, session)
Build and run the tests, and each should still pass.
Excellent job, you’ve declared two properties! OK, maybe it’s not that exciting. However, these properties are important to making networking calls, and you can actually write that code now!
TDDing the networking call
You’ll 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:
- Calling the right URL.
- Handling error responses.
- Deserializing models on success.
- Handling invalid responses.
Calling the right URL
You’ll start by ensuring that you call the right URL. Unfortunately, URLSession doesn’t actually have a way to check which URL was called. The easiest way to do this is by mocking URLSession through subclassing it. To prevent any actual networking calls from being made in your unit tests, you’ll also mock URLSessionDataTask.
Add these additional new subclasses after DogPatchClientTests in DogPatchClientTests.swift:
// 1
class MockURLSession: URLSession {
override func dataTask(
with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionDataTask {
return MockURLSessionDataTask(
completionHandler: completionHandler,
url: url)
}
}
// 2
class MockURLSessionDataTask: URLSessionDataTask {
var completionHandler: (Data?, URLResponse?, Error?) -> Void
var url: URL
init(completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void,
url: URL) {
self.completionHandler = completionHandler
self.url = url
super.init()
}
// 3
override func resume() {
// don't do anything
}
}
Here’s what you’ve done:
-
You create
MockURLSessionas a subclass ofURLSessionand overridedataTask(with url:, completionHandler:)to return aMockURLSessionDataTask. -
You create
MockURLSessionDataTaskas a subclass ofURLSessionDataTask, declare properties forurlandcompletionHandlerand set these within its initializer. This will allow you to use these values within your tests. -
To ensure
MockURLSessionDataTasknever makes any real network requests, you overrideresume()to do nothing.
Instead of passing a real URLSession into DogPatchClient, you’ll pass an instance of MockURLSession.
To make it clear this is a mock. Right-click on the session property within DogPatchClientTests, select Refactor -> Rename and change its name to mockSession. Then, replace the var mockSession line with the following, ignoring the compiler error for now:
var mockSession: MockURLSession!
This changes its type to MockURLSession, but you also need to update where its set within setUp(). Replace the mockSession = line within setUp with the following:
mockSession = MockURLSession()
You can now use this property within a test. Add the following test right after the existing ones, ignoring the compiler error:
func test_getDogs_callsExpectedURL() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionDataTask
}
As the name implies, this test will make sure that the getDogs method calls a specific URL. This test doesn’t compile because you haven’t declared getDogs yet. Add the following to DogPatchClient to do so:
func getDogs(completion:
@escaping ([Dog]?, Error?) -> Void) -> URLSessionDataTask {
return session.dataTask(with: baseURL) { _, _, _ in }
}
This method calls session.dataTask(with:completionHandler:) to make your test code compile.
You now need a failing test assertion to verify the right URL is called. Add the following to the end of the test method:
// then
XCTAssertEqual(mockTask.url, getDogsURL)
This test assertion fails, so you can now write the production code to call the correct URL. Replace the contents of getDogs(completion:) within DogPatchClient with the following:
let url = URL(string: "dogs", relativeTo: baseURL)!
return session.dataTask(with: url) { _, _, _ in }
Build and run the tests, and they should all pass.
URLSession doesn’t start a networking task after its created. Instead, you’re required to call resume on the task to begin it.
You need a test method that verifies this is done. Before you can write this, replace resume() within MockURLSessionDataTask with the following:
var calledResume = false
override func resume() {
calledResume = true
}
Here, you declare a new Boolean for calledResume, which defaults to false, and you set it to true within resume(). You can now write a test method that uses this. Add the following after the last test method:
func test_getDogs_callsResumeOnTask() {
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionDataTask
// then
XCTAssertTrue(mockTask.calledResume)
}
Build and run, and you’ll see this test fails as expected. To make it pass, replace the return line within getDogs(completion:) on DogPatchClient with the following:
let task =
session.dataTask(with: url) { data, response, error in }
task.resume()
return task
Build and run your tests, and they should pass now. There’s nothing to refactor here, so let’s continue!
Handling error responses
Your next task is to handle error responses. There are two scenarios that indicate an error occurred:
-
The server returns an HTTP status code besides 200. This endpoint always returns 200 if it is successful. 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. The
errorwill be set in this case.
You’ll 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! MockURLSessionDataTask
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, and you then 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 and, 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 will call the completion handler if it isn’t. Build and run your tests, and your test should now pass. Do you see anything to refactor? Yep, getDogsURL is exactly 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, and they should all continue to pass.
The other error scenario you need to handle is if there’s an error that’s returned. 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! MockURLSessionDataTask
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 aresponsethat has astatusCodeof200and anexpectedError. It’s unlikely that you’ll have a “success” response code of 200 and also an error. But 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 thestatusCodeisn’t triggered in this case. -
Within
when, you setup variables to check whether the completion was called and what values were received. Then, you call thecompletionHandleron themockTaskwith theresponseandexpectedErrorfrom before. -
Within
then, you assert that the completion is called, the received dogs arenil, and the error matches what you expect.
Build and run your tests, and you’ll see the assertions for both calledCompletion and unwrapping receivedError fail, which is expected as you haven’t written this code yet. You can also temporarily change the assignment of receivedDogs to an empty array of Dog to prove that XCTAssertNil(receivedDogs) fails, but be sure to set this property back to nil before continuing on.
To have all the asserts pass, replace the entire guard line within getDogs on DogPatchClient with the following:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil else {
Build and run your tests, and they should all pass now. However, there’s now a lot of code duplication between this test and the previous one.
To fix this, you’ll pull out a helper method for the common code. Add the following method right after tearDown, as it will be 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! MockURLSessionDataTask
mockTask.completionHandler(data, response, error)
return (calledCompletion, receivedDogs, receivedError)
}
Here’s how this works:
-
This method accepts inputs for
data,statusCodeanderror, and 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. -
It then creates local variables, calls
getDogsonsutand calls thecompletionHandleronmockTask, just like the previous test methods were doing. -
Finally, it returns the tuple created from the local variables for
calledCompletion,receivedDogsandreceivedError.
You can use this method to remove the duplicate code from your test methods. First, replace the contents of test_getDogs_givenResponseStatusCode500_callsCompletion with the following:
// when
let result = whenGetDogs(statusCode: 500)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
XCTAssertNil(result.error)
This method is greatly 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 likewise greatly simplified, and it now only handles the parts that are unique to setting up the expectedError and testing that it’s returned correctly.
Build and run the tests, and they should all continue to pass. That was a great refactor, and your upcoming tests will definitely make good use of this helper method!
Deserializing Dog models
You’re finally ready to handle the happy-path case, handling a successful response.
Before you do, there’s a convenience extension that is already in the project that you should know about. Open Data+JSONFile.swift, and you’ll see it has a static method for getting Data from a file, fromJSON(fileName:file:line:).
This is intended to be used from test methods. If the file cannot be found, then it will fail an assertion and throw an exception via XCTUnwrap. For example, if it wasn’t added or the wrong file name is input into the method.
Further, a kind developer colleague – you’re welcome ;] – has already provided a test data file called GET_Dogs_Response.json for you.
Armed with this info, you’re ready to write the happy-path test! Add the following right after the previous test:
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 does:
-
You first create
databy callingData.fromJSONwith the given JSON filename. -
You create a new
decoderof typeJSONDecoder, use it to decode thedata. This is possible becauseDogalready conforms toDecodable, and it already has tests verifying it works within DogTests.swift. -
You then call
whenGetDogsjust like the other test methods, but this time, you passdatainto it. -
You lastly assert that the completion is called,
dogsis equal to theresult.dogs, and 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 the following:
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
error == nil,
let data = data else {
The difference here is that you’ve added 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 another test is needed.
Build and run the unit tests, and they should 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 could not 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 what this does:
-
You set the
datafrom the fileGET_Dogs_MissingValuesResponse. This is a valid JSON array, but it’s missing anidthat’s required to deserialize aDogobject. -
You then create a
decoderof typeJSONDecoderand attempt to deserialize thedata. You capture the error that’s thrown asexpectedError. -
You call
whenGetDogsand then assert that the completion was called, the returned dogs arenil, and the error has the samedomainandcodeas theexpectedError. The cast toNSErroris required becauseErrorobjects aren’t directly comparable. By casting toNSError, you can compare thedomainandcodefor the errors to one another, which is “good enough” to show its the same error.
Build and run the tests. Not only does this test fail, but it also crashes! Well, it’s good you caught this doing TDD rather than after the code had 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 the following 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, and they should all now pass.
Dispatching to a response queue
Your DogPatchClient is handling networking like a boss! There’s just one problem – you’ve been mocking URLSessionDataTask to prevent real networking calls from being made, but unfortunately, you’ve also masked a behavior of URLSessionDataTask.
You see, URLSessionDataTask actually calls its closure on a background queue. This is problematic because the app will need to perform UI operations using the Dogs or Error result, and this must be done on the Main queue.
While you could leave it to the consumer to dispatch to the main queue, this just pushes the problem off and makes the networking client harder to consume. A better design is to have DogPatchClient accept a responseQueue and have it handling 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 actually defined responseQueue on DogPatchClient, this test doesn’t currently compile. Ah, you did this dance just 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 unit tests:
init(baseURL: URL,
session: URLSession,
responseQueue: DispatchQueue?)
The unit tests don’t compile because you need to update setting sut in setUp. Replace that line with this:
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: nil)
Finally, add the following code to the end of test_init_sets_responseQueue():
// then
XCTAssertEqual(sut.responseQueue, responseQueue)
Build and run the tests, and as expected, this new test method should fail. To fix it, replace the let responseQueue line within DogPatchClient with this:
let responseQueue: DispatchQueue?
Next, add this line within init:
self.responseQueue = responseQueue
Build and rerun your tests, and they should all now pass.
Updating the mocks
You next need to update MockURLSession and MockURLSessionDataTask to call the completion handler on a dispatch queue. First, 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 test methods won’t need this queue, so you’ll call this only for the new test methods you’ll add next.
You also need to change the initializer signature for MockURLSessionDataTask. 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:
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 is passed into this 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 dataTask(url:completionHandler:) on MockURLSession with the following:
return MockURLSessionDataTask(
completionHandler: completionHandler,
url: url,
queue: queue)
This passes the queue into the new initializer on MockURLSessionDataTask.
Build and run your unit tests. Since none of the tests depend on which queue the completion handler is called, they should all continue to pass.
Handling dispatch scenarios
You next need to verify that completionHandler is dispatched to the responseQueue. This 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, 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! MockURLSessionDataTask
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(nil, response, nil)
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
Here’s how this code works:
-
Within the
givensection, you callmockSession.givenDispatchQueueto set thequeueonmockSession, which it will in turn use to create aMockURLSessionDataTask. You also create thesut, passing in.mainas theresponseQueueintoDogPatchClient. Lastly, you create anexpectation, which you’ll later use to wait on thecompletionHandlerto be called.Technically, you could have used any
responseQueue. Pragmatically, however, the completion handler will need to be dispatched to themainqueue. Sadly, iOS makes it difficult to get 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
Threadis themainthread, and the main dispatch queue is always run on the main thread. Hence, your tests will 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 is run on the mainThread. Short of Apple making it easier to test and validate which dispatch queue is used; this is “good enough.” -
Within
when, you first create a local variable forthread, and you then callsut.getDogs(). Within its completion handler, you setthreadand fulfill theexpectation.You then create a
responsevariable with an error status code of500, and you use this to call thecompletionHandler. -
Within
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 there! 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, replace this line within getDogs(completion:) on DogPatchClient:
let task = session.dataTask(with: url) {
data, response, error in
With the following code, ignoring the warning for now:
let task = session.dataTask(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 prevent creating a strong reference cycle that’s possible if you had instead referenced self directly.
Next, replace the first instance of this code, which is found within the guard let response closure:
completion(nil, error)
With the following code:
guard let responseQueue = self.responseQueue else {
completion(nil, error)
return
}
responseQueue.async {
completion(nil, error)
}
This checks if the responseQueue is set and dispatches the call to the completion if so.
Build and run the unit tests, and they should all pass. There’s nothing to refactor yet, so you can simply move onto testing the next scenario – ensuring an HTTP error is dispatched 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! MockURLSessionDataTask
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. The difference is the when section, wherein you pass an error into the mockTask.completionHandler.
Build and run your tests and, surprisingly, this test actually passes! What’s up with that?
Within getDogs, you’ll see that the check for an error and the HTTP status code is actually 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 later refactor this code, and this check isn’t combined in the same guard like it currently is, you still want to ensure that the error is dispatched on the responseQueue. So, you can simply leave this test as is and move onto refactoring.
There is indeed code to be refactored 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! MockURLSessionDataTask
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 that the test method is wanting to verify. It also accepts an input for line, which is used to ensure XCTAssertTrue attributes a failure to the test method line number, instead of this helper method itself.
You can now use this helper method to get rid of the duplicated code. Replace the contents of test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue with this:
verifyGetDogsDispatchedToMain(statusCode: 500)
Next, replace the contents of test_getDogs_givenError_dispatchesToResponseQueue with this:
// 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 they should all continue to pass.
The next test scenario you need to cover is ensuring a valid response is dispatched 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’re making great use of your helper methods in writing compact tests. Build and run the tests, and you’ll see this test method fails.
To fix this, replace this line within getDogs on DogPatchClient
completion(dogs, nil)
With this code:
guard let responseQueue = self.responseQueue else {
completion(dogs, nil)
return
}
responseQueue.async {
completion(dogs, nil)
}
Similarly to how you handled the error, this code checks if there is a responseQueue and dispatches dogs to it if so.
Build and run the tests, and they should all now pass. However, there’s now duplicate logic in DogPatchClient that you need to eliminate next. 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)
}
}
To allow this method to be used with any model, 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 not a responeQueue, it merely calls the completion with the inputs.
You can use this 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 should all still pass.
There’s just one more scenario you need to verify is dispatched to the response queue: If an invalid response is received. Add the following test for this:
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 they should all pass. You’ve done 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. Let’s recap what you learned:
-
Avoid making real networking calls in your unit tests by mocking
URLSessionandURLSessionDataTask. -
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
URLSessionDataTask’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.