In this episode we implement OHHTTPStubs, a library that can be used to intercept and stub out network calls made with URLSession. Using this technique we can avoid hitting the network for our tests. We can also simulate different responses that are difficult or impractical to simulate in a real request.
Episode Links OHHTTPStubs CryptoCompare API Modeling the Coins Response In the API response we saw last time, all of the coins are returned in a dictionary underneath the data key. We'll start by modeling that. enum CodingKeys : String, CodingKey { case response = "Response" case message = "Message" case baseImageURL = "BaseImageUrl" case baseLinkURL = "BaseLinkUrl" case data = "Data" } We'll provide a custom nested struct to represent this data, decoding with a dynamic CodingKey implementation (since our keys are dynamic): struct Data : Decodable { private struct Keys : CodingKey { var stringValue: String init?(stringValue: String) { self.stringValue = stringValue } var intValue: Int? init?(intValue: Int) { self.stringValue = String(intValue) self.intValue = intValue } } private var coins: [String : Coin] = [:] init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: Keys.self) for key in container.allKeys { coins[key.stringValue] = try container.decode(Coin.self, forKey: key) } } func allCoins() -> [Coin] { return Array(coins.values) } subscript(_ key: String) -> Coin? { return coins[key] } } This references a new type Coin. I want this to be somewhat isolated from the rest of the application, so this will be a nested type inside of CoinList: struct Coin : Decodable { let name: String let symbol: String let imagePath: String? enum CodingKeys : String, CodingKey { case name = "CoinName" case symbol = "Symbol" case imagePath = "ImageUrl" } } Now that we have this in place, we can write another test that we can successfully parse these coins and access them by their symbol. Testing that we can parse coins We'll start by copying the basic structure of making the request and setting up the expectation that we can wait on later: func testCoinListRetrievesCoins() { let exp = expectation(description: "Received response") client.fetchCoinList { result in exp.fulfill() // ... } waitForExpectations(timeout: 3.0, handler: nil) } Then we can write some assertions that we are able to pull out a coin successfully: switch result { case .success(let coinList): XCTAssertGreaterThan(coinList.data.allCoins().count, 1) let coin = coinList.data["BTC"] XCTAssertNotNil(coin) XCTAssertEqual(coin?.symbol, "BTC") XCTAssertEqual(coin?.name, "Bitcoin") XCTAssertNotNil(coin?.imagePath) case .failure(let error): XCTFail("Error in coin list request: \(error)") } And if we run this test, it passes. 🎉 But we probably don't want to keep running our tests against a live API, do we? Setting up OHHTTPStubs To stub out network calls, we'll use a library called OHHTTPStubs. We'll integrate this into our test target in our Podfile: platform :ios, '11.2' target 'CoinList' do use_frameworks! target 'CoinListTests' do inherit! :search_paths pod 'OHHTTPStubs/Swift' end end Failing tests that hit the network The first step is for us to not allow any request to hit the network in our tests. We can make exceptions to this rule, but it's a good thing to set up initially. At the top of our test we'll import the library: import OHHTTPStubs Then we can add this code to the setup() method: override func setUp() { super.setUp() OHHTTPStubs.onStubMissing { request in XCTFail("Missing stub for \(request)") } } Now if we run our tests they will all fail because they are hitting the network. Intercepting requests and returning fake data We will use the curl command in Terminal to fetch the API response and save it to a JSON file. We can then add a new bundle to our test target called Fixtures.bundle. Then we can create a class to read these file and use them as the body of stub responses. We'll call this FixtureLoader: class FixtureLoader { static func reset() { OHHTTPStubs.removeAllStubs() } static func stubCoinListResponse() { stub(condition: isHost("min-api.cryptocompare.com") && isPath("/data/all/coinlist")) { req -> OHHTTPStubsResponse in return jsonFixture(with: "coinlist.json") } } private static func jsonFixture(with filename: String) -> OHHTTPStubsResponse { let bundle = OHResourceBundle("Fixtures", FixtureLoader.self)! let path = OHPathForFileInBundle(filename, bundle)! return OHHTTPStubsResponse(fileAtPath: path, statusCode: 200, headers: nil) } } Then, back in our test, we can set up a stub by calling: FixtureLoader.stubCoinListResponse() And make sure we unset it in our tearDown() method to avoid one stub interfering with a different test. override func tearDown() { super.tearDown() FixtureLoader.reset() } Now we can continue to run our tests, but they will use this dummy response instead of actually hitting the network.