Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Unit Testing Combine

I'm having difficulties testing Combine. I'm following:

  • https://www.swiftbysundell.com/articles/unit-testing-combine-based-swift-code/

Which tests:

final class ViewModel {
    @Published private(set) var tokens = [String]()
    @Published var string = ""
 
    private let tokenizer = Tokenizer()

    init () {
        $string
            .flatMap(tokenizer.tokenize)
            .replaceError(with: [])
            .assign(to: &$tokens)
    }
}

struct Tokenizer {
    func tokenize(_ string: String) -> AnyPublisher<[String], Error> {
        let strs = string.components(separatedBy: " ")
        return Just(strs)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

with the following:

func testTokenizingMultipleStrings() throws {
    let viewModel = ViewModel()
    let tokenPublisher = viewModel.$tokens
        .dropFirst()
        .collect(2)
        .first()
    viewModel.string = "Hello @john"
    viewModel.string = "Check out #swift"
    let tokenArrays = try awaitPublisher(tokenPublisher)
    XCTAssertEqual(tokenArrays.count, 2)
    XCTAssertEqual(tokenArrays.first, ["Hello", "john"])
    XCTAssertEqual(tokenArrays.last, ["Check out", "swift"])
}

And the following helper function:

extension XCTestCase {
    func awaitPublisher<T: Publisher>(
        _ publisher: T,
        timeout: TimeInterval = 10,
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> T.Output {
        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)
            }
        )
        waitForExpectations(timeout: timeout)
        cancellable.cancel()
        let unwrappedResult = try XCTUnwrap(
            result,
            "Awaited publisher did not produce any output",
            file: file,
            line: line
        )
        return try unwrappedResult.get()
    }
}

Here receiveValue is never called so the test doesn't complete. How can I get this test to pass?

like image 914
WishIHadThreeGuns Avatar asked Jun 19 '26 13:06

WishIHadThreeGuns


1 Answers

I ran into the same issue and eventually realized that for the test to pass, we need to update the view-model between setting up the subscription and waiting for the expectation. Since both currently happen inside the awaitPublisher helper, I added a closure parameter to that function:

func awaitPublisher<T: Publisher>(
    _ publisher: T,
    timeout: TimeInterval = 10,
    file: StaticString = #file,
    line: UInt = #line,
    closure: () -> Void
) throws -> T.Output {
    ...
    let expectation = ...
    let cancellation = ...

    closure()

    waitForExpectations(timeout: timeout)
    ...
}

Note the exact position of the closure – it won’t work if it’s called too early or too late.

You can then call the helper in your test like so:

let tokenArrays = try awaitPublisher(publisher) {
    viewModel.string = "Hello @john"
    viewModel.string = "Check out #swift"
}
like image 110
Martin Avatar answered Jun 22 '26 11:06

Martin