This is the third and last installment of a series that teaches you how to mock and stub system classes in Swift. In this tutorial, we unit test the AddLocationViewViewModel
class.
We create a new unit test case class, name it AddLocationViewViewModelTests, and remove the sample tests Xcode added for us.
Because the AddLocationViewViewModel
class uses RxSwift and RxCocoa, we need to add an import statement for RxSwift. We also add import statements for RxTest and RxBlocking. These libraries are part of the RxSwift project and make testing reactive code much easier. It's one of the best features of the RxSwift project.
Last but not least, we import the Cloudy module to make sure we have access to the AddLocationViewViewModel
class. By prefixing the import statement with the testable attribute, internal methods are also accessible from within the test target.
import XCTest
import RxTest
import RxSwift
import RxBlocking
@testable import Cloudy
class AddLocationViewViewModelTests: XCTestCase {
// MARK: - Set Up & Tear Down
override func setUp() {
super.setUp()
}
override func tearDown() {
super.tearDown()
}
}
Before we can write any tests, we need to declare a few properties:
viewModel
of typeAddLocationViewViewModel!
scheduler
of typeSchedulerType!
query
of typeVariable<String>!
Notice that these properties are implicitly unwrapped optionals. Safety isn't a major concern when I write unit tests. It's more important for the unit tests to be reliable and easy to read. If something goes wrong, it means I made a mistake in one of the test cases.
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 through the query
variable.
// MARK: - Properties
var viewModel: AddLocationViewViewModel!
// MARK: -
var scheduler: SchedulerType!
// MARK: -
var query: Variable<String>!
We prepare for every unit test in the setUp()
method of the test case. Remember that this method is executed every time a test is run. It ensures we start with a clean slate. 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.
override func setUp() {
super.setUp()
// Initialize Query
query = Variable<String>("")
}
The next step is initializing the view model. We have a problem, though. If we use the Geocoder
class we implemented in the previous tutorial, 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.
private class MockLocationService: LocationService {
}
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. 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.
private class MockLocationService: LocationService {
func geocode(addressString: String?, completionHandler: @escaping LocationServiceCompletionHandler) {
if let addressString = addressString, !addressString.isEmpty {
// Create Location
let location = Location(name: "Brussels", latitude: 50.8503, longitude: 4.3517)
// Invoke Completion Handler
completionHandler([location], nil)
} else {
// Invoke Completion Handler
completionHandler([], nil)
}
}
}
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 passed in as a driver.
override func setUp() {
super.setUp()
// Initialize Query
query = Variable<String>("")
// Initialize Location Service
let locationService = MockLocationService()
// Initialize View Model
viewModel = AddLocationViewViewModel(query: query.asDriver(), locationService: locationService)
}
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.
override func setUp() {
super.setUp()
// Initialize Query
query = Variable<String>("")
// Initialize Location Service
let locationService = MockLocationService()
// Initialize View Model
viewModel = AddLocationViewViewModel(query: query.asDriver(), locationService: locationService)
// Initialize Scheduler
scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
}
To prove that the mock location service does the job we want it to do, I'm going to write a unit test that 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()
. We create an observable and store a reference to it in a constant named observable
.
// Create Subscription
let observable = viewModel.locations.asObservable().subscribeOn(scheduler)
We emit a new event by setting the value of the query
property. This mimics the user entering a city name in the search bar of the add location view controller.
// Set Query
query.value = "Brus"
We use toBlocking()
to make the test synchronous. The result, an array of Location
instances, is stored in the result
constant.
// Fetch Result
let result = try! observable.skip(1).toBlocking().first()!
We assert that result
isn't equal to nil
and that it contains one element.
XCTAssertNotNil(result)
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.
// Fetch Location
let location = result.first!
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 that we need to fix. This is what the unit test looks like.
func testLocations_HasLocations() {
// Create Subscription
let observable = viewModel.locations.asObservable().subscribeOn(scheduler)
// Set Query
query.value = "Brus"
// Fetch Result
let result = try! observable.skip(1).toBlocking().first()!
XCTAssertNotNil(result)
XCTAssertEqual(result.count, 1)
// Fetch Location
let location = result.first!
XCTAssertEqual(location.name, "Brussels")
}
What Have We Accomplished
If you're new to dependency injection and protocol-oriented programming, it may seem a lot of work to mock the Core Location framework. That may be true because we had to start from scratch. We extracted the CLGeocoder
class from the AddLocationViewViewModel
class, we defined the LocationService
protocol, we implemented the Geocoder
class, and we injected a Geocoder
instance into the AddLocationViewViewModel
class.
That's a lot of work. Isn't it? But if you embrace protocol-oriented programming in the code you write day to day, that's how you architect your projects. You make sure that the code you write is decoupled and easy to test. That's the default. Protocol-oriented programming and dependency injection allow you to do that.
I hope you agree that the result is pretty nice. The AddLocationViewViewModel
class has no clue what type of object performs the geocoding requests. The only requirement the AddLocationViewViewModel
class sets is that the object conforms to the LocationService
protocol. This makes it easy to inject a mock location service when we're testing. We can even replace the location service with a third party service if necessary.
Even though mocking and stubbing is easier if you can take advantage of the powerful Objective-C runtime, I like how Swift forces me to rethink the code I write. This is something I've written about in the past. By writing unit tests, you automatically write better code. Give it a try.