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.

Leave a Comment