Unit testing asynchronous Swift code
Discover page available: Unit TestingWhen starting to work with tests, there's one problem in particular that almost every developer runs into - how to test asynchronous code. It can be code that makes network requests, that performs work on multiple threads, or schedules delayed operations.
While we've touched on this subject before in posts like "Mocking in Swift" and "Reducing flakiness in Swift tests" - this week, let's focus in on a few different techniques that can help make testing asynchronous code a lot easier.
What's the problem?
So exactly why is it so hard to test asynchronous code? The core of the problem is that a test is considered over as soon as its function returns. Because of that, any asynchronous code will be ignored, since it'll run after the test has already finished.
Not only can this make code hard to test, but it can also lead to false positives. Let's take a look at an example in which we are testing that an ImageScaler
is returning the correct amount of scaled images:
class ImageScalerTests: XCTestCase {
func testScalingProducesSameAmountOfImages() {
let scaler = ImageScaler()
let originalImages = loadImages()
scaler.scale(originalImages) { scaledImages in
XCTAssertEqual(scaledImages.count, originalImages.count)
}
}
}
The problem is that ImageScaler
is always doing its work asynchronously, meaning that the closure will be executed too late. So even if our assert will fail, our test will still always pass.
Expectations
A common way of solving problems like the above is to use expectations. They essentially provide a way to tell the test runner that you want to wait for a while before proceeding. Let's update our test from above to use an expectation:
class ImageScalerTests: XCTestCase {
func testScalingProducesSameAmountOfImages() {
let scaler = ImageScaler()
let originalImages = loadImages()
// Create an expectation
let expectation = self.expectation(description: "Scaling")
var scaledImages: [UIImage]?
scaler.scale(originalImages) {
scaledImages = $0
// Fullfil the expectation to let the test runner
// know that it's OK to proceed
expectation.fulfill()
}
// Wait for the expectation to be fullfilled, or time out
// after 5 seconds. This is where the test runner will pause.
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(scaledImages?.count, originalImages.count)
}
}
Note that we also moved our call to XCTAssertEqual
out from the image scaler's completion handler, into the root of the test function. Doing this eliminates the risk of further false positives, since even if we somehow mess up our test logic and fail to wait for the asynchronous work to complete, we'll still get a test failure.
As you can see above, using expectations can be a great (and fairly easy) way to write tests against asynchronous APIs that use completion handlers, since we can simply fulfill our expectation in that closure. It's usually a good fit for code that we can't really speed up and need to wait for - without having to introduce unnecessary waiting time, since we can proceed as soon as our asynchronous operation completes.
Inverted expectations
The cool thing is that expectations are not only useful for waiting for something to happen - they can also be used to verify that something didn't happen.
Let's say that we're writing tests for a Debouncer
class that lets us easily delay closures (for example, if we're implementing a search-as-you-type UI and we don't want to perform too many network requests). To test that our Debouncer
actually delays the closures we send it, and that it cancels any pending closure when a new one is scheduled, we can use an inverted expectation.
Inverting an expectation is as easy as setting the isInverted
property to true
, and it works just as you might expect - the test will fail in case the expectation is fulfilled within the deadline timeframe. Combining an inverted expectation with a normal one, we can write a test like this:
class DebouncerTests: XCTestCase {
func testPreviousClosureCancelled() {
let debouncer = Debouncer(delay: 0.25)
// Expectation for the closure we'e expecting to be cancelled
let cancelExpectation = expectation(description: "Cancel")
cancelExpectation.isInverted = true
// Expectation for the closure we're expecting to be completed
let completedExpectation = expectation(description: "Completed")
debouncer.schedule {
cancelExpectation.fulfill()
}
// When we schedule a new closure, the previous one should be cancelled
debouncer.schedule {
completedExpectation.fulfill()
}
// We add an extra 0.05 seconds to reduce the risk for flakiness
waitForExpectations(timeout: 0.3, handler: nil)
}
}
As you can see above, we don't really need to perform any assertions ourselves, as XCTest will automatically fail our test in case our expectations aren't met. Pretty sweet! 🍭
Using dispatch queues
Even though expectations are very powerful and useful in many different situations, sometimes we don't have a completion handler or other callback to hook into. This is especially true for "fire and forget" type operations, such as preloading content.
Let's say that we're writing tests for a FileLoader
, which has an API to preload a set of files on a background queue. Normally, it'd be really hard to know when such an operation finishes, and adding a whole completion handler API only for testing feels kind of wrong.
But what we can do, is to enable a DispatchQueue
to be injected from the outside - that way we can use the sync
method to issue a synchronous closure on a specific queue right after our preloading has occurred, which will enable us to wait for the operation to finish, like this:
class FileLoaderTests: XCTestCase {
func testPreloadingFiles() {
let loader = FileLoader()
let queue = DispatchQueue(label: "FileLoaderTests")
loader.preloadFiles(named: ["One", "Two", "Three"], on: queue)
// Issue an empty closure on the queue and wait for it to be executed
queue.sync {}
let preloadedFileNames = loader.preloadedFiles.map { $0.name }
XCTAssertEqual(preloadedFileNames, ["One", "Two", "Three"])
}
}
Just like when using expectations, we enable our test to proceed as soon as all preloading operations have finished - wasting zero precious time! 👍
Turning synchronous
Using the above techniques can help us make asynchronous testing much easier in a wide range of scenarios, but you know what would be even easier - not having to do asynchronous testing in the first place! What if we could turn our asynchronous code synchronous, just for testing? 🤔
For example, let's say that we have a class called PushNotificationManager
that deals with the status of push notifications using the UNUserNotificationCenter
API. When requesting permission to send push notifications, we call the requestAuthorization
API, which is asynchronous and responds using a completion handler. Because of that, our PushNotificationManager
needs to be async too, but let's take a look at how we can use a mock to actually make it synchronous in our tests.
Similar to how we mocked our networking code in "Mocking in Swift" - we'll abstract UNUserNotificationCenter
into a protocol that we can create a synchronous mock of in our tests:
protocol UserNotificationCenter {
func requestAuthorization(options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void)
}
// Since our protocol requirements exactly match UNUserNotificationCenter's API,
// we can simply make it conform to it using an empty extension.
extension UNUserNotificationCenter: UserNotificationCenter {}
class UserNotificationCenterMock: UserNotificationCenter {
// Properties that let us control the outcome of an authorization request.
var grantAuthorization = false
var error: Error?
func requestAuthorization(options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void) {
// We execute the completion handler right away, synchronously.
completionHandler(grantAuthorization, error)
}
}
Now we can write a test more or less like we would against a synchronous API - without expectations, fiddling with dispatch queues, or waiting for any operations to complete:
class PushNotificationManagerTests: XCTestCase {
func testSuccessfulAuthorization() {
let center = UserNotificationCenterMock()
let manager = PushNotificationManager(notificationCenter: center)
center.grantAuthorization = true
var status: PushNotificationManager.Status?
manager.enableNotifications { status = $0 }
XCTAssertEqual(status, .enabled)
}
}
A big benefit of this approach is that our test will execute instantly, since we don't need to wait for any background tasks to finish 🎉.
Conclusion
Testing asynchronous code will probably always be a lot trickier than when dealing with purely synchronous code - but by using techniques like the ones from this post, we can make it a lot easier. Like always, my recommendation is to try to learn about as many different techniques as possible in order to be able to pick the most appropriate tool for each situation.
Regardless of which technique you choose, I really recommend staying away from adding waiting time (like calling sleep
) in your tests - that'll just make them slow and can be a huge source of flakiness down the line.
What do you think? What's your favorite way of dealing with asynchronous code in Swift unit tests? Let me know, along with any comments, questions or feedback you have - on Twitter @johnsundell. For more blog posts about unit testing in Swift - head over to the Categories Page.
Thanks for reading! 🚀