Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

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.

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

  • 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.

@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 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 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.

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()
}

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.

@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)
  }
}
extension URLSessionTask: URLSessionTaskProtocol { }
func test_URLSession_conformsTo_URLSessionProtocol() {
  XCTAssertTrue((session as AnyObject) is URLSessionProtocol)
}
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 } )
  }
}
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)
}
return dataTask(with: url, completionHandler: { _, _, _ in } )
var url: URL!
url = URL(string: "https://example.com")!
url = nil
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)
}
return dataTask(with: url,                    
                completionHandler: completionHandler)

Creating and using the session mocks

Next, you need to create test types for MockURLSession and MockURLSessionTask.

@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() {
  
  }
}
var mockSession: MockURLSession!
mockSession = MockURLSession()
let session: URLSessionProtocol
init(baseURL: URL, session: URLSessionProtocol)
XCTAssertTrue(sut.session === mockSession)

Calling the right URL

Now, use MockURLSession to validate behavior within your tests!

func test_getDogs_callsExpectedURL() {
  // given
  let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!

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

Handling error responses

Next, you need to handle error responses. Two scenarios 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! MockURLSessionTask
  
  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! MockURLSessionTask
  
  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! MockURLSessionTask
    
    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 models on success

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 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.

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

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
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 MockURLSessionTask(
  completionHandler: completionHandler,
  url: url,
  queue: queue)

Handling dispatch scenarios

Next, you need to verify that completionHandler dispatches to the responseQueue, which 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! 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)
  }
}
let task = session.makeDataTask(with: url) { 
  data, response, error in
let task = session.makeDataTask(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! 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)
  }
}
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! 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)
  }
}
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. Here’s a recap of 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