XCTestExpectation subclasses that simplify testing Combine Publishers and help to improve the readability of unit tests.
Writing tests for Combine Publishers (or @Published properties) using XCTestExpectation
usually involves some boilerplate code such as:
let expectation = XCTestExpectation(description: "Wait for the publisher to emit the expected value")
viewModel.$output.sink { _ in
} receiveValue: { value in
if value == expectedValue {
expectation.fulfill()
}
}
.store(in: &cancellables)
wait(for: [expectation], timeout: 1)
We can try using a XCTKeyPathExpectation
but it requires that the observed object inherits from NSObject
and also marking the properties we want to observe with both the @objc
attribute and the dynamic
modifier to make them KVO-compliant:
class ViewModel: NSObject {
@objc dynamic var isLoading = false
}
let expectation = XCTKeyPathExpectation(keyPath: \ViewModel.isLoading, observedObject: viewModel, expectedValue: true)
Another tempting approach would be using XCTNSPredicateExpectation
like:
let expectation = XCTNSPredicateExpectation(predicate: NSPredicate { _,_ in
viewModel.output == expectedValue
}, object: viewModel)
While this looks nice and compact, the problem with XCTNSPredicateExpectation
is that is quite slow and best suited for UI tests. This is because it uses some kind of polling mechanism that adds a significant delay of 1 second minimum before the expectation is fulfilled. So it's better not to follow this path in unit tests.
The PublisherExpectations is a set of 3 XCTestExpectation that allows declaring expectations for publisher events in a clear and concise manner. They inherit from XCTestExpectation so they can be used in the wait(for: [expectations])
call as with any other expectation.
PublisherValueExpectation
: An expectation that is fulfilled when a publisher emits a value that matches a certain condition.PublisherFinishedExpectation
: An expectation that is fulfilled when a publisher completes successfully.PublisherFailureExpectation
: An expectation that is fulfilled when a publisher completes with a failure.
- Wait for an expected value:
let publisherExpectation = PublisherValueExpectation(stringPublisher, expectedValue: "Got it")
- Wait for a value that matches a condition:
let publisherExpectation = PublisherValueExpectation(arrayPublisher) { $0.contains(value) }
- Works with
@Published
property wrappers as well:
let publisherExpectation = PublisherValueExpectation(viewModel.$isLoaded, expectedValue: true)
let publisherExpectation = PublisherValueExpectation(viewModel.$keywords) { $0.contains("Cool") }
- Waiting for the publisher to finish:
let publisherExpectation = PublisherFinishedExpectation(publisher)
- Waiting for the publisher to finish after emitting an expected value:
let publisherExpectation = PublisherFinishedExpectation(publisher, expectedValue: 2)
- Waiting to finish after emitting a value that matches a certain condition:
let publisherExpectation = PublisherFinishedExpectation(arrayPublisher) { array in
array.allSatisfy { $0 > 5 }
}
- Expecting a failure:
let publisherExpectation = PublisherFailureExpectation(publisher)
- Expecting a failure with an error that matches a condition:
let publisherExpectation = PublisherFailureExpectation(publisher) { error in
guard case .apiError(let code) = error, code = 500 else { return false }
return true
}
- If the expected error conforms to Equatable:
let publisherExpectation = PublisherFailureExpectation(publisher, expectedError: ApiError(code: 100))
- From the File menu, select Add Packages...
- Enter package repository URL: https://github.com/yannxou/PublisherExpectations
- Confirm the version and let Xcode resolve the package
Thanks to Combine we can adapt the publisher to check many things while keeping the test readability:
- Expect many values to be emitted:
let publisherExpectation = PublisherValueExpectation(publisher.collect(3), expectedValue: [1,2,3])
- Expect the first/last emitted value:
let publisherExpectation = PublisherValueExpectation(publisher.first(), expectedValue: 1)
let publisherExpectation = PublisherValueExpectation(publisher.last(), expectedValue: 5)
- Expect the second emitted value:
let publisherExpectation = PublisherValueExpectation(publisher.dropFirst().first(), expectedValue: 2)