Weekly Swift articles, podcasts and tips by John Sundell.

Async/await in Swift unit tests

Published on 23 Sep 2018
Basics article available: Unit Testing

Asynchronous 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.

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! 🚀