Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

First Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

8. RESTful Networking
Written by Joshua Greene

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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 Dogs or Error.

  • Models/Dog.swift contains the Dog model 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.

@testable import DogPatch
import XCTest

class DogPatchClientTests: XCTestCase {
  var sut: DogPatchClient!
}
import Foundation

class DogPatchClient {

}
func test_init_sets_baseURL() {
  // given
  let baseURL = URL(string: "https://example.com/api/v1/")!
  
  // when
  sut = DogPatchClient(baseURL: baseURL)
}
let baseURL = URL(string: "https://example.com/")!

init(baseURL: URL) {

}
// then
XCTAssertEqual(sut.baseURL, baseURL)
let baseURL: URL
self.baseURL = baseURL
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)
}
let session: URLSession = URLSession(configuration: .default)
init(baseURL: URL, session: URLSession)
let session = URLSession.shared
sut = DogPatchClient(baseURL: baseURL, session: session)
// then
XCTAssertEqual(sut.session, session)
let session: URLSession
self.session = session
var baseURL: URL!
var session: URLSession!
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()
}
XCTAssertEqual(sut.baseURL, baseURL)
XCTAssertEqual(sut.session, session)

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

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.

// 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
  }
}
var mockSession: MockURLSession!
mockSession = MockURLSession()
func test_getDogs_callsExpectedURL() {
  // given
  let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!

  // when
  let mockTask = sut.getDogs() { _, _ in } 
    as! MockURLSessionDataTask
}
func getDogs(completion:
  @escaping ([Dog]?, Error?) -> Void) -> URLSessionDataTask {
  return session.dataTask(with: baseURL) { _, _, _ in }
}
// then
XCTAssertEqual(mockTask.url, getDogsURL)
let url = URL(string: "dogs", relativeTo: baseURL)!
return session.dataTask(with: url) { _, _, _ in }
var calledResume = false
override func resume() {
  calledResume = true
}
func test_getDogs_callsResumeOnTask() {
  // when
  let mockTask = sut.getDogs() { _, _ in } 
    as! MockURLSessionDataTask
  
  // then
  XCTAssertTrue(mockTask.calledResume)
}
let task = 
  session.dataTask(with: url) { data, response, error in }
task.resume()
return task

Handling error responses

Your next task is to handle error responses. There are two scenarios that indicate an error occurred:

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)
}
guard let response = response as? HTTPURLResponse, 
  response.statusCode == 200 else {
  completion(nil, error)
  return
}
var getDogsURL: URL {
  return URL(string: "dogs", relativeTo: baseURL)!
}
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)
}
guard let response = response as? HTTPURLResponse,
  response.statusCode == 200,
  error == nil else {
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)
}
// when
let result = whenGetDogs(statusCode: 500)

// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
XCTAssertNil(result.error)
// 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)

Deserializing Dog models

You’re finally ready to handle the happy-path case, handling a successful response.

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)
}
guard let response = response as? HTTPURLResponse,
  response.statusCode == 200,
  error == nil,
  let data = data else {
let decoder = JSONDecoder()
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs, nil)
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)
}
let dogs = try! decoder.decode([Dog].self, from: data)
completion(dogs)
do {
  let dogs = try decoder.decode([Dog].self, from: data)
  completion(dogs, nil)
} catch {
  completion(nil, error)
}

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.

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)  
}
let responseQueue: DispatchQueue? = nil
init(baseURL: URL,
     session: URLSession,
     responseQueue: DispatchQueue?)
sut = DogPatchClient(baseURL: baseURL,
                     session: mockSession,
                     responseQueue: nil)
// then
XCTAssertEqual(sut.responseQueue, responseQueue)
let responseQueue: DispatchQueue?
self.responseQueue = responseQueue

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
func givenDispatchQueue() {    
  queue = DispatchQueue(label: "com.DogPatchTests.MockSession")
}
init(completionHandler: 
    @escaping (Data?, URLResponse?, Error?) -> Void,
  url: URL,
  queue: DispatchQueue?)
self.completionHandler = completionHandler
if let queue = queue {
  self.completionHandler = { data, response, error in
    queue.async() {
      completionHandler(data, response, error)
    }
  }
} else {
  self.completionHandler = completionHandler
}
return MockURLSessionDataTask(
  completionHandler: completionHandler,
  url: url,
  queue: queue)

Handling dispatch scenarios

You next need to verify that completionHandler is dispatched to the responseQueue. This should happen in these cases:

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)
  }
}
let task = session.dataTask(with: url) { 
  data, response, error in
let task = session.dataTask(with: url) { [weak self] 
  data, response, error in
    guard let self = self else { return }
completion(nil, error)
guard let responseQueue = self.responseQueue else {
  completion(nil, error)
  return
}
responseQueue.async {
  completion(nil, error)
}
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)
  }
}
guard let response = response as? HTTPURLResponse,
  response.statusCode == 200,
  error == nil,
  let data = data else {
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)
  }
}
verifyGetDogsDispatchedToMain(statusCode: 500)
// given
let error = NSError(domain: "com.DogPatchTests", code: 42)

// then
verifyGetDogsDispatchedToMain(error: error)
func test_getDogs_givenGoodResponse_dispatchesToResponseQueue() 
  throws {
  // given
  let data = try Data.fromJSON(
    fileName: "GET_Dogs_Response")
  
  // then
  verifyGetDogsDispatchedToMain(data: data)
}
completion(dogs, nil)
guard let responseQueue = self.responseQueue else {
  completion(dogs, nil)
  return
}
responseQueue.async {
  completion(dogs, nil)
}
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)
  }
}
guard let responseQueue = self.responseQueue else {
  completion(nil, error)
  return
}
responseQueue.async {
  completion(nil, error)
}
self.dispatchResult(error: error, completion: completion)
guard let responseQueue = self.responseQueue else {
  completion(dogs, nil)
  return
}
responseQueue.async {
  completion(dogs, nil)
}
self.dispatchResult(models: dogs, completion: completion)
func test_getDogs_givenInvalidResponse_dispatchesToResponseQueue()
  throws {
    // given
    let data = try Data.fromJSON(
      fileName: "GET_Dogs_MissingValuesResponse")
    
    // then
    verifyGetDogsDispatchedToMain(data: data)
}
completion(nil, error)
self.dispatchResult(error: error, completion: completion)

Key points

In this chapter, you learned how to do TDD for a networking client. Let’s recap what you learned:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now