In this episode, we finish the unit test we started in the previous episode. Even though the implementation of the addLocation(with:) method of the AddLocationViewModel class isn't overly complex, writing a unit test for it is quite the challenge. Let's continue where we left off.
Populating the Locations Property
Before we invoke the addLocation(with:) method of the AddLocationViewModel class, we need to make sure the locations property contains the location whose identifier we pass to the addLocation(with:) method. We can trigger a geocoding request by setting the query property of the view model.
Because we are in control of the geocoding service, the value we assign to the query property isn't important. We define which locations the geocoding service returns to the view model.
// MARK: - Tests for Add Location
@MainActor
func testAddLocation() async throws {
let locationToAdd = Location.mock
let store = MockStore()
let geocodingService = MockGeocodingClient(
result: .success([locationToAdd])
)
let viewModel = AddLocationViewModel(
store: store,
geocodingService:geocodingService
)
XCTAssertTrue(store.locations.isEmpty)
let expectation = expectation(description: "Validate Locations")
store.locationsPublisher
.filter { locations in
locations.contains { $0.id == locationToAdd.id }
}
.sink { _ in
expectation.fulfill()
}.store(in: &subscriptions)
viewModel.query = "Brussels"
await fulfillment(of: [expectation], timeout: 10.0)
}
Remember that the view model uses the throttle operator to limit the number of geocoding requests it sends. That is another hurdle we need to deal with. It means we can only invoke the addLocation(with:) method after the locations property is updated with the result of the geocoding request. We do that by subscribing to the locations publisher of the view model using the sink(receiveValue:) method.
viewModel.$locations
.sink { _ in
}
We are not interested in the array of locations the publisher emits because we control the array of locations the geocoding service returns to the view model. In the value handler we pass to the sink(receiveValue:) method, we invoke the view model's addLocation(with:) method, passing in the identifier of the mock location.
viewModel.$locations
.sink { _ in
viewModel.addLocation(with: locationToAdd.id)
}
We add the AnyCancellable instance the sink(receiveValue:) method returns to the subscriptions property we declared in the previous episode.
viewModel.$locations
.sink { _ in
viewModel.addLocation(with: locationToAdd.id)
}.store(in: &subscriptions)
Click the diamond on the left of the unit test to execute it. The unit test fails even though it seems we carefully sidestepped the caveats we uncovered along the way. What did we miss?
Debugging the Published Property Wrapper
The failure is due to the somewhat odd behavior of the Published property wrapper. This is something that trips up many developers. Let me explain what is happening. The moment the view model updates its locations property, the locations publisher emits the new value of the locations property, the array of locations returned by the geocoding service. The unit test subscribes to the locations publisher to time the invocation of the addLocation(with:) method. That is how we designed the unit test.
The problem is that we overlooked a little known aspect of the Published property wrapper. The locations publisher emits the new value of the locations property before the value is assigned to the locations property. Make sure you understand what that means. It is unclear why the Published property wrapper works this way, but it hasn't changed over the years so I assume it is intended behavior.
The unit test invokes the addLocation(with:) method the moment the locations publisher emits the array of locations. At that point in time, the locations property hasn't been updated with the new value and that is why the unit test fails. The mock location isn't included in the array of locations the view model manages.
We can work around this issue by invoking the addLocation(with:) method with a delay using Grand Central Dispatch. We asynchronously dispatch the method call to the main dispatch queue. Let's give the unit test another try.
// MARK: - Tests for Add Location
@MainActor
func testAddLocation() async throws {
let locationToAdd = Location.mock
let store = MockStore()
let geocodingService = MockGeocodingClient(
result: .success([locationToAdd])
)
let viewModel = AddLocationViewModel(
store: store,
geocodingService:geocodingService
)
XCTAssertTrue(store.locations.isEmpty)
let expectation = expectation(description: "Validate Locations")
store.locationsPublisher
.filter { locations in
locations.contains { $0.id == locationToAdd.id }
}
.sink { _ in
expectation.fulfill()
}.store(in: &subscriptions)
viewModel.$locations
.sink { _ in
DispatchQueue.main.async {
viewModel.addLocation(with: locationToAdd.id)
}
}.store(in: &subscriptions)
viewModel.query = "Brussels"
await fulfillment(of: [expectation], timeout: 10.0)
}
This simple change resolves the problem.
Writing a Unit Test for the Unhappy Path
We completed the unit test for the happy path, that is, the view model successfully adds a location to the store. Let's write a unit test for the unhappy path. This won't take long thanks to the groundwork we laid in this and the pervious episode.
To avoid code duplication, we make use of a helper method. Declare a private enum with name AddLocationResult. The enum defines two cases, success and failure.
import XCTest
import Combine
@testable import Thunderstorm
final class AddLocationViewModelTests: XCTestCase {
// MARK: - Types
private enum AddLocationResult {
// MARK: - Cases
case success
case failure
}
...
}
Adding a location fails if we pass the identifier of a location to the addLocation(with:) method that isn't included in the array of locations the view model manages. The changes we need to make are small. Declare the testAddLocation() method privately and rename it to runAddLocationTest(). The method accepts one argument of type AddLocationResult, the enum we declared a moment ago.
@MainActor
private func runAddLocationTest(result: AddLocationResult) async throws {
...
}
Change the name of the locationToAdd constant to locationToAddID. If the result parameter is equal to success, we assign the identifier of the mock location. If the result parameter is equal to failure, we assign an invalid identifier.
let locationToAddID = result == .success ? Location.mock.id : "abc"
Because the locationToAdd constant no longer exists, we pass the mock location to the initializer of the MockGeocodingClient struct.
let geocodingService = MockGeocodingClient(
result: .success([Location.mock])
)
In the closure we pass to the filter operator, we make use of the locationToAddID constant we declared a moment ago.
store.locationsPublisher
.filter { locations in
locations.contains { $0.id == locationToAddID }
}
.sink { _ in
expectation.fulfill()
}.store(in: &subscriptions)
Last but not least, we pass the value stored in the locationToAddID constant to the addLocation(with:) method.
viewModel.$locations
.sink { _ in
DispatchQueue.main.async {
viewModel.addLocation(with: locationToAddID)
}
}.store(in: &subscriptions)
There is one other important change we need to make. If we pass an invalid identifier to the addLocation(with:) method, the expectation won't be fulfilled. We take that into account by setting the isInverted property of the expectation to true. This means that the unit test fails if the expectation is fulfilled. The unit tests passes if the expectation isn't fulfilled.
let expectation = expectation(description: "Validate Locations")
expectation.isInverted = result == .failure
Add an asynchronous, throwing unit test with name testAddLocationSuccess(). In the body of the method, we invoke the helper method, passing in success as the value of the result parameter.
// MARK: - Tests for Add Location
func testAddLocationSuccess() async throws {
try await runAddLocationTest(result: .success)
}
@MainActor
private func runAddLocationTest(result: AddLocationResult) async throws {
...
}
Add another asynchronous, throwing unit test with name testAddLocationFailure(). In the body of the method, we invoke the helper method, passing in failure as the value of the result parameter.
// MARK: - Tests for Add Location
func testAddLocationSuccess() async throws {
try await runAddLocationTest(result: .success)
}
func testAddLocationFailure() async throws {
try await runAddLocationTest(result: .failure)
}
@MainActor
private func runAddLocationTest(result: AddLocationResult) async throws {
...
}
Run the test suite to make sure the unit tests pass.
Unit Testing Asynchronous Code
The past few episodes, we explored the complexities asynchronous code can introduce in the test suite of a project. There are several changes we can make to reduce that complexity. Let's take a look at a few of them.
The testAddLocationFailure() unit test takes a long time to run. Because the expectation isn't fulfilled, running the unit test takes as long as the timeout we pass to the fulfillment(of:timeout:) method. We can speed things up by reducing the timeout to two seconds or less.
await fulfillment(of: [expectation], timeout: 2.0)
Because the throttle operator that is used in the AddLocationViewModel class introduces a delay of one second, the timeout cannot be lower than one second.
private func setupBindings() {
$query
.throttle(for: 1.0, scheduler: RunLoop.main, latest: true)
.filter { !$0.isEmpty }
.sink { [weak self] addressString in
self?.geocodeAddressString(addressString)
}.store(in: &subscriptions)
...
}
We could work around this limitation by changing the behavior of the view model when it is under test, but that isn't something I recommend. Taking that approach may lead to more instead of less complexity. The codebase would behave differently under test and that is exactly what we don't want.
Another option to reduce the complexity of the test suite is by exposing some implementation details of the AddLocationViewModel class. We need to jump through a few hoops to populate the locations property of the AddLocationViewModel class. We can simplify this step by exposing the setter of the locations property. This isn't something I recommend, but it is an approach some developers take to simplify the test suite.
One last option to consider is a refactoring of the AddLocationViewModel class. By splitting the view model up into smaller, testable components, we might end up with an implementation that is more testable. Smaller chunks of logic are very often easier to unit test so this could be a viable solution.
What's Next?
Asynchronous programming is challenging and the last few episodes have illustrated that unit testing asynchronous code can be just as challenging. I hope that doesn't stop you from writing unit tests, though.