In this episode we talk about testing requests against a real API. For this we will build an app called CoinList that leverages the Crypto Compare API to fetch stats about crypto currencies.
Episode Links Crypto Compare API Writing our API Client We'll start with a simple class that takes a session in the initializer: class CryptoCompareClient { let session: URLSession init(session: URLSession) { self.session = session } } We want to test this class, so we'll also create a test for this as well. class CryptoCompareClientTests : XCTestCase { var client: CryptoCompareClient! override func setUp() { super.setUp() client = CryptoCompareClient(session: URLSession.shared) } } Modeling the Coin List response using Codable Back in our client class, we want to write a method that fetches the coins and returns them as parsed objects. We will write these as closely matching the API response as possible. If we want to deviate from this structure in our application, we will map this response to what we want to work with. This makes our Decodable implementation more straightforward. There's a lot of information to decode in the response, but we will start with a basic structure to get a working example first. struct CoinList : Decodable { var response: String var message: String enum CodingKeys : String, CodingKey { case response = "Response" case message = "Message" } } Indicating Success and Failure with Custom Errors For our completion handlers, we'll leverage a couple of enums: enum ApiResult<T : Decodable> { case success(T) case failure(ApiError) } enum ApiError : Error { case notFound // 404 case serverError // 5xx case requestError // 4xx case responseFormatInvalid(String) case connectionError(Error) } Now we can model our completion blocks like this: typealias ApiCompletionBlock<T : Decodable> = (ApiResult<T>) -> Void Fetching Coins func fetchCoinList(completion: @escaping ApiCompletionBlock<CoinList>) { // we'll write this in a minute } Writing a test for fetchCoinList Before writing the implementation, let's first write a failing test. func testFetchesCoinListResponse() { let exp = expectation(description: "Received response") client.fetchCoinList { result in exp.fulfill() switch result { case .success(let coinList): XCTAssertEqual(coinList.response, "Success") case .failure(let error): XCTFail("Error in coin list request: \(error)") } } waitForExpectations(timeout: 3.0, handler: nil) } Running this test we can see it fails as expected. Fixing the test func fetchCoinList(completion: @escaping ApiCompletionBlock<CoinList>) { let url = URL(string: "https://min-api.cryptocompare.com/data/all/coinlist")! let req = URLRequest(url: url) let task = session.dataTask(with: req) { (data, response, error) in if let e = error { ApiResult.failure(.connectionError(e)) -=> completion } else { let http = response as! HTTPURLResponse switch http.statusCode { case 200: let jsonDecoder = JSONDecoder() do { let coinList = try jsonDecoder.decode(CoinList.self, from: data!) ApiResult.success(coinList) -=> completion } catch let e { print(e) let bodyString = String(data: data!, encoding: .utf8) ApiResult.failure(.responseFormatInvalid(bodyString ?? "<no body>")) -=> completion } default: ApiResult.failure(.serverError) -=> completion } } } task.resume() } There is a lot of boilerplate code in here, but we can refactor this as we go. For now, I want to get a working example going as fast as possible. Side note: I'm toying with a custom operator here -=> that will dispatch these completion blocks on the main queue. Too much magic? Maybe, but it is always helpful to experiment to see what works and what doesn't! Testing that we are called back on the correct thread I tend to follow the guideline that API clients should call their completion blocks on the main queue. We can verify this is happening with a quick test: func testCallsBackOnMainQueue() { let exp = expectation(description: "Received response") client.fetchCoinList { result in exp.fulfill() XCTAssert(Thread.isMainThread, "Expected to be called back on the main queue") } waitForExpectations(timeout: 3.0, handler: nil) } Now we have our first 2 tests passing!