Unit Testing Combine Publishers

Joan Duat
New Work Development
4 min readMay 19, 2023

When testing Combine-related code we must keep in mind that we’re most probably testing asynchronous code. And how do we expect to test asynchronous code in Xcode? Right, with expectations.

Expectations in Xcode allow us to define specific conditions that must be met before a test case can be considered successful or failed. By using expectations, we can accurately validate the behaviour of Combine publishers and other asynchronous code.

In this article, we will analyse the various kinds of expectations available in Xcode and the code required to set them up. Finally, we’ll propose a new method that not only reduces the boilerplate code but also improves the readability of our tests.

Photo by Alexandru Yicol on Unsplash

Let’s start by looking at what kind of expectations are available in Xcode and which ones are best suited for testing Publishers:

XCTestExpectation

This is the basic type of expectation since it allows us to manually control its outcome by calling fulfill(). It can be used to wait until a publisher emits some specific value and we can easily apply this to any @Published property wrapper for that matter:

let expectation = XCTestExpectation(description: "Wait for the publisher to emit the expected value")
viewModel.$keywords.sink { _ in
} receiveValue: { value in
if value.contains("Cool") {
expectation.fulfill()
}
}
.store(in: &cancellables)

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

XCTKVOExpectation

This is an expectation that fulfils on a specific key-value observing (KVO) condition. But this one is deprecated and Apple promotes using the next one:

XCTKeyPathExpectation

This expectation allows waiting for changes to a property specified by key path for a given object. It can be used like this:

let loadedExpectation = XCTKeyPathExpectation(keyPath: \ViewModel.isLoaded, observedObject: viewModel, expectedValue: true)

While this cannot be applied directly to publishers we may think it can be used at least for testing @Published properties but it has some constraints:

  • The tested object must inherit from NSObject
  • The object properties we want to observe must be declared as @objc dynamic
  • The property types that we want to observe must also be representable in Objective-C.

Even if we adapt our code with all those requirements we would still be able to test only a specific value, not the stream of values from the publisher or their completion.

This is far from ideal so let’s look at the next one:

XCTNSPredicateExpectation

This is an expectation that is fulfilled when an NSPredicate is satisfied. It can be used as:

let predicateExpectation = XCTNSPredicateExpectation(predicate: NSPredicate { _,_ in
viewModel.isLoaded
}, object: .none)

But when using this you probably found out that, even if the tested code is fast, the expectation must be awaited for at least more than a second:

wait(for: [predicateExpectation], timeout: 1) // this fails
wait(for: [predicateExpectation], timeout: 1.5) // this succeeds

It turns out that XCTNSPredicateExpectation is slow because it uses some kind of polling mechanism to check the predicate periodically thus makes it best suited for UI tests. So it’s better to avoid it in unit tests if we want them to run as fast as possible.

XCTNSNotificationExpectation

Finally, there’s this expectation that is fulfilled when an expected NSNotification is received. It can be useful in some scenarios but again, not so much for testing Combine-based code.

Level-set the expectations

After looking at the options above it’s clear that we’re just left with the XCTestExpectation as the only choice for testing Publishers.

But writing tests this way always involves some boilerplate code, starting from the the expectation creation, subscribing to the publisher, manually fulfilling the expectation and finally storing the cancellable.

If we care about test readability in such a way they are easy to understand then let’s try to simplify this process by introducing our own custom expectation:

PublisherValueExpectation

We can create an XCTestExpectation subclass that does this repetitive task for us. Here, the PublisherValueExpectation is an expectation that will be fulfilled automatically when the publisher emits a value that matches a given condition.

public final class PublisherValueExpectation<P: Publisher>: XCTestExpectation {
private var cancellable: AnyCancellable?

public init(
_ publisher: P,
condition: @escaping (P.Output) -> Bool)
{
super.init(description: "Publisher expected to emit a value that matches the condition.")
cancellable = publisher.sink { _ in
} receiveValue: { [weak self] value in
if condition(value) {
self?.fulfill()
}
}
}

With this we can just write:

let publisherExpectation = PublisherValueExpectation(viewModel.$keywords) { $0.contains("Cool") }

We can also add a convenience initializer to simply pass an expected value if that conforms to Equatable:

public convenience init(
_ publisher: P,
expectedValue: P.Output
) where P.Output: Equatable
{
let description = "Publisher expected to emit the value '\(expectedValue)'"
self.init(publisher, condition: { $0 == expectedValue }, description: description)
}

And this allows us writing an even more compact expectation that reads nicely:

let publisherExpectation = PublisherValueExpectation(viewModel.$isLoaded, expectedValue: true)

Thanks to Combine we can adapt the tested publisher to check many things. For instance:

  • Expect many values to be emitted by the publisher
let publisherExpectation = PublisherValueExpectation(publisher.collect(3), expectedValue: [1,2,3])
  • Expect the first or last value being emitted by the publisher
let publisherExpectation = PublisherValueExpectation(publisher.first(), expectedValue: 1)
let publisherExpectation = PublisherValueExpectation(publisher.last(), expectedValue: 5)

Keep up with the expectations

The full project can be found in GitHub. It also includes two more useful expectations:

  • PublisherFinishedExpectation: Wait for a publisher to complete successfully (optionally after emitting a certain value or condition)
let publisherExpectation = PublisherFinishedExpectation(publisher, expectedValue: 2)
  • PublisherFailureExpectation: Wait for a publisher to complete with a failure (optionally with an expected error)
let publisherExpectation = PublisherFailureExpectation(publisher, expectedError: ApiError(code: 100))

Conclusion

There are many alternatives to do assertions on publishers but this is a way that is familiar to anyone that already uses test expectations in Xcode and can be easily adopted to existing tests.

Thanks for reading.

--

--