It is time to unit test the AddLocationViewModel class. Create a new unit test case class in the Test Cases group of the CloudyTests target and name it AddLocationViewModelTests.swift.

Remove the example unit tests and add an import statement for the Combine framework. We also need to import the Cloudy module to make sure we have access to the AddLocationViewModel class. Don't forget to prefix the import statement with the testable attribute to make internal entities accessible from within the test target.
AddLocationViewModelTests.swift
import XCTest
import Combine
@testable import Cloudy
class AddLocationViewModelTests: XCTestCase {
// MARK: - Set Up & Tear Down
override func setUpWithError() throws {}
override func tearDownWithError() throws {}
}
Click the diamond in the gutter on the left to run the unit tests for the AddLocationViewModel class. We don't have any unit tests at the moment, but it ensures everything is set up correctly.
Before we can write any unit tests, we need to declare a property, viewModel, of type AddLocationViewModel!. The viewModel property is the view model we will unit test in a moment. Notice that viewModel is an implicitly unwrapped optional. Remember that safety isn't a major concern when we write unit tests. If something goes wrong, it means we made a mistake we need to fix.
// MARK: - Properties
var viewModel: AddLocationViewModel!
We also define a variable property, subscriptions, of type Set<AnyCancellable> and initialize it with an empty set. As the name suggests, the subscriptions property stores subscriptions we create in the unit tests for the AddLocationViewModel class. We covered this earlier in this series.
// MARK: - Properties
var viewModel: AddLocationViewModel!
// MARK: -
var subscriptions: Set<AnyCancellable> = []
Mocking the Location Service
We create the view model in the setUpWithError() method of the test case. But we have a problem. If we use the Geocoder class we created earlier, we cannot stub the response of the geocoding request. Remember that we want to control the environment in which the test suite is run. If the application talks to a location service, we need the ability to control its response.
Fortunately, we already did the heavy lifting to make this very easy. All we need to do is create a mock location service. We start by declaring a private, nested class, MockLocationService, that conforms to the LocationService protocol.
AddLocationViewModelTests.swift
import XCTest
import Combine
@testable import Cloudy
class AddLocationViewModelTests: XCTestCase {
// MARK: - Types
private class MockLocationService: LocationService {
}
...
}
The only method we need to implement to conform to the LocationService protocol is geocode(addressString:completion:). If addressString is equal to an empty string, we invoke the closure with an empty array. If the value of addressString isn't an empty string, we invoke the closure with an array containing a single Location object. It is important to understand that we control what the location service returns. In this example, we return a Location object with a name of Brussels and a fixed set of coordinates.
AddLocationViewModelTests.swift
private class MockLocationService: LocationService {
func geocode(addressString: String, completion: @escaping LocationService.Completion) {
if addressString.isEmpty {
// Invoke Completion Handler
completion(.success([]))
} else {
// Create Location
let location = Location(name: "Brussels", latitude: 50.8503, longitude: 4.3517)
// Invoke Completion Handler
completion(.success([location]))
}
}
}
Before we continue, however, we create a fileprivate extension for the Location struct at the bottom of AddLocationViewModelTests.swift. We want to make it easier to access the Location object the mock location service returns. We define a static computed property, brussels, of type Location. The static computed property creates and returns the Location object the mock location service should return.
AddLocationViewModelTests.swift
fileprivate extension Location {
static var brussels: Location {
Location(name: "Brussels", latitude: 50.8503, longitude: 4.3517)
}
}
With the extension in place, we can use the static computed property in the geocode(addressString:completion:) method of the MockLocationService class. This is much better.
private class MockLocationService: LocationService {
func geocode(addressString: String, completion: @escaping LocationService.Completion) {
if addressString.isEmpty {
// Invoke Completion Handler
completion(.success([]))
} else {
// Invoke Completion Handler
completion(.success([.brussels]))
}
}
}
In the setUpWithError() method, we can now instantiate an instance of the MockLocationService class and pass it as an argument to the initializer of the AddLocationViewModel class.
AddLocationViewModelTests.swift
override func setUpWithError() throws {
// Initialize Location Service
let locationService = MockLocationService()
// Initialize View Model
viewModel = AddLocationViewModel(locationService: locationService)
}
Writing Unit Tests
Because we control what the location service returns, unit testing the implementation of the view model isn't too difficult. The most challenging aspect of unit testing the AddLocationViewModel class is the Combine framework. We only have the XCTest framework to help us.
The first unit test we write tests the locationsPublisher property of the view model. We need to write two unit tests. The first unit test tests that the publisher emits an array of Location objects. The second unit test tests that the publisher emits an empty array. We name the first unit test testLocationsPublisher_HasLocations().
AddLocationViewModelTests.swift
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
}
We need to write an asynchronous unit test and that means we make use of the XCTestExpectation class. We create an expectation by invoking the expectation(description:) method of the XCTestCase class.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
}
We then define which values we expect the locationsPublisher property to emit. The values the publisher emits are of type [Location]. That means the type of expectedValues is [[Location]].
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = []
}
We expect the locationsPublisher property to emit two values, an empty array and an array with one location. The empty array is the initial value of the locationsPublisher property. It is emitted as soon as a subscriber is attached to the publisher.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[.brussels]
]
}
The next step is attaching a subscriber to the locationsPublisher property by invoking the sink(receiveValue:) method. In the closure we pass to the sink(receiveValue:) method, we compare the emitted value to the value of expectedValues. If both are equal, the expectation is fulfilled. We use the store(in:) method to add the subscription returned by the sink(receiveValue:) method to the set of subscriptions.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[.brussels]
]
// Subscribe to Locations Publisher
viewModel.locationsPublisher
.sink { (result) in
if result == expectedValues {
expectation.fulfill()
}
}.store(in: &subscriptions)
}
There is one key element missing from the unit test. The comparison in the closure we pass to the sink(receiveValue:) method makes no sense at the moment. The locationsPublisher property emits an array of Location objects whereas expectedValues is of type [[Location]]. This is easy to resolve with the collect(_:) operator. As the name suggests, this operator collects up to the specified number of elements and emits those elements as an array. We expect the publisher to emit two elements so we pass 2 to the collect(_:) operator.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[.brussels]
]
// Subscribe to Locations Publisher
viewModel.locationsPublisher
.collect(2)
.sink { (result) in
if result == expectedValues {
expectation.fulfill()
}
}.store(in: &subscriptions)
}
The rest of the unit test is simple. We mock user input by setting the query property of the view model.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[.brussels]
]
// Subscribe to Locations Publisher
viewModel.locationsPublisher
.collect(2)
.sink { (result) in
if result == expectedValues {
expectation.fulfill()
}
}.store(in: &subscriptions)
// Set Query
viewModel.query = "Brus"
}
Last but not least, we wait for the expectation we defined earlier to be fulfilled. If the expectation isn't fulfilled in time, the unit test fails due to a timeout.
// MARK: - Tests for Locations Publisher
func testLocationsPublisher_HasLocations() {
// Define Expectation
let expectation = self.expectation(description: "Has Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[.brussels]
]
// Subscribe to Locations Publisher
viewModel.locationsPublisher
.collect(2)
.sink { (result) in
if result == expectedValues {
expectation.fulfill()
}
}.store(in: &subscriptions)
// Set Query
viewModel.query = "Brus"
// Wait for Expectations
wait(for: [expectation], timeout: 1.0)
}
We also need to unit test the behavior when the user enters an empty string. This is very similar. We name the second unit test testLocationsPublisher_NoLocations(). We expect an empty array of Location objects and that is what we test.
AddLocationViewModelTests.swift
func testLocationsPublisher_NoLocations() {
// Define Expectation
let expectation = self.expectation(description: "No Locations")
// Define Expected Values
let expectedValues: [[Location]] = [
[],
[]
]
// Subscribe to Locations Publisher
viewModel.locationsPublisher
.collect(2)
.sink { (result) in
if result == expectedValues {
expectation.fulfill()
}
}.store(in: &subscriptions)
// Set Query
viewModel.query = ""
// Wait for Expectations
wait(for: [expectation], timeout: 1.0)
}
Let's run the unit tests we have so far to make sure they pass. That is looking good.

I won't discuss every unit test of the AddLocationViewModel class, but I want to show you a few more. With the next unit test, we test the location(at:) method of the AddLocationViewModel class.
We ask the view model for the location at index 0. It shouldn't be equal to nil. We also assert that the name of the Location object is equal to Brussels.
AddLocationViewModelTests.swift
// MARK: - Tests for Location At Index
func testLocationAtIndex_NotNil() {
// Define Expectation
let expectation = self.expectation(description: "Location Not Nil")
// Attach Subscriber
viewModel.locationsPublisher
.collect(2)
.sink { [weak self] _ in
if let location = self?.viewModel.location(at: 0), location.name == Location.brussels.name {
expectation.fulfill()
}
}.store(in: &subscriptions)
// Set Query
viewModel.query = "Brus"
// Wait for Expectations
wait(for: [expectation], timeout: 1.0)
}
We can write a similar unit test for an index that is out of bounds. We set the query property of the view model to an empty string and ask the view model for the location at index 0. Because the array of Location objects is an empty array, index 0 is out of bounds and the location(at:) method should return nil.
AddLocationViewModelTests.swift
func testLocationAtIndex_Nil() {
// Define Expectation
let expectation = self.expectation(description: "Location Nil")
// Attach Subscriber
viewModel.locationsPublisher
.collect(2)
.sink { [weak self] _ in
if self?.viewModel.location(at: 0) == nil {
expectation.fulfill()
}
}.store(in: &subscriptions)
// Set Query
viewModel.query = ""
// Wait for Expectations
wait(for: [expectation], timeout: 1.0)
}
Run the test suite to make sure the unit tests pass.
What's Next?
The unit tests we covered in this episode only cover the happy paths, that is, the geocoding requests performed by the mock location service are successful. It is equally important to write unit tests for the unhappy paths. This isn't difficult since we control the result of the geocoding requests.
This episode illustrates that writing unit tests for code that involves the Combine framework isn't trivial. The XCTest framework doesn't offer an API that makes this easier. I hope Apple continues to improve the Combine framework and draws inspiration from RxTest and RxBlocking.