Test-Driven iOS: Testing Asynchronous Network Calls with FutureKit

Reading Time: 5 minutes

Lately I’ve found myself writing asynchronous network calls on mobile platforms in the reactive style. I want to share how you can test-drive calls like this for iOS. The following example uses SwiftyJSON for JSON deseriaization and FutureKit as the asynchronous framework surrounding the network call. There are several libraries in iOS that do both of these things, but your structure here will look similar with any of them.

This example is updated to Swift 3.

0. Overall Structure

On the implementation side, you’ll have two components. First, a service will wrap the network call and return a future. Second, some object will call that service on the main thread and respond to the way the result completes (in our case, this will be a ViewController). So this means we’ll need to mock the result of the network call that the service makes. We’ll also need to test each of the different ways the future can complete to make sure the ViewController responds the way we would like.

Now let’s look at some code from a sample app about…villains.

…because everyone thinks all my sample apps are about candy.

1. Unit Testing the Service

We’ll be unit testing the service here with the standard test runner for iOS, XCTest (though you could also use Quick if you prefer), and with matchers from Nimble (although Hamcrest or XCTassert would do fine as well).

As mentioned above, this service wraps the network call itself, which in iOS means calling dataTask() on your session object, specified as URLSession.shared. We need to mock that object to ensure that our test asserts on the functionality of our code and not the iOS framework. We also want to stub the response from this call, which is a URLSessionDataTask on which we call resume().

import XCTest
import Nimble
import SwiftyJSON
import FutureKit
@testable import VillainApp
class VillainServiceTest: XCTestCase {
var service: VillainService!
var mockSession: MockSession!
let fakeHTTPResponse = HTTPURLResponse(url: URL(string: "http://www.wearevillains.com/")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
override func setUp() {
super.setUp()
self.continueAfterFailure = false
service = VillainService()
mockSession = MockSession()
service.session = mockSession
}
func testGetVillainList() {
let villainsDict = ["villainCount": 3,
"villains":[
["name": "Cruella DeVille",
"specialty": "unethical treatment of animals"],
["name": "Ursula",
"specialty": "fine print"],
["name": "Elphaba",
"specialty": "faking own death"],
]
]
let villainResponse = VillainResponse.init(fromJSON: JSON(villainsDict))
let villainData: Data = try! JSONSerialization.data(withJSONObject: villainsDict, options: .prettyPrinted)
let someResponse = URLResponse(url: URL(string: "http://www.wearevillains.com/")!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil)
let future = self.service.getVillainList()
mockSession.completionHandler!(villainData, someResponse, nil)
future.onSuccess { (successResponse) -> Void in
expect(self.mockSession.request?.url?.scheme).to(equal("http"))
expect(self.mockSession.request?.url?.host).to(equal("http://www.wearevillains.com"))
expect(self.mockSession.request?.url?.path).to(equal("/team"))
expect(self.mockSession.request?.httpMethod).to(equal("GET"))
expect(successResponse).toEventually(equal(villainResponse))
expect(successResponse.login.expand.absoluteString).to(equal("http://www.wearevillains.com/"))
expect(self.mockSession.stubbedDataTask.resumeWasCalled).to(beTrue())
}
future.onCancel {
fail("Should have successfully obtained villains! Cancelled instead.")
}
future.onFail { (error) -> Void in
print(error)
fail("Should have successfully obtained villains! Failed instead.")
}
expect(future.isCompleted).to(beTrue())
}
}

On line 42, we call the method we’re testing. On line 44, we trigger the response. So session.dataTask() takes a completion handler closure that accepts data, a response, and an error. When we call that method on our mock, our mock will save off the completion handler that the service passes in. Then we’ll call that completion handler with the data that we want the service to think that the network call returned.

This is what your mocks look like so you can perform those assertions on your session and on the task that your session.dataTask() call returns:

class MockSession: URLSession {
var completionHandler: ((Data?, URLResponse?, Error?) -> Void)?
var request: URLRequest?
let stubbedDataTask: MockURLSessionDataTask = MockURLSessionDataTask()
override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
self.completionHandler = completionHandler
self.request = request
return stubbedDataTask
}
}
class MockURLSessionDataTask: URLSessionDataTask {
var resumeWasCalled: Bool = false
override func resume() {
resumeWasCalled = true
}
}

2. Implementing the Service

So what does this service look like? It keeps the session as a stored field and calls session.dataTask(). The key piece that differentiates our service from that method, and our service from other services, is the completion handler that we’re passing into that method. That completion handler will know what kind of responses to expect. Our service will make a new Promise of the type of response it expects to receive, and it will parse the response we get to determine how to complete the promise. Then we’ll return a future of that promise, which our caller will use to respond when the promise completes.

import FutureKit
import SwiftyJSON
class VillainService {
let url: String
var session: URLSession = URLSession.shared
func getVillainList() -> Future<VillainResponse> {
let promise = Promise<VillainResponse>()
var request = URLRequest(url: URL(string: "http://www.wearevillains.com/team"))
request.httpMethod = "GET"
let task = session.dataTask(with: request) { data, response, error in
if error != nil {
promise.completeWithFail(error!)
} else {
do {
if let convertedJsonIntoDict = try JSONSerialization.jsonObject(with: data!, options: []) as? NSDictionary {
let dataAsJson = JSON(convertedJsonIntoDict)
promise.completeWithSuccess(APIRootResourceLinks.init(fromJSON: dataAsJson)!)
}
} catch let error as NSError {
promise.completeWithFail(error)
}
}
task.resume()
return promise.future
}
}
extension Villain : JSONDeserializable {
init?(fromJSON json: JSON) {
guard let name = json["name"].string else { return nil }
guard let specialty = json["specialty"].string else { return nil }
}
}
extension VillainResponse : JSONDeserializable {
init?(fromJSON json: JSON) {
guard let
villainCount = json["villainCount"].int,
let villains = json["villains"].array {
for (index, vill) in villains.enumerated() {
if let villain = Villain(fromJson: json["villains"][index]) {
villains.append(villain)
}
}
} else { return nil }
count = villainCount
villains = villains
}
}

view raw
VillainService.swift
hosted with ❤ by GitHub

3. Unit Testing the ViewController

So we have our service that makes a call to get a list of villains. Suppose we want to show that list of villains on a screen when it comes back. So we’re going to make a VillainsMasterViewController to see the villains. But first, let’s look at the structure of our unit tests for the ViewController’s responses when our promise completes.

You’ll see in this example test some of the other important concepts we have discussed in the past around testing iOS apps, namely mocking dependencies and presenting ViewControllers in tests. If you are interested or you need a refresher, check out these posts:

Test-Driven iOS: Mocking Dependencies

Test-Driven iOS: Using Segues (covers UI unit testing)

On to the code:

import FutureKit
import XCTest
import Nimble
@testable import VillainApp
class VillainsMasterViewControllerTest: XCTestCase {
var viewController: VillainsMasterViewController!
var mockVillainService: MockVillainService!
var somePromise: Promise<VillainResponse>?
class MockVillainService: VillainService {
var stubbedPromise: Promise<VillainResponse>?
override func getVillainList() -> Future<VillainResponse> {
return stubbedPromise.future
}
}
override func setUp() {
super.setUp()
somePromise = Promise<VillainResponse>()
mockVillainService = MockVillainService()
mockVillainService.stubbedPromise = somePromise
}
func testSuccessfulCallDisplaysVillains() {
let villainsDict = ["villainCount": 3,
"villains":[
["name": "Cruella DeVille",
"specialty": "unethical treatment of animals"],
["name": "Ursula",
"specialty": "fine print"],
["name": "Elphaba",
"specialty": "faking own death"],
]
]
let villainsResponse = VillainResponse.init(fromJSON: JSON(villainsDict))
villainsController.loadFromStoryboard(mockVillainService)
assertThat(villainsController.view, present())
let window = UIWindow()
window.rootViewController = controller
window.makeKeyAndVisible()
controller.didTapLoginButton(NSObject())
expect(self.somePromise?.isCompleted).toNot(beTrue())
somePromise?.completeWithSuccess(villainsResponse!)
expect(self.somePromise?.isCompleted).to(beTrue())
//assert that the villain list is populated
window.resignKey()
window.isHidden = true
}
}

4. Implementing the ViewController Response

This part of the asynchronous call structure feels really nice. We make the call to our VillainService in our ViewController. The service returns a future, and we chain our response blocks onto that future in the ViewController so they will be triggered when the promise completes.

import FutureKit
import UIKit
class VillainsMasterViewController: UIViewController {
let villainService: VillainService?
func viewDidLoad() {
villainService?.getVillainList()
.onSuccess() { (apiRootResourceLinks) in
//display villain information in view
}.onCancel {
//tell the user that the villain call got cancelled
}.onFail { (error) in
//show the villain app error page
}
}
}

Testing your asynchronous network calls in iOS requires a solid understanding of how to isolate the objects under test; once that is done, you can manipulate the network responses and call completions with as little as a line of code to test your implementation. These tests run independently of your actual network call to give you an idea of the state of the mobile code in isolation. Most importantly, tests like this can help you identify when you have mishandled a case while adding code or refactoring.

 

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.