Articles, podcasts and news about Swift development, by John Sundell.

Unit testing Swift code that uses async/await

Published on 16 Nov 2021
Discover page available: Unit Testing

Writing robust and predictable unit tests for asynchronous code has always been particularly challenging, given that each test method is executed completely serially, line by line (at least when using XCTest). So when using patterns like completion handlers, delegates, or even Combine, we’d always have to find our way back to our synchronous testing context after performing a given asynchronous operation.

With the introduction of async/await, though, writing asynchronous tests is starting to become much simpler in many different kinds of situations. Let’s take a look at why that is, and how async/await can also be a great testing tool even when verifying asynchronous code that hasn’t yet been migrated to Swift’s new concurrency system.

Essential Developer

Essential Developer: Learn the most up-to-date techniques and strategies for testing new and legacy Swift code in this free practical course for developers who want to become complete senior iOS developers. This virtual event includes lectures, mentoring sessions, and step-by-step instructions. Click to learn more.

Asynchronous test methods

Let’s say that we’re working on an app that includes the following ImageTransformer, which has an asynchronous method that lets us resize an image to a new size:

struct ImageTransformer {
    ...

    func resize(_ image: UIImage, to newSize: CGSize) async -> UIImage {
        ...
    }
}

Since the above method is marked as async, we’ll need to use await when calling it, which in turn means that those calls need to happen within a context that supports concurrency. Normally, creating such a concurrent context involves wrapping our async code within a Task, but the good news is that Apple’s built-in unit testing framework — XCTest — has been upgraded to automatically do that wrapping work for us.

So, in order to call the above ImageTransformer method within one of our tests, all that we have to do is to make that test method async, and we’ll then be able to directly use await within it:

class ImageTransformerTests: XCTestCase {
    func testResizedImageHasCorrectSize() async {
        let transformer = ImageTransformer()
        let originalImage = UIImage()
        let targetSize = CGSize(width: 100, height: 100)

        let resizedImage = await transformer.resize(
            originalImage,
            to: targetSize
        )

        XCTAssertEqual(resizedImage.size, targetSize)
    }
    
    ...
}

This is definitely one of the areas in which async/await really shines, as it lets us write asynchronous tests in a way that’s almost identical to how we’d test our synchronous code. No more expectations, partial results, or managing timeouts — really neat!

Testing throwing APIs

Since test methods can also be marked as throws, we can even use the above setup when testing asynchronous APIs that can throw errors as well. For example, here’s what our ImageTransformer test could look like if we made our resize method capable of throwing errors:

struct ImageTransformer {
    ...

    func resize(_ image: UIImage,
                to newSize: CGSize) async throws -> UIImage {
        ...
    }
}

class ImageTransformerTests: XCTestCase {
    func testResizedImageHasCorrectSize() async throws {
        let transformer = ImageTransformer()
        let originalImage = UIImage()
        let targetSize = CGSize(width: 100, height: 100)

        let resizedImage = try await transformer.resize(
            originalImage,
            to: targetSize
        )

        XCTAssertEqual(resizedImage.size, targetSize)
    }
    
    ...
}

Just like how XCTest helps us manage our non-throwing async calls, the system will handle any errors that will be generated by the above code, and will automatically turn any such errors into proper test failures.

An alternative to expectations

Swift’s new concurrency system also includes a set of continuation APIs that enable us to make other kinds of asynchronous code compatible with async/await, and while those APIs are primarily aimed at bridging the gap between our existing code and the new concurrency system, they can also be used as an alternative to XCTest’s expectations system.

For example, let’s now imagine that our ImageTransformer hasn’t yet been migrated to using async/await, and that it’s instead currently using a completion handler-based API that looks like this:

struct ImageTransformer {
    ...

    func resize(
        _ image: UIImage,
        to newSize: CGSize,
        then onComplete: @escaping (Result<UIImage, Error>) -> Void
    ) {
        ...
    }
}

Using Swift’s withCheckedThrowingContinuation function, we can actually still test the above method using async/await, without requiring us to make any modifications to ImageTransformer itself. All that we have to do is to use that continuation function to wrap our call to resize, and to then pass our completion handler’s result to the continuation object that we’re given access to:

class ImageTransformerTests: XCTestCase {
    func testResizedImageHasCorrectSize() async throws {
        let transformer = ImageTransformer()
        let originalImage = UIImage()
        let targetSize = CGSize(width: 100, height: 100)

        let resizedImage = try await withCheckedThrowingContinuation { continuation in
            transformer.resize(originalImage, to: targetSize) { result in
                continuation.resume(with: result)
            }
        }

        XCTAssertEqual(resizedImage.size, targetSize)
    }
    
    ...
}

I’ll leave it up to you to decide whether the above is better, worse, or just different compared to creating, awaiting, and then fulfilling an expectation. But regardless, it’s certainly a great tool to have in our toolbox, and even if we’re not yet ready to fully adopt Swift’s new concurrency system within our production code, perhaps using the above technique when writing tests can be a great introduction to concepts like async/await.

Linux compatibility

Although all of the tools and techniques that’s been covered in this article are fully backward compatible (starting in Xcode 13.2), at the time of writing, we’re not yet able to use async-marked test methods outside of Apple’s platforms (like on Linux) when using the Swift Package Manager’s automatic test discovery feature.

Thankfully, that’s something that we can work around using the aforementioned expectation system — for example by extending XCTestCase with a utility method that’ll let us wrap our asynchronous testing code within an async-marked closure:

extension XCTestCase {
    func runAsyncTest(
        named testName: String = #function,
        in file: StaticString = #file,
        at line: UInt = #line,
        withTimeout timeout: TimeInterval = 10,
        test: @escaping () async throws -> Void
    ) {
        var thrownError: Error?
        let errorHandler = { thrownError = $0 }
        let expectation = expectation(description: testName)

        Task {
            do {
                try await test()
            } catch {
                errorHandler(error)
            }

            expectation.fulfill()
        }

        waitForExpectations(timeout: timeout)

        if let error = thrownError {
            XCTFail(
                "Async error thrown: \(error)",
                file: file,
                line: line
            )
        }
    }
}

We could’ve also opted to mark the above runAsyncTest method as throws, and to then directly throw any error that was encountered. However, that’d either require us to always use try when calling the above method (even when testing code that can’t actually throw), or to introduce two separate overloads of it (one throwing, one non-throwing). So, in this case, we’re instead passing any thrown error to XCTFail to cause a test failure when an error was encountered.

With the above in place, we can now simply wrap any async/await-based code that we’re looking to test within a call to our new runAsyncTest method — and we’ll be able to directly use both try and await within the passed closure, just like when running our tests on Apple’s platforms:

class ImageTransformerTests: XCTestCase {
    func testResizedImageHasCorrectSize() {
        runAsyncTest {
            let transformer = ImageTransformer()
            let originalImage = Image()
            let targetSize = Size(width: 100, height: 100)

            let resizedImage = try await transformer.resize(
                originalImage,
                to: targetSize
            )

            XCTAssertEqual(resizedImage.size, targetSize)
        }
    }
    
    ...
}

Note that we’ve also made a few other tweaks to the above code in order to make it Linux-compatible, such as using a custom Image type, rather than using UIImage.

Thankfully, the above workaround shouldn’t be required for long, since I fully expect that the open source version of XCTest (that’s used on non-Apple platforms) will eventually be updated with the same async/await support that Xcode’s version has.

Support Swift by Sundell by checking out this sponsor:

Essential Developer

Essential Developer: Learn the most up-to-date techniques and strategies for testing new and legacy Swift code in this free practical course for developers who want to become complete senior iOS developers. This virtual event includes lectures, mentoring sessions, and step-by-step instructions. Click to learn more.

Conclusion

Personally, I think that async/await is quite a game-changer when it comes to writing tests that are covering asynchronous code. In fact, all the way back in 2018, I wrote an article on how to build a custom version of that pattern for use in unit tests, so I’m very delighted to now have those capabilities built into the language itself.

I hope that this article has given you a few ideas on how you could start deploying async/await within your asynchronous unit tests, and if you have any questions, comments, or feedback, then feel free to reach out via email.

Thanks for reading!