Async/await in Swift unit tests
Discover page available: Unit TestingAsynchronous code is essential to providing a good user experience, but can at times be really tricky to write, debug and especially test. Since tests execute completely synchronously by default, we often have to either adapt our production code or write more complex test cases in order to test our asynchronous function calls and operations.
While we already took a look at how to start testing asynchronous code in "Unit testing asynchronous Swift code", this week - let's explore how we can take things further and make our asynchronous tests much simpler, inspired by the async/await programming paradigm.
⚠️ Please note that the following article was written in 2018, long before Swift gained built-in support for async/await. To learn about how to use the actual async/await language feature when writing unit tests, please refer to this article instead.
Testing asynchronous code
Let's start with a quick recap of exactly why asynchronous code is so tricky to test. Here we've implemented a simple AsyncOperation
type, that takes a closure and performs it asynchronously on a given dispatch queue, and then passes the result to a completion handler:
struct AsyncOperation<Value> {
let queue: DispatchQueue = .main
let closure: () -> Value
func perform(then handler: @escaping (Value) -> Void) {
queue.async {
let value = self.closure()
handler(value)
}
}
}
In order to test the above code, we could mock DispatchQueue
in order to make it always execute synchronously - which is a good approach in many cases, but not really here, since the whole point of our AsyncOperation
type is that it executes asynchronously.
Instead, we'll use an expectation - which lets us wait until our asynchronous operation finishes, by fulfilling the expectation as part of the operation's completion handler, like this:
class AsyncOperationTests: XCTestCase {
func testPerformingOperation() {
// Given
let operation = AsyncOperation { "Hello, world!" }
let expectation = self.expectation(description: #function)
var result: String?
// When
operation.perform { value in
result = value
expectation.fulfill()
}
// Then
waitForExpectations(timeout: 10)
XCTAssertEqual(result, "Hello, world!")
}
}
Above we're using the Given, When, Then
structure to make our test code a bit easier to follow. More on that in "Making Swift tests easier to debug".
Expectations are great, but require a bit of boilerplate that both adds extra code to our test cases, and also tends to be quite repetitive to write when testing a lot of asynchronous code (since we always have to do the same create, fulfill, wait dance for every expectation in every test).
Let's take a look at how we could make the above test code a lot simpler by implementing a version of async/await.
Async/await
Async/await has become an increasingly popular way to deal with asynchronous code in several other languages - including C# and JavaScript. Instead of having to keep passing completion handlers around, async/await essentially lets us mark our asynchronous functions as async
, which we can then wait for using the await
keyword.
While Swift does not yet natively support async/await, if it was to be added it could look something like this:
async func loadImage(from url: URL) -> UIImage? {
let data = await loadData(from: url)
let image = await data?.decodeAsImage()
return image
}
As you can see above, the main benefit of async/await is that it essentially lets us write asynchronous code as if it was synchronous, since the compiler automatically synthesizes the code required to deal with the complexities of waiting for our asynchronous operations to complete.
While we can't simply add new keywords to Swift, there's a way to achieve much of the same result in our test code, using expectations under the hood.
What we'll do is that we'll add a method called await
to XCTestCase
, that takes a function that itself takes a completion handler as an argument. We'll then do the same expectation dance as before, by calling the passed function and using an expectation to wait for its completion handler to be called, like this:
extension XCTestCase {
func await<T>(_ function: (@escaping (T) -> Void) -> Void) throws -> T {
let expectation = self.expectation(description: "Async call")
var result: T?
function() { value in
result = value
expectation.fulfill()
}
waitForExpectations(timeout: 10)
guard let unwrappedResult = result else {
throw AwaitError()
}
return unwrappedResult
}
}
With the above in place, we're now able to heavily reduce the complexity of our AsyncOperation
test from before, which now only requires us to create the operation and then pass its perform
method to our new await
API - like this:
class AsyncOperationTests: XCTestCase {
func testPerformingOperation() throws {
let operation = AsyncOperation { "Hello, world!" }
let result = try await(operation.perform)
XCTAssertEqual(result, "Hello, world!")
}
}
Pretty cool! 👍 The above works since Swift has support for first class functions, which lets us pass an instance method as if it was a closure. We're also taking advantage of the fact that unit tests can throw in Swift, which becomes really useful in situations like the one above (since we don't need to deal with any optionals).
Additional complexity
Our above await
method works great as long as the asynchronous function we're calling doesn't accept any arguments other than a completion handler. However, many times that's not the case, such as in this test - where we're verifying that an ImageResizer
correctly resizes a given image, which requires an additional factor
argument to be passed to the asynchronous resize
method:
class ImageResizerTests: XCTestCase {
func testResizingImage() {
// Given
let originalSize = CGSize(width: 200, height: 100)
let resizer = ImageResizer(image: .stub(withSize: originalSize))
let expectation = self.expectation(description: #function)
var resizedImage: UIImage?
// When (here we need to pass a factor as an additional argument)
resizer.resize(byFactor: 5) { image in
resizedImage = image
expectation.fulfill()
}
// Then
waitForExpectations(timeout: 10)
XCTAssertEqual(resizedImage?.size, CGSize(width: 1000, height: 500))
}
}
While we wouldn't be able to use our previous version of await
to write the above test (since we need to pass a factor to resize
), the good news is that we can easily add an overload that accepts an additional argument besides a completion handler - like this:
extension XCTestCase {
// We'll add a typealias for our closure types, to make our
// new method signature a bit easier to read.
typealias Function<T> = (T) -> Void
func await<A, R>(_ function: @escaping Function<(A, Function<R>)>,
calledWith argument: A) throws -> R {
return try await { handler in
function((argument, handler))
}
}
}
With the above in place, we're now able to give our ImageResizer
test the same treatment as our AsyncOperation
test before, and heavily reduce its length and complexity by using our new await
overload:
class ImageResizerTests: XCTestCase {
func testResizingImage() throws {
let originalSize = CGSize(width: 200, height: 100)
let resizer = ImageResizer(image: .stub(withSize: originalSize))
let resizedImage = try await(resizer.resize, calledWith: 5)
XCTAssertEqual(resizedImage.size, CGSize(width: 1000, height: 500))
}
}
So far so good! 👍 The only major downside with the above approach is that we'll have to keep adding additional overloads of async
for every new combination of arguments and completion handlers that we encounter. While new overloads are fairly easy to create, we could end up having to maintain quite a lot of additional code. It could definitely still be worth it, but let's see if we can take things a bit further, shall we? 😉
Awaiting the future
If we take a peek under the hood of the async/await implementation used in JavaScript, we can see that it actually is just syntactic sugar on top of Futures & Promises (C# also uses a similar Task metaphor). Like we took a look at in "Under the hood of Futures & Promises in Swift", Futures & Promises provide much of the same value as async/await, but with a slightly more verbose syntax.
When using Futures & Promises, each asynchronous call returns a Future
, which can then be observed to await its result. Since a Future
always has the same layout, regardless of the signature of the function that generated it, we can easily add just a single await
overload to support all Future
-based asynchronous APIs.
Our new overload takes a Future<T>
instead of a function, and performs much of the same work as before - creating an expectation and using it to await the result of the future, like this:
extension XCTestCase {
func await<T>(_ future: Future<T>) throws -> T {
let expectation = self.expectation(description: "Async call")
var result: Result<T>?
future.observe { asyncResult in
result = asyncResult
expectation.fulfill()
}
waitForExpectations(timeout: 10)
switch result {
case nil:
throw AwaitError()
case .value(let value)?:
return value
case .error(let error)?:
throw error
}
}
}
With the above in place, we can now easily support any kind of asynchronous functions (regardless of the amount of arguments that they accept), as long as they return a Future
instead of using a completion handler. If we modify our ImageResizer
from before to do just that, we can use the same simple test code, but without having to add additional overloads of await
:
class ImageResizerTests: XCTestCase {
func testResizingImage() throws {
let originalSize = CGSize(width: 200, height: 100)
let resizer = ImageResizer(image: .stub(withSize: originalSize))
let resizedImage = try await(resizer.resize(byFactor: 5))
XCTAssertEqual(resizedImage.size, CGSize(width: 1000, height: 500))
}
}
The beauty of Swift's overloading capabilities is that we don't have to choose if we don't want to. We can keep supporting both completion handler-based and Futures/Promises-based asynchronous code at the same time, which is especially useful when migrating from one pattern to another.
Conclusion
While Swift doesn't yet natively support async/await, we can follow much of the same ideas in order to make our tests that verify asynchronous code a lot simpler. While adding new methods (such as async
) to XCTestCase
definitely has a maintenance cost, it should most often turn out to be a net win given how much boilerplate we can remove from all test cases dealing with asynchronous APIs.
When writing code like the samples from this article, it's also hard not to be amazed by how powerful Swift's type system is, and just how many cool things we can do in a language that supports first class functions in addition to strong, static typing. It's of course important not to take things too far, and in many ways using Futures & Promises instead of complex completion handlers can be a way to keep things relatively simple.
Maybe one day we'll have native support for both Futures & Promises, as well as async/await, in Swift - but until then we can always add lightweight extensions to take us much of the way there.
What do you think? Do you like the Futures & Promises programming model, is async/await something you'd like to see in Swift, and could these concepts make your asynchronous test code simpler? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.
Thanks for reading! 🚀