25 Testing and Mocking

Setting Up the Environment

It’s time to unit test the AddLocationViewViewModel class. Create a new unit test case class in the Test Cases group of the CloudyTests target and name it AddLocationViewViewModelTests.swift.

Creating AddLocationViewViewModelTests.swift
Creating AddLocationViewViewModelTests.swift

Remove the sample tests and add an import statement for RxSwift, RxTest, and RxBlocking. As I mentioned earlier, RxTest and RxBlocking make testing reactive code much easier. It’s one of the best features of the RxSwift project.

We also need to import the Cloudy module to make sure we have access to the AddLocationViewViewModel class. Don’t forget to prefix the import statement with the testable attribute to make internal entities accessible from within the test target.

AddLocationViewViewModelTests.swift

 1 import XCTest
 2 import RxTest
 3 import RxSwift
 4 import RxBlocking
 5 @testable import Cloudy
 6 
 7 class AddLocationViewViewModelTests: XCTestCase {
 8     
 9     // MARK: - Set Up & Tear Down
10 
11     override func setUp() {
12         super.setUp()
13     }
14     
15     override func tearDown() {
16         super.tearDown()
17     }
18 
19 }

Click the diamond in the gutter on the left to run the unit tests for the AddLocationViewViewModel class. We don’t have any tests yet, but it ensures everything is set up correctly.

Before we can write any tests, we need to declare a few properties:

AddLocationViewViewModelTests.swift

 1 // MARK: - Properties
 2 
 3 var viewModel: AddLocationViewViewModel!
 4 
 5 // MARK: -
 6 
 7 var scheduler: SchedulerType!
 8 
 9 // MARK: -
10 
11 var query: Variable<String>!

Notice that these properties are implicitly unwrapped optionals. Remember that safety isn’t a major concern when we write unit tests. If something goes wrong, it means we made a mistake in the test case, which we need to fix first.

The viewModel property is the view model we’ll be testing. The scheduler property is less important for our discussion. Schedulers provide a layer of abstraction for scheduling operations using RxSwift. That’s all you need to know about schedulers to follow along.

We use the query property to mock user input. Because the view model doesn’t know where the input comes from, it’s easy to mock user input by emitting events with the help of the query variable.

We set everything up in the setUp() method of the test case. Remember that this method is executed every time a unit test is run. It ensures we start with a clean slate before a unit test is run.

In the setUp() method, we create a Variable of type String and assign it to the query property. The variable is initialized with an empty string.

AddLocationViewViewModelTests.swift

1 override func setUp() {
2     super.setUp()
3 
4     // Initialize Query
5     query = Variable<String>("")
6 }

Mocking the Location Service

The next step is initializing the view model. 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, which conforms to the LocationService protocol.

AddLocationViewViewModelTests.swift

 1 import XCTest
 2 import RxTest
 3 import RxSwift
 4 import RxBlocking
 5 @testable import Cloudy
 6 
 7 class AddLocationViewViewModelTests: XCTestCase {
 8 
 9     private class MockLocationService: LocationService {
10 
11     }
12 
13     ...
14 
15 }

The only method we need to implement to conform to the LocationService protocol is geocode(addressString:completionHandler:). If addressString has a value and its value isn’t an empty string, we invoke the completion handler with an array containing one Location instance. The second argument of the completion handler, an optional error, is nil. Notice that we control what the location service returns. In this example, we return a Location instance with a name of Brussels and a fixed set of coordinates.

AddLocationViewViewModelTests.swift

 1 private class MockLocationService: LocationService {
 2 
 3     func geocode(addressString: String?, completionHandler: @escapin\
 4 g LocationServiceCompletionHandler) {
 5         if let addressString = addressString, !addressString.isEmpty\
 6  {
 7             // Create Location
 8             let location = Location(name: "Brussels", latitude: 50.8\
 9 503, longitude: 4.3517)
10 
11             // Invoke Completion Handler
12             completionHandler([location], nil)
13         } else {
14             // Invoke Completion Handler
15             completionHandler([], nil)
16         }
17     }
18 
19 }

If addressString has no value or its value is equal to an empty string, we invoke the completion handler with an empty array. The second argument of the completion handler, an optional error, is nil.

That’s it. In the setUp() method, we can now instantiate an instance of the MockLocationService class and pass it as an argument to the initializer of the AddLocationViewViewModel class. The first argument of the initializer is the query property as a driver.

AddLocationViewViewModelTests.swift

 1 override func setUp() {
 2     super.setUp()
 3 
 4     // Initialize Query
 5     query = Variable<String>("")
 6 
 7     // Initialize Location Service
 8     let locationService = MockLocationService()
 9 
10     // Initialize View Model
11     viewModel = AddLocationViewViewModel(query: query.asDriver(), lo\
12 cationService: locationService)
13 }

We also create a concurrent dispatch queue scheduler and assign it to the scheduler property. Don’t worry about this if you’re not familiar with RxSwift.

AddLocationViewViewModelTests.swift

 1 override func setUp() {
 2     super.setUp()
 3 
 4     // Initialize Query
 5     query = Variable<String>("")
 6 
 7     // Initialize Location Service
 8     let locationService = MockLocationService()
 9 
10     // Initialize View Model
11     viewModel = AddLocationViewViewModel(query: query.asDriver(), lo\
12 cationService: locationService)
13 
14     // Initialize Scheduler
15     scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
16 }

Writing Unit Tests

The first test we’re going to write tests the locations driver of the view model. Now that we control what the location service returns, we can test the behavior of the view model. We name the test testLocations_HasLocations().

AddLocationViewViewModelTests.swift

1 // MARK: - Tests for Locations
2 
3 func testLocations_HasLocations() {
4 
5 }

We create an observable and store a reference to it in a constant named observable.

AddLocationViewViewModelTests.swift

1 // Create Subscription
2 let observable = viewModel.locations.asObservable().subscribeOn(sche\
3 duler)

We emit a new event by setting the value of the query property. This mimics the user entering a name of a city in the search bar of the add location view controller.

AddLocationViewViewModelTests.swift

1 // Set Query
2 query.value = "Brus"

We use toBlocking() to make the test synchronous. The result is stored in the result constant.

AddLocationViewViewModelTests.swift

1 // Fetch Result
2 let result = try! observable.skip(1).toBlocking().first()!

The result is an array of Location instances. We assert that result isn’t equal to nil and that it contains one element.

AddLocationViewViewModelTests.swift

1 XCTAssertNotNil(result)
2 XCTAssertEqual(result.count, 1)

We also include an assertion for the location stored in the array by making sure the name property of the location is equal to Brussels.

AddLocationViewViewModelTests.swift

1 // Fetch Location
2 let location = result.first!
3 
4 XCTAssertEqual(location.name, "Brussels")

Notice that we make ample use of the exclamation mark. If anything blows up, it’s because of a failed test or an error we made. In other words, we made a mistake we need to fix. This is what the unit test looks like.

AddLocationViewViewModelTests.swift

 1 // MARK: - Tests for Locations
 2 
 3 func testLocations_HasLocations() {
 4     // Create Subscription
 5     let observable = viewModel.locations.asObservable().subscribeOn(\
 6 scheduler)
 7 
 8     // Set Query
 9     query.value = "Brus"
10 
11     // Fetch Result
12     let result = try! observable.skip(1).toBlocking().first()!
13 
14     XCTAssertNotNil(result)
15     XCTAssertEqual(result.count, 1)
16 
17     // Fetch Location
18     let location = result.first!
19 
20     XCTAssertEqual(location.name, "Brussels")
21 }

We also need to unit test the behavior when the user enter’s an empty string. This is very similar. We name this test testLocations_NoLocations(). I won’t go into the finer details of RxSwift, such as drivers replaying the last event when a subscriber is added. We expect an empty array of locations and that’s what we test.

AddLocationViewViewModelTests.swift

 1 func testLocations_NoLocations() {
 2     // Create Subscription
 3     let observable = viewModel.locations.asObservable().subscribeOn(\
 4 scheduler)
 5 
 6     // Fetch Result
 7     let result: [Location] = try! observable.toBlocking().first()!
 8 
 9     XCTAssertNotNil(result)
10     XCTAssertEqual(result.count, 0)
11 }

Let’s run the unit tests we have so far to make sure they pass. That’s looking good.

Running the Unit Tests
Running the Unit Tests

While I won’t be discussing every unit test of the AddLocationViewViewModel class, I want to show you a few more. With the next unit test, we test the location(at:) method of the AddLocationViewViewModel class.

The test looks similar to the ones we wrote earlier. Instead of inspecting the value of locations, 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 instance is equal to Brussels.

AddLocationViewViewModelTests.swift

 1 // MARK: - Tests for Location At Index
 2 
 3 func testLocationAtIndex_NonNil() {
 4     // Create Subscription
 5     let observable = viewModel.locations.asObservable().subscribeOn(\
 6 scheduler)
 7 
 8     // Set Query
 9     query.value = "Brus"
10 
11     // Fetch Result
12     let _ = try! observable.skip(1).toBlocking().first()!
13 
14     // Fetch Location
15     let result = viewModel.location(at: 0)
16 
17     XCTAssertNotNil(result)
18     XCTAssertEqual(result!.name, "Brussels")
19 }

We can create a similar test for an index that is out of bounds. In that case, the location(at:) method returns nil, which we can test for.

AddLocationViewViewModelTests.swift

 1 func testLocationAtIndex_Nil() {
 2     // Create Subscription
 3     let observable = viewModel.locations.asObservable().subscribeOn(\
 4 scheduler)
 5 
 6     // Set Query
 7     query.value = "Brus"
 8 
 9     // Fetch Result
10     let _ = try! observable.skip(1).toBlocking().first()!
11 
12     // Fetch Location
13     let result = viewModel.location(at: 1)
14 
15     XCTAssertNil(result)
16 }

You can find the other unit tests for the AddLocationViewViewModel class in the completed project of this chapter. I hope it’s clear that writing unit tests for a view model with bindings isn’t that much more complicated.