Skip to content

Chapter 01: Network JSON Handler

Rick van Voorden edited this page Nov 8, 2021 · 1 revision

In our previous chapter, we introduced the NetworkDataHandler type to formalize some logic to determine whether or not we have reason to believe a network request was successful. This type (on its own) will not solve many complex problems for us. Let’s see how we can use composition to pair this type with another to handle a much more practical task for iOS engineers: serializing JSON from binary data.

What would the ingredients be to build a new type capable of serializing JSON? Suppose (consistent with NetworkDataHandler) we are fetching this binary data from the network. We would like to take a Data and a URLResponse and, if appropriate, return a valid JSON object (or throw an error if no JSON could be returned). What types would we make use of to accomplish this task? We already built NetworkDataHandler for determining if we believe a network request was successful. Suppose that network request was successful, how could we then serialize that binary data to JSON? It looks like the Apple JSONSerialization class could help us accomplish that. Our strategy will be to build a new type that composes these two tasks together (checking the network response and serializing the binary data). We will introduce two important patterns (dependency-injection and the “test-double” type) that we will need throughout the remainder of this tutorial.


Add a new Swift file named NetworkJSONHandlerTests.swift. Remember to add this file only to your AlbumsTests target. Let’s start writing some tests. We want to test a method that can take a Data and a URLResponse as input, and return a JSON back (or throw an error). Let’s start by testing for correct behavior when our server response indicates our new method should throw an error (no JSON will be returned). We built our NetworkDataHandler to perform some safety checks on our binary data using the status code returned from our server. Another smart safety check can be to inspect the media-type (or MIME-type). If our server tells us this binary data represents an image, we don’t want to go to the trouble of trying to serialize it to a JSON object; we just want to throw an error. Let’s try to formalize this behavior.

//  NetworkJSONHandlerTests.swift

import XCTest

final class NetworkJSONHandlerTestCase : XCTestCase {
  
}

extension NetworkJSONHandlerTestCase {
  func testMimeTypeError() {
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "IMAGE/PNG"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandler.json(
        with: DataTestDouble(),
        response: response
      )
    )
  }
}

Command-U. We fail to compile because of the missing type. Let’s add a new Swift file named NetworkJSONHandler.swift (remember to add this file to your Albums target and your AlbumsTests target). Let’s build the simplest version of NetworkJSONHandler that we can think of to pass our test.

//  NetworkJSONHandler.swift

import Foundation

struct NetworkJSONHandler {

}

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    throw NSError(
      domain: "",
      code: 0
    )
  }
}

Command-U. Tests pass. Let’s improve this implementation with a custom error type.

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  func testMimeTypeError() {
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "IMAGE/PNG"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandler.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      if let error = try? XCTUnwrap(error as? NetworkJSONHandler.Error) {
        XCTAssertEqual(
          error.code,
          .mimeTypeError
        )
        XCTAssertNil(error.underlying)
      }
    }
  }
}

Command-U. Our compiler fails. Let’s add the missing type.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  struct Error : Swift.Error {
    enum Code {
      case mimeTypeError
    }
    
    let code: Self.Code
    let underlying: Swift.Error?
    
    init(
      _ code: Self.Code,
      underlying: Swift.Error? = nil
    ) {
      self.code = code
      self.underlying = underlying
    }
  }
}

Command-U. Tests fail. Let’s update our implementation to throw this new error type.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    throw Self.Error(.mimeTypeError)
  }
}

Command-U. Tests pass. Should we now feel “confident” that our new type has the ability to perform this safety check correctly? Maybe. You might think that now is a good time to add more logic in your implementation. Let’s try and follow the pattern of TDD. Let’s leave this test (and our implementation) in place and continue formalizing the behavior of this type with more tests. When we write a failing test, that will be the time for us to refine our implementation logic.

Does practicing TDD mean you only practice refactoring after you have written a failing test? No. It does not. In fact, the ability to refactor with safety and confidence is one the biggest advantages of practicing TDD. It is, however, a best-practice to wait until your tests are complete (you have formalized the expected behavior of your production type) before refactoring.

Let’s write a new test. Our goal for this test will be to assume the server returned the correct media-type, but our status code indicates our response was still unsuccessful. Let’s indicate this scenario with a new Code on our NetworkJSONHandler.Error type.

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  func testDataHandlerError() {
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "TEXT/JAVASCRIPT"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandler.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      if let error = try? XCTUnwrap(error as? NetworkJSONHandler.Error) {
        XCTAssertEqual(
          error.code,
          .dataHandlerError
        )
        XCTAssertNil(error.underlying)
      }
    }
  }
}

Command-U. Our compiler fails. Let’s update our error type to make this test compile.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  struct Error : Swift.Error {
    enum Code {
      case mimeTypeError
      case dataHandlerError
    }
    
    let code: Self.Code
    let underlying: Swift.Error?
    
    init(
      _ code: Self.Code,
      underlying: Swift.Error? = nil
    ) {
      self.code = code
      self.underlying = underlying
    }
  }
}

Command-U. Tests fail. Let’s update our implementation to return this new code when our media-type matches what we are looking for.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    guard
      let mimeType = response.mimeType?.lowercased(),
      mimeType == "text/javascript"
    else {
      throw Self.Error(.mimeTypeError)
    }
    
    throw Self.Error(.dataHandlerError)
  }
}

Command-U. Tests pass (including our original test passing in the “wrong” media-type). This is good. We still need a lot of work to improve this test. We would like to confirm this implementation behaves correctly for every status code we might expect from our server. How do we do that? One approach would be to write a test that passes in all possible status codes and test that the type returns (or throws) correctly. That sounds very similar to all the work we just finished for NetworkDataHandlerTestCase. Would it be right to “test twice”? It sounds like more work than necessary. We already wrote those tests in NetworkDataHandlerTestCase. Why would we need identical tests in NetworkJSONHandlerTestCase? And what about when we want a type to serialize an image? Would we need to duplicate those tests all over again? We will take a different approach.

Our use of the text/javascript media-type is not arbitrary; as of this writing, this matches the type returned from the Apple server. If you would like this type to handle multiple media-types (that represent a JSON object), you can choose to test (and implement) them here.

What if, instead of testing that our NetworkJSONHandler performs correctly for a set of status codes, we (instead) test that our NetworkJSONHandler “hands off” any arbitrary status code to a NetworkDataHandler type. If we test that NetworkDataHandler performs correctly, and we test that NetworkJSONHandler correctly makes use of NetworkDataHandler, we can be confident that NetworkJSONHandler will perform correctly. This might all sound a little abstract; let’s work through and see this concept in action. It’s time for our introduction to dependency-injection.

For our previous type, we wrote a test to iterate over six-hundred different status codes that might return from a server. This is a lot more than the total number of “official” status codes specified by IANA. We should feel confident this type will behave correctly when we use it in the “real world”. What about our new type that needs to perform logic over a media-type? We wrote one test for an image media-type, and one more test for a JSON media-type. Should we feel confident this type will behave correctly for all media-types? Maybe. Would it be an improvement to this test if, instead of passing in an image media-type, we iterated over an array of many different media-types? Maybe. As Jon Reid used to say: “Whatever makes you sleep better at night.” For now, we will leave this test in place and move on. If you like, you are welcome to experiment with improving this test.

Here’s a brief look at the steps we will follow to continue testing this type:

  • Define a DataHandlerTestDouble test type to implement the method we would need an arbitrary data handler type to provide.
  • Write the test to prove our NetworkJSONHandler is correctly making use of the DataHandlerTestDouble.
  • Define a NetworkJSONHandlerDataHandler production protocol to document the method we would need an arbitrary data handler type to provide.
  • Make our NetworkDataHandler production type a generic type with a generic parameter constrained to our NetworkJSONHandlerDataHandler production protocol.
  • Update the implementation of our NetworkDataHandler production type to make use of the generic DataHandler parameter.
  • Confirm that our tests pass.

Let’s get started with the DataHandlerTestDouble. We will define a new type just for testing. We will declare the same method as our public production NetworkDataHandler type. Instead of duplicating the logic from our production type, our test type will just “save” the parameters passed in (and throw an error we send up). In the words of Gerard Meszaros, we will “spy” on the parameter values and “stub” the return value.[^1]

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  private struct DataHandlerTestDouble {
    static var parameterData: Data?
    static var parameterResponse: URLResponse?
    static var returnData: Data?
    static let returnError = NSErrorTestDouble()
    
    static func data(
      with data: Data,
      response: URLResponse
    ) throws -> Data {
      self.parameterData = data
      self.parameterResponse = response
      guard
        let returnData = self.returnData
      else {
        throw self.returnError
      }
      return returnData
    }
  }
}

Our DataHandlerTestDouble implements the same type method we implemented on our production type, we just save the parameters to type variables and either return a Data or throw an NSError. Setting up our type like this will enable us to pass this type along to our production NetworkJSONHandler and test how we pass parameters down (and test what we do with the return value or error that comes back up). Let’s see how we plan to make use of this new type. Let’s update our test. This is going to be a lot of new code. We’ll look closely at what this all means once we have this code in place.

//  NetworkJSONHandlerTests.swift

final class NetworkJSONHandlerTestCase : XCTestCase {
  private typealias NetworkJSONHandlerTestDouble = NetworkJSONHandler<DataHandlerTestDouble>
}

extension NetworkJSONHandlerTestCase {
  override func tearDown() {
    DataHandlerTestDouble.parameterData = nil
    DataHandlerTestDouble.parameterResponse = nil
    DataHandlerTestDouble.returnData = nil
  }
}

extension NetworkJSONHandlerTestCase {
  func testMimeTypeError() {
    DataHandlerTestDouble.returnData = nil
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "IMAGE/PNG"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandlerTestDouble.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      XCTAssertNil(DataHandlerTestDouble.parameterData)
      XCTAssertNil(DataHandlerTestDouble.parameterResponse)
      
      if let error = try? XCTUnwrap(error as? NetworkJSONHandlerTestDouble.Error) {
        XCTAssertEqual(
          error.code,
          .mimeTypeError
        )
        XCTAssertNil(error.underlying)
      }
    }
  }
}

extension NetworkJSONHandlerTestCase {
  func testDataHandlerError() {
    DataHandlerTestDouble.returnData = nil
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "TEXT/JAVASCRIPT"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandlerTestDouble.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      XCTAssertEqual(
        DataHandlerTestDouble.parameterData,
        DataTestDouble()
      )
      XCTAssertIdentical(
        DataHandlerTestDouble.parameterResponse,
        response
      )
      
      if let error = try? XCTUnwrap(error as? NetworkJSONHandlerTestDouble.Error) {
        XCTAssertEqual(
          error.code,
          .dataHandlerError
        )
        if let underlying = try? XCTUnwrap(error.underlying as NSError?) {
          XCTAssertIdentical(
            underlying,
            DataHandlerTestDouble.returnError
          )
        }
      }
    }
  }
}

Let’s walk through and see why we made these changes.

  • We start by defining a typealias which we will use when we write our tests. Our type alias is just our production type with our DataHandlerTestDouble passed as a generic parameter. This is not necessary to write our tests, but it can help make our code a little clearer if we define the type we are testing in “just one place” (instead of defining the type in each and every test). It can also help communicate to engineers inspecting these tests how we intend to make use of these test-double types.
  • We implement a tearDown method. This is a special method defined on XCTestCase that enables us to write some code that will run after every test. Our strategy with DataHandlerTestDouble is to spy on parameter values and save them to type variables (since the method we are spying is a type method). We will also write to a static variable when we would like for this type to return a valid Data. This state will persist across tests and could lead to false test results. Using type variables to spy (and stub) can be a powerful tool, but we must take care to not let these tools cause more problems than they solve. While you could make a practice out of resetting these type variables in the body of every test that modifies them, we will make it a practice to clean them up in tearDown.
  • We update our test methods to test that the spy parameters match what we expect and the stub error also matches what we would expect. We expect our first test to fail because of our incorrect media-type. This is why we test that our production type did not make use of the test-double. We expect our second type to fail because of an implementation detail of our DataHandler. This is why we test that our production type did make use of the test-double. Setting our stub return values to nil is not necessary (since we implemented this work in tearDown), but it helps to communicate the conditions that make this test different than the others (since we do stub valid return values later).

Command-U. Our compiler fails. We have not yet made our NetworkJSONHandler a generic type. Let’s make this happen.

//  NetworkJSONHandler.swift

protocol NetworkJSONHandlerDataHandler {
  static func data(
    with: Data,
    response: URLResponse
  ) throws -> Data
}

struct NetworkJSONHandler<DataHandler : NetworkJSONHandlerDataHandler> {
  
}

Command-U. Our compiler fails because our test-double type does not conform to the production protocol. This is an easy fix.

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  private struct DataHandlerTestDouble : NetworkJSONHandlerDataHandler {
    static var parameterData: Data?
    static var parameterResponse: URLResponse?
    static var returnData: Data?
    static let returnError = NSErrorTestDouble()
    
    static func data(
      with data: Data,
      response: URLResponse
    ) throws -> Data {
      self.parameterData = data
      self.parameterResponse = response
      guard
        let returnData = self.returnData
      else {
        throw self.returnError
      }
      return returnData
    }
  }
}

Command-U. Tests fail. We’ve passed in the test-double type as a generic parameter, but our tests prove that our production type is not doing what we expect with that test-double. Let’s update our production logic to make this test pass.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    guard
      let mimeType = response.mimeType?.lowercased(),
      mimeType == "text/javascript"
    else {
      throw Self.Error(.mimeTypeError)
    }
    
    do {
      try DataHandler.data(
        with: data,
        response: response
      )
    } catch {
      throw Self.Error(
        .dataHandlerError,
        underlying: error
      )
    }
  }
}

Command-U. Tests pass. We’ve just seen our first example of dependency-injection. Rather than test our production type as if its supporting types are also production types, we test our production type as if its supporting types are test-double types (spies and stubs). Since we already tested those supporting types, we can worry more about testing whether or not we would make correct use of those supporting types (without duplicating our tests to verify our supporting types perform correctly).

In the previous version of our tutorial, we implemented dependency-injection by subclassing our production classes for testing (and we injected test-double types with overriding). This “subclass-and-override” technique makes use of polymorphism (and the dynamic nature of Objective-C) to “swap out” production logic with test logic at run-time. What about Swift? So far, we’ve written two production types (NetworkDataHandler and NetworkJSONHandler) that are both struct values, which means we can’t subclass. Would it be right to make these struct values class values? As we will see throughout this tutorial, we don’t need these to be classes anymore. Rather than inject our dependencies at run-time with subclass-and-override, we can inject our dependencies at compile-time with generic parameters. We don’t need polymorphism to swap production logic for test-double logic. We can statically create these generic types at compile-time for faster, safer, and simpler tests (and production implementations).

Let’s add one small thing while we are here. We’ve verified that our NetworkJSONHandler can accept a DataHandlerTestDouble as a generic parameter. What about our production NetworkDataHandler type? Let’s make sure it will be considered a valid generic parameter.

//  NetworkJSONHandler.swift

extension NetworkDataHandler : NetworkJSONHandlerDataHandler {

}

Great. We have our NetworkJSONHandler type which correctly makes use of a DataHandler generic parameter to help determine whether or not a network response was valid. What next? We want to hand off valid binary data to a type that will help us serialize JSON objects. In the previous version of our tutorial we used the NSJSONSerialization class for this purpose. Let’s take a look at the Swift version of this API we will want to make use of.

//  Foundation.NSJSONSerialization

open class JSONSerialization : NSObject {
  open class func jsonObject(
    with data: Data,
    options opt: JSONSerialization.ReadingOptions = []
  ) throws -> Any
}

If you followed along with our previous tutorial, you remember that when we encounter a method given to us by Apple, we choose not to write tests to verify the behavior; we assume the method behaves as documented. Instead of testing our own types with a dependency on production Apple types, we expand our type with another generic parameter. We will inject a test-double version of this Apple class for testing. Let’s build the protocol we will use for making this happen.

//  NetworkJSONHandler.swift

protocol NetworkJSONHandlerJSONSerialization {
  associatedtype JSON
  
  static func jsonObject(
    with: Data,
    options: JSONSerialization.ReadingOptions
  ) throws -> JSON
}

extension JSONSerialization : NetworkJSONHandlerJSONSerialization {
  
}

You might notice one small difference between the method we defined on our protocol and the method that was defined for us on our production Apple type. Our production Apple type returns a value of type Any. The method we defined on our protocol returns an associated type. This is a more flexible way to define our protocols; we will say that our method might return any type, but it will be the responsibility of the type conforming to this protocol to specify what that would be.

For our previous test, we started by defining our test-double type before defining the protocol we expected that test-double type to conform to (which led to a compiler error when we tried to pass that test-double type as a generic parameter). Here, we begin by defining the protocol we expect our new test-double type to conform to. We will create many test-doubles for this tutorial. There is no one single rule you must follow every time for how you go about building those test-doubles. Let experience be your guide. As much as possible, we will still try not to modify the implementation of our production types until we feel confident that our tests are ready.

Let’s build our test-double and write a test for a JSON error. This will be a test for the situation where we have a correct media-type and a valid status code, but JSON serialization failed for some arbitrary reason.

//  NetworkJSONHandlerTests.swift

final class NetworkJSONHandlerTestCase : XCTestCase {
  private typealias NetworkJSONHandlerTestDouble = NetworkJSONHandler<DataHandlerTestDouble, JSONSerializationTestDouble>
}

extension NetworkJSONHandlerTestCase {
  private struct JSONSerializationTestDouble : NetworkJSONHandlerJSONSerialization {
    static var parameterData: Data?
    static var parameterOptions: JSONSerialization.ReadingOptions?
    static var returnJSON: NSObject?
    static let returnError = NSErrorTestDouble()
    
    static func jsonObject(
      with data: Data,
      options: JSONSerialization.ReadingOptions
    ) throws -> NSObject {
      self.parameterData = data
      self.parameterOptions = options
      guard
        let returnJSON = self.returnJSON
      else {
        throw self.returnError
      }
      return returnJSON
    }
  }
}

extension NetworkJSONHandlerTestCase {
  override func tearDown() {
    DataHandlerTestDouble.parameterData = nil
    DataHandlerTestDouble.parameterResponse = nil
    DataHandlerTestDouble.returnData = nil
    
    JSONSerializationTestDouble.parameterData = nil
    JSONSerializationTestDouble.parameterOptions = nil
    JSONSerializationTestDouble.returnJSON = nil
  }
}

extension NetworkJSONHandlerTestCase {
  func testJSONSerializationError() {
    DataHandlerTestDouble.returnData = DataTestDouble()
    
    JSONSerializationTestDouble.returnJSON = nil
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "TEXT/JAVASCRIPT"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandlerTestDouble.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      XCTAssertEqual(
        DataHandlerTestDouble.parameterData,
        DataTestDouble()
      )
      XCTAssertIdentical(
        DataHandlerTestDouble.parameterResponse,
        response
      )
      
      XCTAssertEqual(
        JSONSerializationTestDouble.parameterData,
        DataHandlerTestDouble.returnData
      )
      XCTAssertEqual(
        JSONSerializationTestDouble.parameterOptions,
        []
      )
      
      if let error = try? XCTUnwrap(error as? NetworkJSONHandlerTestDouble.Error) {
        XCTAssertEqual(
          error.code,
          .jsonSerializationError
        )
        if let underlying = try? XCTUnwrap(error.underlying as NSError?) {
          XCTAssertIdentical(
            underlying,
            JSONSerializationTestDouble.returnError
          )
        }
      }
    }
  }
}

There’s a lot going on here. Let’s slow down and look at what we built.

  • We update our NetworkJSONHandlerTestDouble type alias to pass the JSONSerializationTestDouble as a second generic parameter to our production NetworkJSONHandler type.
  • We define the JSONSerializationTestDouble type (which conforms to NetworkJSONHandlerJSONSerialization). As before, we spy our parameter values to type variables and stub our return values from type variables. You might notice that our method returns a value of type NSObject. This will satisfy the associated type we defined earlier.
  • We update the tearDown method on our test case class to reset the type variables we defined to help spy and stub our new test-double.
  • We write our new test. We begin by stubbing a valid binary data on our DataHandlerTestDouble. We then test that the binary data returned from our DataHandlerTestDouble matches the same binary data that was passed in to our JSONSerializationTestDouble. We are composing these two types together by verifying that the output of the first type becomes the input of the second type. We then verify that the error thrown by our JSONSerializationTestDouble type matches the error reported from our NetworkJSONHandler type.

Command-U. Our compiler fails. Let’s update our production type with a new generic parameter. We can also update our custom error with a new code.

//  NetworkJSONHandler.swift

struct NetworkJSONHandler<
  DataHandler : NetworkJSONHandlerDataHandler,
  JSONSerialization : NetworkJSONHandlerJSONSerialization
> {
  
}

extension NetworkJSONHandler {
  struct Error : Swift.Error {
    enum Code {
      case mimeTypeError
      case dataHandlerError
      case jsonSerializationError
    }
    
    let code: Self.Code
    let underlying: Swift.Error?
    
    init(
      _ code: Self.Code,
      underlying: Swift.Error? = nil
    ) {
      self.code = code
      self.underlying = underlying
    }
  }
}

Command-U. Tests fail. Let’s start with throwing the new error we just defined.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    guard
      let mimeType = response.mimeType?.lowercased(),
      mimeType == "text/javascript"
    else {
      throw Self.Error(.mimeTypeError)
    }
    
    do {
      try DataHandler.data(
        with: data,
        response: response
      )
    } catch {
      throw Self.Error(
        .dataHandlerError,
        underlying: error
      )
    }
    
    throw Self.Error(.jsonSerializationError)
  }
}

Command-U. Tests fail. Let’s update our production implementation so that we are correctly passing data from our DataHandler type to our JSONSerialization type.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws {
    guard
      let mimeType = response.mimeType?.lowercased(),
      mimeType == "text/javascript"
    else {
      throw Self.Error(.mimeTypeError)
    }
    
    let data = try { () -> Data in
      do {
        return try DataHandler.data(
          with: data,
          response: response
        )
      } catch {
        throw Self.Error(
          .dataHandlerError,
          underlying: error
        )
      }
    }()
    
    do {
      try JSONSerialization.jsonObject(
        with: data,
        options: []
      )
    } catch {
      throw Self.Error(
        .jsonSerializationError,
        underlying: error
      )
    }
  }
}

Command-U. Tests pass. We’re making a lot of progress. Let’s go back to our previous tests. Now that we have our JSONSerialization type, let’s go ahead and verify that we don’t try to use this type when our media-type (or our status code) indicate an error.

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  func testMimeTypeError() {
    DataHandlerTestDouble.returnData = nil
    
    JSONSerializationTestDouble.returnJSON = nil
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "IMAGE/PNG"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandlerTestDouble.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      XCTAssertNil(DataHandlerTestDouble.parameterData)
      XCTAssertNil(DataHandlerTestDouble.parameterResponse)
      
      XCTAssertNil(JSONSerializationTestDouble.parameterData)
      XCTAssertNil(JSONSerializationTestDouble.parameterOptions)
      
      if let error = try? XCTUnwrap(error as? NetworkJSONHandlerTestDouble.Error) {
        XCTAssertEqual(
          error.code,
          .mimeTypeError
        )
        XCTAssertNil(error.underlying)
      }
    }
  }
}

extension NetworkJSONHandlerTestCase {
  func testDataHandlerError() {
    DataHandlerTestDouble.returnData = nil
    
    JSONSerializationTestDouble.returnJSON = nil
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "TEXT/JAVASCRIPT"])
    
    XCTAssertThrowsError(
      try NetworkJSONHandlerTestDouble.json(
        with: DataTestDouble(),
        response: response
      )
    ) { error in
      XCTAssertEqual(
        DataHandlerTestDouble.parameterData,
        DataTestDouble()
      )
      XCTAssertIdentical(
        DataHandlerTestDouble.parameterResponse,
        response
      )
      
      XCTAssertNil(JSONSerializationTestDouble.parameterData)
      XCTAssertNil(JSONSerializationTestDouble.parameterOptions)
      
      if let error = try? XCTUnwrap(error as? NetworkJSONHandlerTestDouble.Error) {
        XCTAssertEqual(
          error.code,
          .dataHandlerError
        )
        if let underlying = try? XCTUnwrap(error.underlying as NSError?) {
          XCTAssertIdentical(
            underlying,
            DataHandlerTestDouble.returnError
          )
        }
      }
    }
  }
}

Command-U. Tests pass. Let’s move on and test for the successful JSON serialization. This is when we pass binary data to our JSONSerialization type and receive a valid JSON object back.

//  NetworkJSONHandlerTests.swift

extension NetworkJSONHandlerTestCase {
  func testSuccess() {
    DataHandlerTestDouble.returnData = DataTestDouble()
    
    JSONSerializationTestDouble.returnJSON = NSObject()
    
    let response = HTTPURLResponseTestDouble(headerFields: ["CONTENT-TYPE": "TEXT/JAVASCRIPT"])
    
    XCTAssertNoThrow(
      try {
        let json = try NetworkJSONHandlerTestDouble.json(
          with: DataTestDouble(),
          response: response
        )
        
        XCTAssertEqual(
          DataHandlerTestDouble.parameterData,
          DataTestDouble()
        )
        XCTAssertIdentical(
          DataHandlerTestDouble.parameterResponse,
          response
        )
        
        XCTAssertEqual(
          JSONSerializationTestDouble.parameterData,
          DataHandlerTestDouble.returnData
        )
        XCTAssertEqual(
          JSONSerializationTestDouble.parameterOptions,
          []
        )
        
        XCTAssertIdentical(
          json,
          JSONSerializationTestDouble.returnJSON
        )
      }()
    )
  }
}

Command-U. Our compiler fails. Our production type needs to return a value. Our test-double JSONSerialization type returns a value of type NSObject. Instead of returning an Any from our production type, it would make our testing easier if we knew this was a type that could be compared with XCTAssertIdentical. Fortunately, our associated type will help us here. We already defined the JSONSerializationTestDouble so that NSObject would be the associated type. When we told Swift that we expected the Apple JSONSerialization class to conform to NetworkJSONHandlerJSONSerialization, that would imply that Any would be the associated type (because Any matches the return type of the original Apple method). These are not run-time (polymorphic) decisions. We are programming with generics; these are static, compile-time decisions. This means we can update our NetworkJSONHandler type to return a value of an associated type we defined in one of our generic parameters. Here’s what that looks like.

//  NetworkJSONHandler.swift

extension NetworkJSONHandler {
  static func json(
    with data: Data,
    response: URLResponse
  ) throws -> JSONSerialization.JSON {
    guard
      let mimeType = response.mimeType?.lowercased(),
      mimeType == "text/javascript"
    else {
      throw Self.Error(.mimeTypeError)
    }
    
    let data = try { () -> Data in
      do {
        return try DataHandler.data(
          with: data,
          response: response
        )
      } catch {
        throw Self.Error(
          .dataHandlerError,
          underlying: error
        )
      }
    }()
    
    do {
      return try JSONSerialization.jsonObject(
        with: data,
        options: []
      )
    } catch {
      throw Self.Error(
        .jsonSerializationError,
        underlying: error
      )
    }
  }
}

Command-U. Tests pass. It might not seem like it would be too much trouble to leave the Any value in our production type and then implement some type casting in our test code to test for value equality (or reference equality). We might not always be so lucky. Instead of Any, we might find ourselves trying to construct some large, expensive production type when we aren’t really all that interested in the details of that production type. We are more interested in whether or not the return value we expected was forwarded (or returned) correctly. Associated types give us a way to swap out (at compile-time) a lightweight type for testing, while still leaving the production return values in place.

You might be familiar with the Apple JSONDecoder class for serializing JSON objects. Could we have built this type to make use of JSONDecoder (instead of JSONSerialization)? Maybe. The advantage of the JSONSerialization class (for this chapter) is its simplicity. Our primary goal with this chapter was to introduce dependency-injection (and test-doubles). The JSONSerialization class gives us one type method; our test-double type implements one type method. The JSONDecoder class offers a lot of power to engineers, but would make presenting the topics in this chapter a little more complex. Fortunately, one of the benefits of practicing TDD is the security to refactor with confidence. We can start with this implementation and refactor later if we want to take advantage of the advanced functionality offered by JSONDecoder.

That about does it for JSON serialization. We’ve covered some important topics in this chapter. Let’s review what we worked on. We implemented a new custom type (NetworkJSONHandler) for serializing JSON objects from network data. We composed two types together to help accomplish this. We start by passing our network response and binary data down to a DataHandler type. If appropriate, we then pass binary data from our DataHandler type to our JSONSerialization type. We use generic programming to inject these dependencies at compile-time, and we swap in test-double spies and stubs for testing. We will see this pattern over and over throughout this tutorial.

When we make use of test-double types, we might have several XCTAssert assertions in each test. Is this wrong? Is it a sign we should choose a different architecture? Not necessarily. While it is considered a TDD best-practice to “test one thing” at a time, we will take this to mean we should try and test just one production API at a time. If verifying that one production API performs correctly means we want to assert for multiple conditions at once, that doesn’t have to be a bad thing.

Let’s move on to a topic just about every mobile engineer is going to be familiar with. Let’s build a type for serializing an image from binary data.

[^1] Fowler, Martin (2007-01-02). Mocks Aren’t Stubs.

Clone this wiki locally