Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Win a iOSKonf '24 conference ticket with thisfree giveaway

Unit testing async/await Swift code

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:

Unit testing async/await with UI logic can result in build errors.
Unit testing async/await with UI logic can result in build errors.

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

Asserting Main actor-isolated properties isn’t possible from a non-isolated autoclosure.
Asserting Main actor-isolated properties isn’t possible 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!

 

Stay updated with the latest in Concurrency

The 2nd largest newsletter in the Apple development community with 18,327 developers.


Featured SwiftLee Jobs

Find your next Swift career step at world-class companies with impressive apps by joining the SwiftLee Talent Collective. I'll match engineers in my collective with exciting app development companies. SwiftLee Jobs