Getting started with testing your Combine code
Published on: May 11, 2020A question that often comes up when folks get into learning Combine is "how do I test code that uses Combine?". In this week's post, I will briefly explain the basics of testing Combine code. I will assume that you already know the basics of testing and Combine. If you're just getting started with both topics or would like a refresher I can recommend that you take a look at the following resources:
- My series of posts on testing
- My series of posts on Combine
- My Practical Combine book if you want to learn a lot more about Combine, and how to test Combine code.
By the end of this post you will understand the essentials of testing code that uses Combine.
Effectively writing tests for code that uses Combine
If you know how you can test asynchronous code, you know how to test Combine code. That's the short answer I like to give to people who ask me about testing their Combine code. The essence of testing asynchronous code is that you use XCTest
's XCTestExpectation
objects to make sure your test sits idle until your asynchronous code has produced a result. You can then assert than this result is the result that you expected and your test will have succeeded (or failed). This same idea applies to Combine code.
Before you write your tests you will need to apply the same abstractions that you would apply for other code, and you'll want to make sure that you can mock or stub any dependencies from the object you're testing. The major difference between testing code that uses Combine, and code that runs asynchronously is that your asynchronous code will typically produce a single result. Combine code can publish a stream of values which means you'll want to make sure that your publisher has published all of the values you expected.
Let's look at a simple model and view model that we can write a test for:
public class Car {
@Published public var kwhInBattery = 50.0
let kwhPerKilometer = 0.14
}
public struct CarViewModel {
var car: Car
public lazy var batterySubject: AnyPublisher<String?, Never> = {
return car.$kwhInBattery.map({ newCharge in
return "The car now has \(newCharge)kwh in its battery"
}).eraseToAnyPublisher()
}()
public mutating func drive(kilometers: Double) {
let kwhNeeded = kilometers * car.kwhPerKilometer
assert(kwhNeeded <= car.kwhInBattery, "Can't make trip, not enough charge in battery")
car.kwhInBattery -= kwhNeeded
}
}
The CarViewModel
in this example provides batterySubject
publisher and we'll test that it publishes a new value every time we call drive(kilometers:)
. The drive(kilometers:)
method is used to update the Car
's kwhInBattery
which means that $kwhInBattery
should emit a new value, which is then transformed and the transformed value is them emitted by batterySubject
.
Keep in mind that we have no business testing whether the map
that's used for the batterySubject
works properly. We shouldn't care what Combine operators are used under the hood, and we also should test that Combine's built-in operators work properly. That's Apple's job. Your job is to test the code you write and own.
To test CarViewModel
you could write the following unit test:
class CarViewModelTest: XCTestCase {
var car: Car!
var carViewModel: CarViewModel!
var cancellables: Set<AnyCancellable>!
override func setUp() {
car = Car()
carViewModel = CarViewModel(car: car)
cancellables = []
}
func testCarViewModelEmitsCorrectStrings() {
// determine what kwhInBattery would be after driving 10km
let newValue: Double = car.kwhInBattery - car.kwhPerKilometer * 10
// configure an array of expected output
var expectedValues = [car.kwhInBattery, newValue].map { doubleValue in
return "The car now has \(doubleValue)kwh in its battery"
}
// expectation to be fulfilled when we've received all expected values
let receivedAllValues = expectation(description: "all values received")
// subscribe to the batterySubject to run the test
carViewModel.batterySubject.sink(receiveValue: { value in
guard let expectedValue = expectedValues.first else {
XCTFail("Received more values than expected.")
return
}
guard expectedValue == value else {
XCTFail("Expected received value \(value) to match first expected value \(expectedValue)")
return
}
// remove the first value from the expected values because we no longer need it
expectedValues = Array(expectedValues.dropFirst())
if expectedValues.isEmpty {
// the test is completed when we've received all expected values
receivedAllValues.fulfill()
}
}).store(in: &cancellables)
// call drive to trigger a second value
carViewModel.drive(kilometers: 10)
// wait for receivedAllValues to be fulfilled
waitForExpectations(timeout: 1, handler: nil)
}
}
I have added several comments to the unit test code. I am testing my Combine code by comparing an array of expected values to the values that are emitted by batterySubject
. The first element in the expectedValues
array is always the element that I expect to receive from batterySubject
. After receiving a value, I use dropFirst()
to create a new expectedValues
array with all elements from the old expectedValues
array, except for the first value. I drop the first value because I just received that value.
In essence, this code isn't too different from any other asynchronous test code you may have written. The most important difference is in the fact that you now need to compare an array of expected values to the values that were actually emitted by your publisher.
Note that I am not using any Combine operators other than sink
because I need to subscribe to the publisher I want to test. Using a Combine operator in your test is often a sign that you're not really testing the logic that you should be testing.
If you consider this test to be a little bit wordy, or if you think writing your tests like I just showed you is a bit repetitive when you're testing a whole bunch of publishers, I agree with you. In my Practical Combine book I demonstrate how you can improve a test like this using a simple extension on Publisher
that allows you to compare a publisher's output to an array of expected values using just a few lines of code. You will also learn how you can test a publisher that needs to explicitly complete or throw an error before you consider it to be completed, including a nifty helper that allows you to write your tests in a way that almost makes them look like your logic isn't asynchronously tested at all.
In summary
In this week's post, I gave you some insight into testing code that uses Combine. You learned how you can set up an array of expected values, compare them with the values that are emitted by a publisher, and ultimately complete your test when all expected values have been emitted. If you want to learn more about Combine, testing or both make sure to take a look at the testing and Combine categories on this blog. And if you want to gain some deeper knowledge about testing your Combine code make sure to pick up my book on Combine. It includes an entire chapter dedicated to testing with several examples and two convenient helpers to clean up your test code.