AsyncExpectations

0.2.0

bangerang/swift-async-expectations

What's New

0.2.0

2023-08-16T15:33:53Z

What's Changed

  • Use async let for expectations with two expressions by @KaiOelfke in #5
  • Sendable expressions instead of mainactor by @KaiOelfke in #6

New Contributors

Full Changelog: 0.1.0...0.2.0

AsyncExpectations

AsyncExpectations is a testing library for Swift that brings structured testing to unstructured concurrency.

Motivation

Writing unit tests for structured concurrency is straightforward and simple.

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

But for unstructured concurrency this can be more challenging. For example, when writing integration tests to evaluate how a view model interacts with the 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
                    }
                }
        })
    }
}

To write a test that passes using async await we need to introduce a delay.

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

However, relying on arbitrary delays can make the test unreliable. Additionally, the test will run slower since it requires a wait time of at least 0.2 seconds.

Another more reliable approach would be to use Combine with an 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)
}

But this approach not only necessitates the use of more boilerplate code, but it also results in less readable code due to its non-linear nature. Furthermore, it requires us to maintain a reference to a cancellable object and perform necessary cleanup after each test.

With AsyncExpectations, we can remove the delay and the boilerplate, resulting in much simpler code.

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

Expectations

AsyncExpectations provides a number of different expectations.

expectValue

The expectValue can be used to await a value, useful for testing Combine publishers or unwrapping optionals.

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

Waits for a specified time for an expectation to be true.

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

Full list of expectations includes.

  • expect

  • expectFalse

  • expectEqual

  • expectNotEqual

  • expectNotNil

  • expectNil

  • expectThrowsError

  • expectNoThrow

  • expectLessThan

  • expectLessThanOrEqual

  • expectGreaterThan

  • expectGreaterThanOrEqual

  • expectValue

If needed, we can easlily extend this list with additional expectations.

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

Cancellation

Unlike XCTest, AsyncExpectations will cancel the current task in the event of a test timeout. Failure to cancel the task could result in the current test run freezing if the task never completes.

Installation

You can add AsyncExpectations as a package dependency https://github.com/bangerang/swift-async-expectations.

Description

  • Swift Tools 5.7.0
View More Packages from this Author

Dependencies

Last updated: Sun Mar 17 2024 20:48:35 GMT-0900 (Hawaii-Aleutian Daylight Time)