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