Articles, podcasts and news about Swift development, by John Sundell.

Unit testing Combine-based Swift code

Published on 19 Feb 2021
Discover page available: Combine

Testing asynchronous code is often particularly tricky, and code written using Apple’s Combine framework is no exception. Since each XCTest-based unit test executes in a purely synchronous fashion, we have to find ways to tell the test runner to await the output of the various asynchronous calls that we’re looking to test — otherwise their output won’t be available until after our tests have already finished executing.

So, in this article, let’s take a look at how to do just that when it comes to code that’s based on Combine publishers, and also how we can augment Combine’s built-in API with a few utilities that’ll heavily improve its testing ergonomics.

Awaiting expectations

As an example, let’s say that we’ve been working on a Tokenizer that can be used to identify various tokens within a string (such as usernames, URLs, hashtags, and so on). Since we’re looking to tokenize strings of varying length and complexity, we’ve opted to make our Tokenizer perform its work on a background thread, and to then use Combine to asynchronously report what tokens that it found within a given String:

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

Now let’s say that we wanted to write a series of tests to verify that our tokenization logic works as intended, and since the above API is asynchronous, we’re going to have to do a little bit of work to ensure that those tests will execute predictably.

Like we took a look at in “Unit testing asynchronous Swift code”, XCTest’s expectations system enables us to tell the test runner to await the result of an asynchronous call by creating, awaiting and fulfilling an XCTestExpectation instance. So let’s use that system, along with Combine’s sink operator, to write our first test like this:

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

While the above test works perfectly fine, it’s arguably not that pleasant to read, and it involves a lot of boilerplate code that we’ll likely have to repeat within every single Tokenizer test that we’ll write.

A dedicated method for awaiting the output of a publisher

So let’s address those issues by introducing a variant of the await method from “Async/await in Swift unit tests”, which essentially moves all of the setup code required to observe and await the result of a publisher into a dedicated XCTestCase method:

extension XCTestCase {
    func awaitPublisher<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()
    }
}

To learn more about the XCTUnwrap function used above, check out “Avoiding force unwrapping in Swift unit tests”.

With the above in place, we’ll now be able to drastically simplify our original test, which can now be implemented using just a few lines of code:

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

Much better! However, one thing that we have to keep in mind is that our new awaitPublisher method assumes that each publisher passed into it will eventually complete, and it also only captures the latest value that the publisher emitted. So while it’s incredibly useful for publishers that adhere to the classic request/response model, we might need a slightly different set of tools to test publishers that continuously emit new values.

Testing published properties

A very common example of publishers that never complete and that keep emitting new values are published properties. For example, let’s say that we’ve now wrapped our Tokenizer into an ObservableObject that can be used within our UI code, which might look something like this:

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

Note how we currently replace all errors with an empty array of tokens, in order to be able to assign our publisher directly to our tokens property. Alternatively, we could’ve used an operator like catch to perform more custom error handling, or to propagate any encountered error to the UI.

Now let’s say that we’d like to write a test that ensures that our new EditorViewModel keeps publishing new [Token] values as its string property is modified, which means that we’re no longer just interested in a single output value, but rather multiple ones.

An initial idea on how to handle that, while still being able to use our awaitPublisher method from before, might be to use Combine’s collect operator to emit a single array containing all of the values that our view model’s tokens property will publish, and to then perform a series of verifications on that array — like this:

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 awaitPublisher(tokenPublisher)
        XCTAssertEqual(tokenArrays.count, 2)
        XCTAssertEqual(tokenArrays.first, [.text("Hello "), .username("john")])
        XCTAssertEqual(tokenArrays.last, [.text("Check out "), .hashtag("swift")])
    }
}

However, the above test currently fails, since the first element within our tokenArrays output value will be an empty array. That’s because all @Published-marked properties always emit their current value when a subscriber is attached to them, meaning that the above tokenPublisher will always receive our tokens property’s initial value (an empty array) as its first input value.

Thankfully, that problem is quite easily fixed, since Combine ships with a dedicated operator that lets us ignore the first output element that a given publisher will produce — dropFirst. So if we simply insert that operator as the first step within our local publisher’s pipeline, then our test will now successfully pass:

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 awaitPublisher(tokenPublisher)
        XCTAssertEqual(tokenArrays.count, 2)
        XCTAssertEqual(tokenArrays.first, [.text("Hello "), .username("john")])
        XCTAssertEqual(tokenArrays.last, [.text("Check out "), .hashtag("swift")])
    }
}

However, we’re once again at a point where it would be quite tedious (and error prone) to have to write the above set of operators for each published property that we’re looking to write tests against — so let’s write another utility that’ll do that work for us. This time, we’ll extend the Published type’s nested Publisher directly, since we’re only looking to perform this particular operation when testing published properties:

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

With the above in place, we’ll now be able to easily collect the N number of next values that a given published property will emit within any of our unit tests:

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

Conclusion

Even though Combine’s stream-driven design might differ quite a bit from other kinds of asynchronous code that we might be used to (such as using completion handler closures, or something like Futures and Promises), we can still use XCTest’s asynchronous testing tools when verifying our Combine-based logic as well. Hopefully this article has given you a few ideas on how to do just that, and how we can make writing such tests much simpler by introducing a few lightweight utilities.

If you’ve got questions, comments, or feedback, then feel free to reach out via either Twitter or email. Thanks for reading, and happy testing!