Swift:使用async/await对代码进行单元测试的教程

472 阅读6分钟

为异步代码编写健壮和可预测的单元测试一直是特别具有挑战性的,因为每个测试方法都是完全串行执行的,一行一行的(至少在使用XCTest时)。因此,当使用像完成处理程序、委托或甚至Combine这样的模式时,我们总是不得不在执行完一个给定的异步操作后找到回到同步测试环境的方法。

不过,随着async/await的引入,在许多不同的情况下,编写异步测试开始变得更加简单。让我们来看看为什么会这样,以及async/await如何成为一个伟大的测试工具,即使在验证尚未迁移到Swift的新并发系统的异步代码时。

异步测试方法

假设我们正在开发一个应用程序,其中包括以下ImageTransformer ,它有一个异步方法,可以让我们把图片的大小调整到一个新的尺寸。

struct ImageTransformer {
    ...

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

由于上述方法被标记为async ,我们在调用它时需要使用await ,这又意味着这些调用需要发生在一个支持并发的上下文中。通常情况下,创建这样的并发上下文需要将我们的异步代码包装在一个Task ,但好消息是,苹果内置的单元测试框架--XCTest--已经升级,可以自动为我们进行包装工作。

因此,为了在我们的一个测试中调用上述ImageTransformer 方法,我们所要做的就是使该测试方法成为async ,然后我们就可以在其中直接使用await

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)
    }
    
    ...
}

这绝对是async/await真正发挥作用的领域之一,因为它让我们以一种几乎与我们测试同步代码相同的方式编写异步测试。没有更多的期望,部分结果,或管理超时 - 真的很好

测试抛出的API

由于测试方法也可以被标记throws ,我们甚至可以在测试异步API时使用上述设置,这些API也可以抛出错误。例如,如果我们让我们的resize 方法能够抛出错误,那么我们的ImageTransformer 测试会是什么样子:

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)
    }
    
    ...
}

就像XCTest帮助我们管理非抛出的异步调用一样,系统将处理上述代码产生的任何错误,并自动将任何此类错误转化为适当的测试失败。

预期的替代方案

Swift 的新并发系统还包括一组*延续性 API*,使我们能够使其他类型的异步代码与 async/await 兼容,虽然这些 API 主要是为了弥补我们现有代码与新并发系统之间的差距,但它们也可以作为 XCTest 的期望系统的替代。

例如,现在让我们想象一下,我们的ImageTransformer 还没有被迁移到使用async/await,相反,它目前使用的是基于完成处理器的API,看起来像这样:

struct ImageTransformer {
    ...

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

使用 Swift 的withCheckedThrowingContinuation 函数,我们实际上仍然可以使用 async/await 测试上述方法,而不需要对ImageTransformer 本身做任何修改。我们所要做的就是使用那个continuation函数来包装我们对resize 的调用,然后将我们的完成处理程序的结果传递给我们被赋予权限的continuation对象:

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)
    }
    
    ...
}

我将让你来决定上述做法与创建、等待、然后完成一个期望相比,是更好、更坏,还是只是不同。但无论如何,这肯定是我们工具箱中的一个很好的工具,即使我们还没有准备好在生产代码中完全采用Swift的新并发系统,也许在编写测试时使用上述技术可以作为async/await等概念的一个很好的介绍。

Linux兼容性

虽然本文所涉及的所有工具和技术都是完全向后兼容的(从Xcode 13.2开始),但在撰写本文时,我们还不能在苹果平台以外的地方(如Linux)使用Swift Package Manager的自动测试发现功能,使用async-marked测试方法。

值得庆幸的是,我们可以使用上述的期望系统来解决这个问题--例如,通过扩展XCTestCase 的实用方法,让我们把异步测试代码包裹在async 标记的闭包中:

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
            )
        }
    }
}

我们也可以选择将上述runAsyncTest 方法标记为throws ,然后直接抛出所遇到的任何错误。然而,这就要求我们在调用上述方法时总是使用try (即使是在测试那些实际上不能抛出的代码时),或者为它引入两个独立的重载(一个抛出,一个不抛出)。所以,在这种情况下,我们要把任何抛出的错误传递给XCTFail ,以便在遇到错误时造成测试失败。

有了以上这些,我们现在可以简单地将任何我们想要测试的基于异步/等待的代码包裹在对我们新的runAsyncTest 方法的调用中--我们将能够在传递的闭包中直接使用tryawait ,就像在苹果平台上运行我们的测试一样:

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)
        }
    }
    
    ...
}

请注意,我们还对上述代码做了一些其他的调整,以使其与Linux兼容,比如使用一个自定义的Image ,而不是使用UIImage

值得庆幸的是,上述的变通方法应该不需要很长时间,因为我完全期待XCTest的开源版本(在非苹果平台上使用的)最终会更新,并提供与Xcode的版本相同的async/await支持。

结论

我个人认为,当涉及到编写涵盖异步代码的测试时,async/await是一个相当大的改变。

谢谢你的阅读!