Unit tests allow you to validate code written using the latest concurrency framework and async/await. While writing tests doesn’t differ much from synchronous tests, there are a few crucial concepts to be aware of when validating asynchronous code.
If you’re new to async/await, I encourage you first to read Async await in Swift explained with code examples. Similarly, for unit tests, you can get started by reading Getting started with Unit Tests in Swift. In this article, we’re going to dive into writing tests for code using the latest concurrency framework.
Testing asynchronous code
In this example, we’re going to fetch an image asynchronously using the following image fetcher:
struct ImageFetcher {
enum Error: Swift.Error {
case imageCastingFailed
}
func fetchImage(for url: URL) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw Error.imageCastingFailed
}
return image
}
}
The code is relatively simple and helps us explain the concept of testing code that uses async/await. Note that performing network requests in your unit tests is not recommended. If you’re interested in solving this you can read my article How to mock Alamofire and URLSession requests in Swift.
The image fetching method is marked with async
and throws
, so we’ll have to deal with both asynchronous code and handling failures. You can write a unit test to validate image fetching as follows:
final class ImageFetcherTests: XCTestCase {
/// We marked the method to be async and throwing.
func testImageFetching() async throws {
let imageFetcher = ImageFetcher()
/// The image URL in this example returns a random image.
/// I recommend mocking outgoing network requests as a best practice:
/// https://avanderlee.com/swift/mocking-alamofire-urlsession-requests/
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let image = try await imageFetcher.fetchImage(for: imageURL)
XCTAssertNotNil(image)
}
}
By marking our unit test definition with async
and throws
you can:
- Call async code directly in your unit test using
await
- Use throwing methods using
try
and let the test automatically fail when an error throws
We simplify the unit test by marking our test definition as asynchronous and throwing. Using a Task or a try-catch structure for unit testing async/await logic is unnecessary.
Unit testing UI logic using @MainActor
While unit testing async/await, you might run into the following error:
Expression is ‘async’ but is not marked with ‘await’
Xcode could show this error when unit testing code that accesses UI logic like view controllers:
Types like UIViewController
contain the @MainActor attribute, requiring you only to access them on the main thread. You can read more about this attribute in MainActor usage in Swift explained to dispatch to the main thread.
In this case, we can mark the constructor using await. However, as soon as we want to validate the configuration, we will run into the following error:
Main actor-isolated property ‘image’ can not be referenced from a non-isolated autoclosure
A proper fix is to mark the unit testing method with the @MainActor
attribute:
@MainActor
func testImageConfiguration() async {
let viewController = ImageViewController()
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
await viewController.configureImage(using: imageURL)
XCTAssertNotNil(viewController.imageView.image)
}
This allows us to remove the await keyword before the ImageViewController
constructor since the unit test will now run in a Main actor-isolated context.
Preventing XCTestExpectation deadlocks
The concurrency framework is still relatively new, and you’re likely developing inside an older code base that uses test expectations. Unit testing async/await containing logic by using expectations can result in so-called deadlocks: a situation in which two or multiple threads wait for each other indefinitely.
In fact, since Xcode 14.3, you could run into the following warning:
Instance method ‘wait’ is unavailable from asynchronous contexts; Use await fulfillment(of:timeout:enforceOrder:) instead; this is an error in Swift 6
The warning is clear and helps you solve the problem using the alternative method, but the warning doesn’t always show up. It’s more likely you didn’t mark your unit tests with async
yet, like as follows:
func testImageConfigurationCompletionCallback() {
let viewController = ImageViewController()
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let imageConfigurationExpectation = expectation(description: "Image should be configured")
viewController.configureImage(using: imageURL, completion: {
imageConfigurationExpectation.fulfill()
})
wait(for: [imageConfigurationExpectation])
}
In this example, we’re validating the ImageViewController.configureImage
method that looks as follows:
func configureImage(using url: URL, completion: @escaping () -> Void) {
Task {
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let image = try? await ImageFetcher().fetchImage(for: imageURL)
await MainActor.run {
imageView.image = image
completion()
}
}
}
As you can see, the method still uses a completion callback, while the inner logic uses the concurrency framework. This typical example could result in flaky tests due to unexpected deadlocks.
You can solve this issue by rewriting your unit tests that validate concurrent code by making use of the await fulfillment
method:
@MainActor
func testImageConfigurationCompletionCallback() async {
let viewController = ImageViewController()
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let imageConfigurationExpectation = expectation(description: "Image should be configured")
viewController.configureImage(using: imageURL, completion: {
imageConfigurationExpectation.fulfill()
})
await fulfillment(of: [imageConfigurationExpectation])
}
You could argue that using this method for every unit test is best practice. However, if no logic under test uses the concurrency framework, it’s better to stick with regular expectations waiting for methods for increased performance.
Conclusion
Unit testing async/await logic requires a different way of writing tests. You’ll have to run certain logic in main actor-isolated contexts and you can prevent deadlocks by awaiting expectations using the new fulfillment method.
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!