Testing asynchronous code in Swift
Asynchronous code is both hard to test; and important to test.
It's important, because it often handles some of the most critical and yet unreliable parts of an app - in other words, networking.
It's hard because it's, well, asynchronous. One of the two jokes in software engineering goes something like "Some people, when faced with a problem, think 'I know, I'll use threading'. Now problems have two they.
The standard rhythm of a unit test goes like this:
- set up the preconditions for the test, to get the system into a known state
- make some assertions about what result you're looking for
- fire the functions under test
- verify that the assertions were met
The problem with asynchronous code is that the third and final steps don't always come in that order, particularly if you're attemping to test a long-running operation. It can still be churning away as your verification takes place - and your tests are unlikely to pass in that kind of scenario.
Fortunately, there are ways around this. It's not especially pretty, but it does work.
waitForExpectations
The key to testing async code in Swift is using the waitForExpectationWithTimeout
feature of the XCTest
framework. An expectation in this context is just a flag that tells the test code "I'm done, you can check your assertions now".
The expectation will hang around and wait for the fulfillment message, which gives the async code a chance to complete before the assertions are checked.
There are three stages involved in setting this up:
- Creating an expectation
- Calling the async function, and then flagging that the expection has been fulfilled when your code has comepleted
- Wrapping the assertions inside a
waitForExpectationWithTimeout block
so that they will wait for the fulfillment to occor.
Creating the expectation
This is quite simple. At the top of your test function, create an expectation with
let asyncExpectation = expectationWithDescription("longRunningFunction")
This needs to be done before you call the async code.
Fulfilling the expectation
With the expectation created, you need to signal that it's fulfilled after your asynchronous code completes. Here's an example of a function that fires off a network request, and takes a completion handler as a parameter to process the data that's returned:
func networkManager.getDataFromApiForUser(user: User, completionHandler: (userData: NSData?, error: NSError?) -> () )
In normal situations, you'd use this function with something like this:
networkManager.getDataFromApiForUser(testUser, completionHandler: { (userData, error) -> () in
... handle the error if one occurs
...
... otherwise process the userData object
)}
Testing the function in a unit test is very similar:
networkManager.getDataFromApiForUser(testUser, completionHandler: { (userData, error) -> () in
... handle the error if one occurs
...
... otherwise process the userData object
expectation.fulfill()
)}
The key difference is that last line - expectation.fulfill()
. What we're doing here is to signal to the asyncExpectation
that everything is completed, and it's now safe to check the assertions.
Checking the assertions
Assertions in this context are just the usual XCTest
assertions that you'll already be familar with. What's different is that we wrap them in a waitForExpectationsWithTimeout
block:
self.waitForExpectationsWithTimeout(5) { error in
XCTestAssertNil(error, "Something went horribly wrong")
XCTestAssertEqual(testUser.orders.count, 10)
}
This function will do two things - firstly, it will wait for a maximum of five seconds before giving up and failing. This prevents you from locking up the tests in an infinite loop if something goes wrong with the code under test. How long to wait is dependent on the kind of processing that's taking place - but ideally shouldn't be so long that it locks up the test suite unncessarily.
Secondly, it will fire the XCTest
expectations as soon as the expectation.fulfill()
function is called. By triggering that at the end of the chunk of async code, you're preventing the assertions racing away and being checked before the function under test has had a chance to complete.
Putting it all together
Putting that all together in a example test looks like this:
func testUserOrdersAreRetrievedFromApiAndProcessed() {
let asyncExpectation = expectationWithDescription("longRunningFunction")
let testUser = User.initWithName("Foo" andAccountNumber: 1234)
networkManager.getDataFromApiForUser(testUser, completionHandler: { (userData, error) -> () in
... handle the error if one occurs
...
... otherwise process the userData object
expectation.fulfill()
)}
self.waitForExpectationsWithTimeout(5) { error in
XCTestAssertNil(error, "Something went horribly wrong")
XCTestAssertEqual(testUser.orders.count, 10)
}
}
It's not the cleanest looking test code you'll ever write, especially if you're not a fan of the Junit
-style syntax of the XCTest
framework - but it works. And given how important asynchronous code like network requests or long-running processing tends to be to an app, it's worth sacrificing a little style for the reassurance that high test coverage will provide.