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. Just so you know, performing network requests in your unit tests is not recommended. If you’re interested in solving this, 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.
Unit testing Concurrency using serial execution
While the above testing methods often work great for unit testing async/await, they can result in flaky tests. For example, imagine the following iteration of our image fetcher that comes with a loading state property:
@Observable
final class ImageFetcher {
enum Error: Swift.Error {
case imageCastingFailed
}
var isLoading = false
func fetchImage(for url: URL) async throws -> UIImage {
self.isLoading = true
defer { self.isLoading = false }
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw Error.imageCastingFailed
}
return image
}
}
Ideally, we would write a test to verify that the isLoading
property becomes true
during image fetching and resets to false
:
func testImageFetchingLoadingState() async throws {
let imageFetcher = ImageFetcher()
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let task = Task { try await imageFetcher.fetchImage(for: imageURL) }
/// Fails: XCTAssertTrue failed - Loading state should update
XCTAssertTrue(imageFetcher.isLoading, "Loading state should update")
let image = try await task.value
XCTAssertNotNil(image)
XCTAssertFalse(imageFetcher.isLoading, "Loading state should reset")
}
Unfortunately, the above test is flaky and fails more often than it succeeds. The URLSession data method inside fetchImage
will only execute when we await the task’s value. However, the XCTAssertTrue
runs before the unstructured task’s inner body, resulting in an isLoading
property still set to the default false
.
Using a main serial executor using the Concurrency Extras Library
We can solve the flaky test by introducing a 3rd party dependency called Swift Concurrency Extras. The team behind Point-Free develops it and allows the creation of a serial executor for testing purposes. The library is inspired by Apple’s AsyncSequenceValidation
, which overrides Swift’s global enqueue hook when new asynchronous tasks are created.
The library allows us to yield tasks and verify state in-between. We need to adjust our test to run inside the main serial executor and yield the unstructured task before validating our loading state:
import ConcurrencyExtras
func testImageFetchingLoadingState() async throws {
try await withMainSerialExecutor {
let imageFetcher = ImageFetcher()
let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
let task = Task { try await imageFetcher.fetchImage(for: imageURL) }
/// Yield the above task to ensure it's constructed and finished.
await Task.yield()
/// At this point, the `fetchImage` method is waiting for its value to be requested.
XCTAssertTrue(imageFetcher.isLoading, "Loading state should update")
let image = try await task.value
XCTAssertNotNil(image)
XCTAssertFalse(imageFetcher.isLoading, "Loading state should reset")
}
}
You can verify the flakiness of this test using the technique described in Flaky tests resolving using Test Repetitions in Xcode.
In case you’re running multiple tests on the main serial executor, you should consider using the following solution instead:
override func invokeTest() {
withMainSerialExecutor {
super.invokeTest()
}
}
Your test suite calls the invokeTest
method before every test and ensures all tests run on the main serial executor.
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!