How to Unittest Combine Cancellables

How To UnitTest Combine Cancellables?

At the risk of being a little annoying, I'm going to answer the more general version of your question: how can you unit test a Combine pipeline?

Let's step back and start with some general principles about unit testing:

  • Don't test Apple's code. You already know what it does. Test your code.

  • Don't test the network (except in a rare test where you just want to make sure the network is up). Substitute your own class that behaves like the network.

  • Asynchronous code needs asynchronous testing.

I assume your getDemos does some asynchronous networking. So without loss of generality I can illustrate with a different pipeline. Let's use a simple Combine pipeline that fetches an image URL from the network and stores it in a UIImage instance property (this is intended to be quite parallel to what you are doing with your pipeline response and self.demos). Here's a naive implementation (assume that I have some mechanism for calling fetchImage):

class ViewController: UIViewController {
var image : UIImage?
var storage = Set<AnyCancellable>()
func fetchImage() {
let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
self.getImageNaive(url:url)
}
func getImageNaive(url:URL) {
URLSession.shared.dataTaskPublisher(for: url)
.compactMap { UIImage(data:$0.data) }
.receive(on: DispatchQueue.main)
.sink { completion in
print(completion)
} receiveValue: { [weak self] image in
print(image)
self?.image = image
}
.store(in: &self.storage)
}
}

All very nice, and it works fine, but it isn't testable. The reason is that if we simply call getImageNaive in our test, we will be testing the network, which is unnecessary and wrong.

So let's make this testable. How? Well, in this simple example, we just need to break off the asynchronous publisher from the rest of the pipeline, so that the test can substitute its own publisher that doesn't do any networking. So, for example (again, assume I have some mechanism for calling fetchImage):

class ViewController: UIViewController {
// Output is (data: Data, response: URLResponse)
// Failure is URLError
typealias DTP = AnyPublisher <
URLSession.DataTaskPublisher.Output,
URLSession.DataTaskPublisher.Failure
>
var image : UIImage?
var storage = Set<AnyCancellable>()
func fetchImage() {
let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
self.getImage(url:url)
}
func getImage(url:URL) {
let pub = self.dataTaskPublisher(for: url)
self.createPipelineFromPublisher(pub: pub)
}
func dataTaskPublisher(for url: URL) -> DTP {
URLSession.shared.dataTaskPublisher(for: url).eraseToAnyPublisher()
}
func createPipelineFromPublisher(pub: DTP) {
pub
.compactMap { UIImage(data:$0.data) }
.receive(on: DispatchQueue.main)
.sink { completion in
print(completion)
} receiveValue: { [weak self] image in
print(image)
self?.image = image
}
.store(in: &self.storage)
}
}

You see the difference? It's almost the same, but the pipeline itself is now distinct from the publisher. Our method createPipelineFromPublisher takes as its parameter any publisher of the correct type. This means that we have abstracted out the use of URLSession.shared.dataTaskPublisher, and can substitute our own publisher. In other words, createPipelineFromPublisher is testable!

Okay, let's write the test. My test case contains a method that generates a "mock" publisher that simply publishes some Data wrapped up in the same publisher type as a data task publisher:

func dataTaskPublisherMock(data: Data) -> ViewController.DTP {
let fakeResult = (data, URLResponse())
let j = Just<URLSession.DataTaskPublisher.Output>(fakeResult)
.setFailureType(to: URLSession.DataTaskPublisher.Failure.self)
return j.eraseToAnyPublisher()
}

My test bundle (which is called CombineTestingTests) also has an asset catalog containing a UIImage called mannyTesting. So all I have to do is call ViewController's createPipelineFromPublisher with the data from that UIImage, and check that the ViewController's image property is now that same image, right?

func testImagePipeline() throws {
let vc = ViewController()
let mannyTesting = UIImage(named: "mannyTesting", in: Bundle(for: CombineTestingTests.self), compatibleWith: nil)!
let data = mannyTesting.pngData()!
let pub = dataTaskPublisherMock(data: data)
vc.createPipelineFromPublisher(pub: pub)
let image = try XCTUnwrap(vc.image, "The image is nil")
XCTAssertEqual(data, image.pngData()!, "The image is the wrong image")
}

Wrong! The test fails; vc.image is nil. What went wrong? The answer is that Combine pipelines, even a pipeline that starts with a Just, are asynchronous. Asynchronous pipelines require asynchronous testing. My test needs to wait until vc.image is not nil. One way to do that is with a predicate that watches for vc.image to no longer be nil:

func testImagePipeline() throws {
let vc = ViewController()
let mannyTesting = UIImage(named: "mannyTesting", in: Bundle(for: CombineTestingTests.self), compatibleWith: nil)!
let data = mannyTesting.pngData()!
let pub = dataTaskPublisherMock(data: data)
vc.createPipelineFromPublisher(pub: pub)
let pred = NSPredicate { vc, _ in (vc as? ViewController)?.image != nil }
let expectation = XCTNSPredicateExpectation(predicate: pred, object: vc)
self.wait(for: [expectation], timeout: 10)
let image = try XCTUnwrap(vc.image, "The image is nil")
XCTAssertEqual(data, image.pngData()!, "The image is the wrong image")
}

And the test passes! Do you see the point? The system-under-test here is exactly the right thing, namely, the mechanism that forms a pipeline that receives the output that a data task publisher would emit and sets an instance property of our view controller. We have tested our code and only our code. And we have demonstrated that our pipeline works correctly.

SwiftUI Combine - How to test waiting for a publisher's async result

You need to wait asynchronously via expectation and check result via publisher.

Here is possible approach. Tested with Xcode 13.2 / iOS 15.2

    private var cancelables = Set<AnyCancellable>()
func testContentViewModel() {
// Given
let viewModel = ContentViewModel()

let expect = expectation(description: "results")
viewModel.$results
.dropFirst() // << skip initial value !!
.sink {
XCTAssertEqual($0, ["01", "02", "03"])
expect.fulfill()
}
.store(in: &cancelables)

viewModel.searchText = "123"
wait(for: [expect], timeout: 3)
}

demo

Unexpected Combine Publisher Behavior

The problem is caused by the fact that numberOfPayments and monthlyRate publishers are co-dependent, and both follow the mortgageTerm publisher. Thus, when $mortgageTerm emits an event, you end up with two other independent events emitted by the follower publishers, and this breaks your flow.

This also indicates you're using too many publishers for things that can be easily solved with computed properties, but I assume you want to experiment with publishers, so, let't give it a go with this.

One solution is to use only one publisher for the two problematic pieces of information, a publisher that emits tuples, and which makes use of some helper functions that calculate the data to emit. This way, the two pieces of information that should be emitted at the same time are, well, emitted at the same time :).

func annualRate(mortgageTerm: MortgageTerm) -> Double {
switch mortgageTerm {
case .tenYear:
return rates.tenYearFix
case .fifteenYear:
return rates.fifteenYearFix
case .twentyYear:
return rates.twentyYearFix
case .thirtyYear:
return rates.thirtyYearFix
}
}

func monthlyRate(mortgageTerm: MortgageTerm) -> Double {
annualRate(mortgageTerm: mortgageTerm) / 12
}

func numberOfPayments(mortgageTerm: MortgageTerm) -> Double {
Double(mortgageTerm.rawValue * 12)
}

lazy var monthlyDetails: AnyPublisher<(monthlyRate: Double, numberOfPayments: Double), Never> = {
$mortgageTerm
.map { (monthlyRate: self.monthlyRate(mortgageTerm: $0), numberOfPayments: self.numberOfPayments(mortgageTerm: $0)) }
.eraseToAnyPublisher()
}()

With the above setup in place, you can use the combineLatest that you attempted first:

func monthlyPayment(financedAmount: Double, monthlyRate: Double, numberOfPayments: Double) -> Double {
let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1

return financedAmount * (numerator / denominator)
}

lazy var monthlyPayment: AnyPublisher<Double, Never> = {
financedAmount.combineLatest(monthlyDetails) { financedAmount, monthlyDetails in
let (monthlyRate, numberOfPayments) = monthlyDetails
return self.monthlyPayment(financedAmount: financedAmount,
monthlyRate: monthlyRate,
numberOfPayments: numberOfPayments)
}
.eraseToAnyPublisher()
}()

Functions are a powerful tool in Swift (and any other language), as clearly defined and specialized functions help with:

  • code structure
  • redability
  • unit testing

In your particular example, I'd go even one step further, and define this:

func monthlyPayment(principalAmount: Double, downPaymentAmount: Double, mortgageTerm: MortgageTerm) -> Double {
let financedAmount = principalAmount - downPaymentAmount
let monthlyRate = self.monthlyRate(mortgageTerm: mortgageTerm)
let numberOfPayments = self.numberOfPayments(mortgageTerm: mortgageTerm)
let numerator = monthlyRate * pow((1 + monthlyRate), Double(numberOfPayments))
let denominator = pow((1 + monthlyRate), Double(numberOfPayments)) - 1

return financedAmount * (numerator / denominator)
}

The above function clearly describes the problem domain of your screen, as its main feature is to compute a monthly payment based on three inputs. And with the function in place, you can resume the whole set of publishers, to only one:

lazy var monthlyPayment = $principalAmount
.combineLatest($downPaymentAmount, $mortgageTerm, self.monthlyPayment)

You get the same functionality, but with less amount of, and more testable, code.

Unit testing SwiftUI/Combine @Published boolean values

Let's say you store a flag in UserDefaults to know whether the user has completed onboarding:

extension UserDefaults {

@objc dynamic public var completedOnboarding: Bool {
bool(forKey: "completedOnboarding")
}
}

You have a ViewModel which tells your View whether to show onboarding or not and has a method to mark onboarding as completed:

class ViewModel: ObservableObject {

@Published private(set) var showOnboarding: Bool = true

private let userDefaults: UserDefaults

public init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
self.showOnboarding = !userDefaults.completedOnboarding
userDefaults
.publisher(for: \.completedOnboarding)
.map { !$0 }
.receive(on: RunLoop.main)
.assign(to: &$showOnboarding)
}

public func completedOnboarding() {
userDefaults.set(true, forKey: "completedOnboarding")
}
}

To test this class you have a XCTestCase:

class MyTestCase: XCTestCase {

private var userDefaults: UserDefaults!
private var cancellables = Set<AnyCancellable>()

override func setUpWithError() throws {
try super.setUpWithError()
userDefaults = try XCTUnwrap(UserDefaults(suiteName: #file))
userDefaults.removePersistentDomain(forName: #file)
}

// ...
}

Some of the test cases are synchronous for example you can easily test that showOnboarding depends on UserDefaults completedOnboarding property:

func test_whenCompletedOnboardingFalse_thenShowOnboardingTrue() {
userDefaults.set(false, forKey: "completedOnboarding")
let subject = ViewModel(userDefaults: userDefaults)
XCTAssert(subject.showOnboarding)
}

func test_whenCompletedOnboardingTrue_thenShowOnboardingFalse() {
userDefaults.set(true, forKey: "completedOnboarding")
let subject = ViewModel(userDefaults: userDefaults)
XCTAssertFalse(subject.showOnboarding)
}

Some test are asynchronous, which means you need to use XCTExpectations to wait for the @Published value to change:

func test_whenCompleteOnboardingCalled_thenShowOnboardingFalse() {
let subject = ViewModel(userDefaults: userDefaults)
// first define the expectation that showOnboarding will change to false (1)
let showOnboardingFalse = expectation(
description: "when completedOnboarding called then show onboarding is false")

// subscribe to showOnboarding publisher to know when the value changes (2)
subject
.$showOnboarding
.filter { !$0 }
.sink { _ in
// when false received fulfill the expectation (5)
showOnboardingFalse.fulfill()
}
.store(in: &cancellables)

// trigger the function that changes the value (3)
subject.completedOnboarding()
// tell the tests to wait for your expectation (4)
waitForExpectations(timeout: 0.1)
}

What is the reason to store subscription into a subscriptions set?

We usually want to store the subscription somewhere, to keep the subscription alive. We often want to keep several subscriptions alive until the enclosing object is destroyed, so it's convenient to store all the subscriptions in a single container.

However, the container does not have to be a Set! It can be (and usually should be) an Array.

Cancellable provides two store(in:) methods:

extension Cancellable {
public func store<C>(in collection: inout C) where C : RangeReplaceableCollection, C.Element == AnyCancellable

public func store(in set: inout Set<AnyCancellable>)
}

(Array conforms to RangeReplaceableCollection, but Set does not, so it needs its own method.)

You have found the one that stores into a Set. But do you need the behavior of a Set? The only reason to store your subscriptions in a Set is if you need to efficiently remove a single subscription from the set, and the set may be large. Otherwise, just use an Array, like this:

class MyObject {

private var tickets = [AnyCancellable]()

...
future
.sink(receiveCompletion: { print($0) }, receiveValue: { print($0) })
.store(in: &tickets)

How to convert myObject to AnyPublisher myObject, Never ?

A simple way to create a one-time publisher is to use Just

Just(someObject).eraseToAnyPublisher()


Related Topics



Leave a reply



Submit