6
Dependency Injection & Mocks
Written by Michael Katz
So far, you’ve built and tested a fair amount of the app. There is one gigantic hole that you may have noticed… this “step-counting app” doesn’t yet count any steps!
In this chapter, you’ll learn how to use mocks to test code that depends on system or external services without needing to call services — the services may not be available, usable or reliable. These techniques allow you to test error conditions, like a failed save, and to isolate logic from SDKs, like Core Motion
and HealthKit
.
Don’t have an iPhone handy? Don’t worry; you’ll dip into functional testing using the Simulator to handle mock data.
What’s up with fakes, mocks, and stubs?
When writing tests, it’s important to isolate the SUT from other parts of the code so your tests have high confidence that they’re testing the system as described. Tests focused on edge cases or error conditions can be very difficult to write, as they often involve specific state external to the SUT. It’s also difficult to diagnose and debug tests that fail due to intermittent or inconsistent issues outside the SUT.
The way to isolate the SUT and circumvent these issues is to use test doubles: objects that stands in for real code. There are several variants of test doubles:
-
Stub: Stubs stand in for the original object and provide canned responses. These are often used to implement one method of a protocol and have empty or nil returning implementations for the others.
-
Fake: Fakes often have logic, but instead of providing real or production data, they provide test data. For example, a fake network manager might read/write from local JSON files instead of connecting over a network.
-
Mock: Mocks are used to verify behavior, that is they should have an expectation that a certain method of the mock gets called or that its state was set to an expected value. Mocks are generally expected to provide test values or behaviors.
-
Partial mock: While a regular mock is a complete substitution for a production object, a partial mock uses the production code and only overrides part of it to test the expectations. Partial mocks are usually a subclass or provide a proxy to the production object.
Understanding CMPedometer
There are a few ways of gathering activity data from the user, but the CMPedometer
API in Core Motion
is by far the easiest.
func testCMPedometer_whenQueries_loadsHistoricalData() {
// given
var error: Error?
var data: CMPedometerData?
let exp = expectation(description: "pedometer query returns")
// when
let now = Date()
let then = now.addingTimeInterval(-1000)
sut.queryPedometerData(from: then, to: now) {
pedometerData, pedometerError in
error = pedometerError
data = pedometerData
exp.fulfill()
}
// then
wait(for: [exp], timeout: 1)
XCTAssertNil(error)
XCTAssertNotNil(data)
if let steps = data?.numberOfSteps {
XCTAssertGreaterThan(steps.intValue, 0)
} else {
XCTFail("no step data")
}
}
Mocking
Restating the problem
Open AppModelTests.swift, and add the following test beneath the “Pedometer” mark:
func testAppModel_whenStarted_startsPedometer() {
//given
givenGoalSet()
let exp = expectation(for: NSPredicate(block:
{ thing, _ -> Bool in
return (thing as! AppModel).pedometerStarted
}), evaluatedWith: sut, handler: nil)
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
XCTAssertTrue(sut.pedometerStarted)
}
let pedometer = CMPedometer()
private(set) var pedometerStarted = false
startPedometer()
// MARK: - Pedometer
extension AppModel {
func startPedometer() {
pedometer.startEventUpdates { event, error in
if error == nil {
self.pedometerStarted = true
}
}
}
}
Mocking the pedometer
To move pass this impasse, it’s time to create the mock pedometer. In order to swap CMPedometer
for it’s mock object, you’ll first need to separate the pedometer’s interface from its implementation.
protocol Pedometer {
func start()
}
import CoreMotion
extension CMPedometer: Pedometer {
func start() {
startEventUpdates { event, error in
// do nothing here for now
}
}
}
init(pedometer: Pedometer = CMPedometer()) {
self.pedometer = pedometer
}
func startPedometer() {
pedometer.start()
}
import CoreMotion
@testable import FitNess
class MockPedometer: Pedometer {
private(set) var started: Bool = false
func start() {
started = true
}
}
var mockPedometer: MockPedometer!
override func setUp() {
super.setUp()
mockPedometer = MockPedometer()
sut = AppModel(pedometer: mockPedometer)
}
func testAppModel_whenStarted_startsPedometer() {
//given
givenGoalSet()
// when
try! sut.start()
// then
XCTAssertTrue(mockPedometer.started)
}
Handling error conditions
Mocks make it easy to test error conditions. If you’ve been following along so far using both Simulator and a device, you may have encountered one or both of these error states:
Dealing with no pedometer
To handle the first case, you’ll have to add functionality to detect that the pedometer is not available and to inform the user.
func testPedometerNotAvailable_whenStarted_doesNotStart() {
// given
givenGoalSet()
mockPedometer.pedometerAvailable = false
// when
try! sut.start()
// then
XCTAssertEqual(sut.appState, .notStarted)
}
var pedometerAvailable: Bool { get }
var pedometerAvailable: Bool = true
var pedometerAvailable: Bool {
return CMPedometer.isStepCountingAvailable() &&
CMPedometer.isDistanceAvailable() &&
CMPedometer.authorizationStatus() != .restricted
}
guard pedometer.pedometerAvailable else {
AlertCenter.instance.postAlert(alert: .noPedometer)
return
}
func testPedometerNotAvailable_whenStarted_generatesAlert() {
// given
givenGoalSet()
mockPedometer.pedometerAvailable = false
let exp = expectation(forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.noPedometer))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
Injecting dependencies
Re-run all the tests, and you will see failures in StepCountControllerTests
. That’s because this new pedometerAvailable
guard in AppModel
is still dependent on the production CMPedometer
in other tests.
var pedometer: Pedometer
AppModel.instance.pedometer = MockPedometer()
Dealing with no permission
The other error state that needs to be handled is when the user declines the permission pop-up.
func testPedometerNotAuthorized_whenStarted_doesNotStart() {
// given
givenGoalSet()
mockPedometer.permissionDeclined = true
// when
try! sut.start()
// then
XCTAssertEqual(sut.appState, .notStarted)
}
func testPedometerNotAuthorized_whenStarted_generatesAlert() {
// given
givenGoalSet()
mockPedometer.permissionDeclined = true
let exp = expectation(forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.notAuthorized))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
var permissionDeclined: Bool { get }
var permissionDeclined: Bool = false
var permissionDeclined: Bool {
return CMPedometer.authorizationStatus() == .denied
}
guard !pedometer.permissionDeclined else {
AlertCenter.instance.postAlert(alert: .notAuthorized)
return
}
Mocking a callback
There is another important error situation to handle. This occurs the very first time the user taps Start on a pedometer-capable device. In that case, the start flow goes ahead, but the user can decline in the permission pop-up. If the user declines, there is an error in the eventUpdates
callback.
func testAppModel_whenDeniedAuthAfterStart_generatesAlert() {
// given
givenGoalSet()
mockPedometer.error = MockPedometer.notAuthorizedError
let exp = expectation(forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.notAuthorized))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
func start(completion: @escaping (Error?) -> Void)
func start(completion: @escaping (Error?) -> Void) {
startEventUpdates { event, error in
completion(error)
}
}
func startPedometer() {
pedometer.start { error in
if let error = error {
let alert = error.is(CMErrorMotionActivityNotAuthorized)
? .notAuthorized : Alert(error.localizedDescription)
AlertCenter.instance.postAlert(alert: alert)
}
}
}
var error: Error?
func start(completion: @escaping (Error?) -> Void) {
started = true
DispatchQueue.global(qos: .default).async {
completion(self.error)
}
}
static let notAuthorizedError =
NSError(domain: CMErrorDomain,
code: Int(CMErrorMotionActivityNotAuthorized.rawValue),
userInfo: nil)
Getting actual data
It’s time move on to handling data updates. The incoming data is the most important part of the app, and it’s crucial to have it properly mocked. The actual step and distance count are provided by CMPedometer
through the aptly named CMPedometerData
object. This too should be abstracted between the app and Core Motion.
protocol PedometerData {
var steps: Int { get }
var distanceTravelled: Double { get }
}
@testable import FitNess
struct MockData: PedometerData {
let steps: Int
let distanceTravelled: Double
}
func testModel_whenPedometerUpdates_updatesDataModel() {
// given
givenInProgress()
let data = MockData(steps: 100, distanceTravelled: 10)
// when
mockPedometer.sendData(data)
// then
XCTAssertEqual(sut.dataModel.steps, 100)
XCTAssertEqual(sut.dataModel.distance, 10)
}
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void)
var updateBlock: ((Error?) -> Void)?
var dataBlock: ((PedometerData?, Error?) -> Void)?
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void) {
started = true
updateBlock = eventUpdates
dataBlock = dataUpdates
DispatchQueue.global(qos: .default).async {
self.updateBlock?(self.error)
}
}
func sendData(_ data: PedometerData?) {
dataBlock?(data, error)
}
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void) {
startEventUpdates { event, error in
eventUpdates(error)
}
startUpdates(from: Date()) { data, error in
dataUpdates(data, error)
}
}
extension CMPedometerData: PedometerData {
var steps: Int {
return numberOfSteps.intValue
}
var distanceTravelled: Double {
return distance?.doubleValue ?? 0
}
}
func startPedometer() {
pedometer.start(dataUpdates: handleData,
eventUpdates: handleEvents)
}
func handleData(data: PedometerData?, error: Error?) {
if let data = data {
dataModel.steps += data.steps
dataModel.distance += data.distanceTravelled
}
}
func handleEvents(error: Error?) {
if let error = error {
let alert = error.is(CMErrorMotionActivityNotAuthorized)
? .notAuthorized : Alert(error.localizedDescription)
AlertCenter.instance.postAlert(alert: alert)
}
}
Making a functional fake
At this point it sure would be nice to see the app in action. The unit tests are useful for verifying logic but are bad at verifying you’re building a good user experience. One way to do that is to build and run on a device, but that will require you to walk around to complete the goal. That’s very time and calorie consuming. There has got to be a better way!
import Foundation
class SimulatorPedometer: Pedometer {
struct Data: PedometerData {
let steps: Int
let distanceTravelled: Double
}
var pedometerAvailable: Bool = true
var permissionDeclined: Bool = false
var timer: Timer?
var distance = 0.0
var updateBlock: ((Error?) -> Void)?
var dataBlock: ((PedometerData?, Error?) -> Void)?
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void) {
updateBlock = eventUpdates
dataBlock = dataUpdates
timer = Timer(timeInterval: 1, repeats: true,
block: { timer in
self.distance += 1
print("updated distance: \(self.distance)")
let data = Data(steps: 10,
distanceTravelled: self.distance)
self.dataBlock?(data, nil)
})
RunLoop.main.add(timer!, forMode: RunLoop.Mode.default)
updateBlock?(nil)
}
func stop() {
timer?.invalidate()
updateBlock?(nil)
updateBlock = nil
dataBlock = nil
}
}
static var pedometerFactory: (() -> Pedometer) = {
#if targetEnvironment(simulator)
return SimulatorPedometer()
#else
return CMPedometer()
#endif
}
init(pedometer: Pedometer = pedometerFactory()) {
self.pedometer = pedometer
}
Wiring up the chase view
Looking at the app now, that white box in the middle is a little disappointing. This is the chase view (it illustrates Nessie’s chase of the user), and hasn’t yet been wired up.
@testable import FitNess
class ChaseViewPartialMock: ChaseView {
var updateStateCalled = false
var lastRunner: Double?
var lastNessie: Double?
override func updateState(runner: Double, nessie: Double) {
updateStateCalled = true
lastRunner = runner
lastNessie = nessie
super.updateState(runner: runner, nessie: nessie)
}
}
var mockChaseView: ChaseViewPartialMock!
mockChaseView = ChaseViewPartialMock()
sut.chaseView = mockChaseView
func testChaseView_whenDataSent_isUpdated() {
// given
givenInProgress()
// when
let data = MockData(steps:500, distanceTravelled:10)
(AppModel.instance.pedometer as! MockPedometer).sendData(data)
// then
XCTAssertTrue(mockChaseView.updateStateCalled)
XCTAssertEqual(mockChaseView.lastRunner, 0.5)
}
NotificationCenter.default
.addObserver(forName: DataModel.UpdateNotification,
object: nil,
queue: nil) { _ in
self.updateUI()
}
private func updateChaseView() {
chaseView.state = AppModel.instance.appState
let dataModel = AppModel.instance.dataModel
let runner =
Double(dataModel.steps) / Double(dataModel.goal ?? 10_000)
let nessie = dataModel.nessie.distance > 0 ?
dataModel.distance / dataModel.nessie.distance : 0
chaseView.updateState(runner: runner, nessie: nessie)
}
Time dependencies
The final major piece missing is Nessie. She should be chasing after the user while the app is in progress. Her progress will be measured at a constant velocity. Measuring something over time? Sounds like a Timer
is the answer.
func testNessie_whenUpdated_incrementsDistance() {
// when
sut.incrementDistance()
// then
XCTAssertEqual(sut.distance, sut.velocity)
}
distance += velocity
Challenge
You’ve reached the end of the chapter, but not the end of the app. You should be able to take the testing tools you’ve learned and finish the app. Your challenge is to add the following tests and features to complete the app:
Key points
- Test doubles let you test code in isolation from other systems, especially those that are part of system SDKs, rely on networking or timers.
- Mocks let you swap in a test implementation of a class, and partial mocks let you just substitute part of a class.
- Fakes let you supply data for testing or use in Simulator.
Where to go from here?
That’s it. Over the past few chapters, you’ve built an an app from the ground up following TDD principles.