How to write unit test for this function loadDemos
?
Here is the code:
class BenefitViewModel: ObservableObject {
…
func loadDemos() {
let testMode = ProcessInfo.processInfo.arguments.contains("testMode")
if testMode {
demos = DummyData().decodeDemos()
} else {
getDemos().sink { result in
switch result {
case .failure(let error):
print(error.localizedDescription)
case .finished:
break
}
} receiveValue: { response in
self.demos = response
print("Demos: \(response.count)")
}
.store(in: &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.