trashpanda.cc

Kohlenstoff-basiert Zweibeiniges Säugetier

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.