Swift:基于Combine框架测试异步代码的教程

420 阅读6分钟

测试异步代码往往特别棘手,使用苹果的Combine框架编写的代码也不例外。由于每个基于XCTest的单元测试都是以纯粹的同步方式执行的,我们必须找到方法告诉测试运行器等待我们要测试的各种异步调用的输出--否则它们的输出将在我们的测试完成才会出现。

因此,在这篇文章中,让我们看看在涉及到基于Combine发布器的代码时,如何做到这一点,以及我们如何用一些实用工具来增强Combine的内置API,这将极大地改善其测试工效。

等待的期望

举个例子,假设我们一直在开发一个Tokenizer ,可以用来识别一个字符串中的各种标记(如用户名、URL、标签等)。由于我们希望对不同长度和复杂程度的字符串进行标记,我们选择让我们的Tokenizer ,在后台线程上执行其工作,然后使用Combine异步报告它在给定的String 中发现的标记:

struct Tokenizer {
    func tokenize(_ string: String) -> AnyPublisher<[Token], Error> {
        ...
    }
}

现在,假设我们想写一系列的测试来验证我们的标记化逻辑是如何工作的,由于上述API是异步的,我们将不得不做一些工作来确保这些测试将可预测地执行。

就像我们在*"单元测试异步Swift代码 "中看到的那样XCTest的期望*系统使我们能够告诉测试运行器通过创建、等待和实现一个XCTestExpectation 实例来等待异步调用的结果。因此,让我们使用该系统,以及Combine的sink 操作符,来编写我们的第一个测试,像这样:

class TokenizerTests: XCTestCase {
    private var cancellables: Set<AnyCancellable>!

    override func setUp() {
        super.setUp()
        cancellables = []
    }

    func testIdentifyingUsernames() {
        let tokenizer = Tokenizer()
    
        // Declaring local variables that we'll be able to write
        // our output to, as well as an expectation that we'll
        // use to await our asynchronous result:
        var tokens = [Token]()
        var error: Error?
        let expectation = self.expectation(description: "Tokenization")

        // Setting up our Combine pipeline:
        tokenizer
            .tokenize("Hello @john")
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let encounteredError):
                    error = encounteredError
                }

                // Fullfilling our expectation to unblock
                // our test execution:
                expectation.fulfill()
            }, receiveValue: { value in
                tokens = value
            })
            .store(in: &cancellables)

        // Awaiting fulfilment of our expecation before
        // performing our asserts:
        waitForExpectations(timeout: 10)

        // Asserting that our Combine pipeline yielded the
        // correct output:
        XCTAssertNil(error)
        XCTAssertEqual(tokens, [.text("Hello "), .username("john")])
    }
}

虽然上面的测试工作得非常好,但可以说它并不那么令人愉快,而且它涉及到大量的模板代码,我们可能不得不在每一个我们要写的Tokenizer 测试中重复这些代码。

一个专门的方法来等待发布者的输出

因此,让我们通过引入 "Swift单元测试中的Async/await "中的await 方法的变体来解决这些问题 该方法基本上将观察和等待发布者结果所需的所有设置代码转移到一个专门的XCTestCase 方法中:

extension XCTestCase {
    func await<T: Publisher>(
        _ publisher: T,
        timeout: TimeInterval = 10,
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> T.Output {
        // This time, we use Swift's Result type to keep track
        // of the result of our Combine pipeline:
        var result: Result<T.Output, Error>?
        let expectation = self.expectation(description: "Awaiting publisher")

        let cancellable = publisher.sink(
            receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    result = .failure(error)
                case .finished:
                    break
                }

                expectation.fulfill()
            },
            receiveValue: { value in
                result = .success(value)
            }
        )

        // Just like before, we await the expectation that we
        // created at the top of our test, and once done, we
        // also cancel our cancellable to avoid getting any
        // unused variable warnings:
        waitForExpectations(timeout: timeout)
        cancellable.cancel()

        // Here we pass the original file and line number that
        // our utility was called at, to tell XCTest to report
        // any encountered errors at that original call site:
        let unwrappedResult = try XCTUnwrap(
            result,
            "Awaited publisher did not produce any output",
            file: file,
            line: line
        )

        return try unwrappedResult.get()
    }
}

有了上述内容,我们现在就可以大大简化我们原来的测试,现在只需用几行代码就可以实现:

class TokenizerTests: XCTestCase {
    func testIdentifyingUsernames() throws {
        let tokenizer = Tokenizer()
        let tokens = try await(tokenizer.tokenize("Hello @john"))
        XCTAssertEqual(tokens, [.text("Hello "), .username("john")])
    }
}

好多了!然而,我们必须记住的一点是,我们新的await 方法假定传入它的每个发布者最终会完成,而且它也只捕获发布者发出的最新值。因此,虽然它对遵守经典的请求/响应模式的发布者非常有用,但我们可能需要一套稍微不同的工具来测试不断发射新值的发布者。

测试发布的属性

一个非常普遍的例子是,发布者永远不会完成,并且不断地发射新的值,这就是发布的属性。例如,假设我们现在已经将我们的Tokenizer 包装成一个ObservableObject ,可以在我们的UI代码中使用,它可能看起来像这样:

class EditorViewModel: ObservableObject {
    @Published private(set) var tokens = [Token]()
    @Published var string = ""

    private let tokenizer = Tokenizer()

    init() {
        // Every time that a new string is assigned, we pass
        // that new value to our tokenizer, and we then assign
        // the result of that operation to our 'tokens' property:
        $string
            .flatMap(tokenizer.tokenize)
            .replaceError(with: [])
            .assign(to: &$tokens)
    }
}

注意我们目前是如何用一个空的标记数组来替换所有的错误,以便能够直接将我们的发布者分配给我们的tokens 属性。另外,我们可以使用像catch 这样的操作符来执行更多的自定义错误处理,或者将任何遇到的错误传播到用户界面。

现在我们假设我们想写一个测试,确保我们的新EditorViewModel 在其string 属性被修改时不断发布新的[Token] 值,这意味着我们不再只对一个输出值感兴趣,而是对多个输出值感兴趣。

关于如何处理这个问题,同时仍然能够使用我们之前的await 方法,一个初步的想法可能是使用Combine的collect 操作符来发射一个包含我们视图模型的tokens 属性将发布的所有值的单一数组,然后对该数组进行一系列的验证--就像这样:

class EditorViewModelTests: XCTestCase {
    func testTokenizingMultipleStrings() throws {
        let viewModel = EditorViewModel()
        
        // Here we collect the first two [Token] values that
        // our published property emitted:
        let tokenPublisher = viewModel.$tokens
            .collect(2)
            .first()
    
        // Triggering our underlying Combine pipeline by assigning
        // new strings to our view model:
        viewModel.string = "Hello @john"
        viewModel.string = "Check out #swift"

        // Once again we wait for our publisher to complete before
        // performing assertions on its output:
        let tokenArrays = try await(tokenPublisher)
        XCTAssertEqual(tokenArrays.count, 2)
        XCTAssertEqual(tokenArrays.first, [.text("Hello "), .username("john")])
        XCTAssertEqual(tokenArrays.last, [.text("Check out "), .hashtag("swift")])
    }
}

然而,上述测试目前是失败的,因为我们的tokenArrays 输出值中的第一个元素将是一个空数组。这是因为所有的@Published 标记的属性在订阅者被附加到它们时,总是发射它们的当前值,这意味着上面的tokenPublisher 将总是收到我们的tokens 属性的初始值(一个空数组)作为其第一个输入值。

值得庆幸的是,这个问题很容易解决,因为Combine有一个专门的操作符,可以让我们忽略特定发布者将产生的第一个输出元素--dropFirst 。因此,如果我们简单地在本地发布器的管道中插入该操作符作为第一步,那么我们的测试将成功通过:

class EditorViewModelTests: XCTestCase {
    func testTokenizingMultipleStrings() throws {
        let viewModel = EditorViewModel()
        let tokenPublisher = viewModel.$tokens
            .dropFirst()
            .collect(2)
            .first()

        viewModel.string = "Hello @john"
        viewModel.string = "Check out #swift"

        let tokenArrays = try await(tokenPublisher)
        XCTAssertEqual(tokenArrays.count, 2)
        XCTAssertEqual(tokenArrays.first, [.text("Hello "), .username("john")])
        XCTAssertEqual(tokenArrays.last, [.text("Check out "), .hashtag("swift")])
    }
}

然而,我们又一次遇到了这样的问题:如果我们要为每一个我们想要测试的发布的属性编写上述的操作符,那将是相当乏味的(而且容易出错)--所以让我们编写另一个工具,为我们做这些工作。这一次,我们将直接扩展Published 类型的嵌套Publisher ,因为我们只想在测试发布的属性时执行这个特殊的操作。

extension Published.Publisher {
    func collectNext(_ count: Int) -> AnyPublisher<[Output], Never> {
        self.dropFirst()
            .collect(count)
            .first()
            .eraseToAnyPublisher()
    }
}

有了上述内容,我们现在就可以轻松地收集N ,一个给定的发布的属性将在我们任何单元测试中发出的下一个值。

class EditorViewModelTests: XCTestCase {
    func testTokenizingMultipleStrings() throws {
        let viewModel = EditorViewModel()
        let tokenPublisher = viewModel.$tokens.collectNext(2)
        ...
    }
}

结论

尽管Combine的流驱动设计与我们可能习惯的其他类型的异步代码有很大的不同(比如使用完成处理程序闭包,或者像Futures和Promises这样的东西),我们仍然可以在验证我们基于Combine的逻辑时使用XCTest的异步测试工具。希望这篇文章能给你一些关于如何做到这一点的想法,以及我们如何通过引入一些轻量级的工具使编写这样的测试更简单。