AsyncExpectations

AsyncExpectations 是一个 Swift 测试库,它将结构化测试引入到非结构化并发中。

动机

为结构化并发编写单元测试是直接且简单的。

func testSearch() async throws {
    let text = "Hello, is it me your looking for?"
    let searchService = SearchService()
  
    let searchResult = try await searchService.search(for: "Hello", in: text)
  
    XCTAssertEqual(searchResult.count, 1)
    XCTAssertEqual(text[searchResult[0]], "Hello")
}

但对于非结构化并发,这可能更具挑战性。例如,在编写集成测试以评估视图模型如何与 SearchService 交互时。

class ViewModel: ObservableObject {
    @Published var text = "Hello, is it me your looking for?"
    @Published var searchText = ""
    @Published var searchResult: [Range<String.Index>] = []
    // ...
    init(searchService: SearchService) {
        self.searchService = searchService
        self.cancellable = $searchText
            .filter { !$0.isEmpty }
            .sink(receiveValue: { [weak self] searchText in
                guard let self else {
                    return
                }
                self.searchTask?.cancel()
                self.searchTask = Task {
                    let result = try await searchService.search(for: searchText, 
                                                                in: self.text)
                    await MainActor.run {
                        self.searchResult = result
                    }
                }
        })
    }
}

为了编写一个使用 async await 通过的测试,我们需要引入一个延迟。

func testSearch() async throws {
    let text = "Hello, is it me your looking for?"
    let viewModel = ViewModel(text: text, searchService: .init())

    viewModel.searchText = "Hello"
    try await Task.sleep(until: .now + .seconds(0.2), clock: .continuous)

    XCTAssertEqual(viewModel.searchResult.count, 1)
    XCTAssertEqual(text[viewModel.searchResult[0]], "Hello")
}

然而,依赖于任意延迟会使测试变得不可靠。 此外,测试运行速度会变慢,因为它需要至少 0.2 秒的等待时间。

另一种更可靠的方法是使用 Combine 配合 XCTestExpectation

private var cancellables: Set<AnyCancellable>!

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

func testSearch() {
    let expectation = expectation(description: #function)
    let text = "Hello, is it me your looking for?"
    let viewModel = ViewModel(text: text, searchService: .init())
    viewModel.$searchResult
        .dropFirst()
        .sink { searchResult in
            XCTAssertEqual(searchResult.count, 1)
            XCTAssertEqual(text[searchResult[0]], "Hello")
            expectation.fulfill()
        }
        .store(in: &cancellables)

    viewModel.searchText = "Hello"

    wait(for: [expectation], timeout: 1)
}

但是,这种方法不仅需要使用更多的样板代码,而且由于其非线性特性,还会导致代码的可读性降低。 此外,它要求我们维护对可取消对象的引用并在每次测试后执行必要的清理。

使用 AsyncExpectations,我们可以消除延迟和样板代码,从而得到更简单的代码。

func testSearch() async throws {
    let text = "Hello, is it me your looking for?"
    let viewModel = ViewModel(text: text, searchService: .init())
  
    viewModel.searchText = "Hello"
  
    try await expectEqual(viewModel.searchResult.count, 1)
    try await expectEqual(text[viewModel.searchResult[0]], "Hello")
}

期望

AsyncExpectations 提供了许多不同的期望。

expectValue

expectValue 可用于等待一个值,这对于测试 Combine 发布者或解包可选值很有用。

class Queue {
    let messages: AnyPublisher<String, Never>
    func sendMessage(_ string: String)
}

func testQueue() async throws {
    let queue = Queue()
  
    queue.sendMessage("Foo")
  
    let value = try await expectValue(queue.messages)
    XCTAssertEqual(value, "Foo")
}

expect

在指定的时间内等待期望为真。

class SomeMock: SomeProtocol {
    var callback: () -> Void
    var didCallFoo = false
    func foo() {
        didCallFoo = true
    }
}

func testMock() async throws {
    let mock = SomeMock()
    var fulfilled = false
    mock.callback = {
        fulfilled = true
    }
    let model = SomeModel(mock)
  
    model.thisWillEventuallyCallTheMock()
    
    try await expect(model.didCallFoo)
    try await expect(fulfilled)
}

完整的期望列表包括:

如果需要,我们可以轻松地使用其他期望来扩展此列表。

@MainActor func expectNumbers(_ expression: @MainActor @escaping () async throws -> String,
                              file: StaticString = #file,
                              line: UInt = #line,
                              timeout: TimeInterval = 1) async throws {
    let allNumbers = { try await expression().allSatisfy { $0.isNumber } }
    if try await !evaluate(allNumbers, timeout: timeout) {
        let value = try await expression()
        XCTFail(#""\#(value)" is not all numbers"#, file: file, line: line)
    }
}

取消

与 XCTest 不同,AsyncExpectations 将在测试超时时取消当前任务。 如果未能取消任务,可能会导致当前测试运行冻结,因为该任务永远不会完成。

安装

您可以将 AsyncExpectations 添加为包依赖项 https://github.com/bangerang/swift-async-expectations