Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

6. Dependency Injection & Mocks
Written by Michael Katz

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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’s 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 below // MARK: - Pedometer:

func testAppModel_whenStarted_startsPedometer() {
  //given
  givenGoalSet()
  let predicate = NSPredicate { model, _ -> Bool in
    (model as? AppModel)?.pedometerStarted ?? false
  }
  let exp = expectation(
    for: predicate,
    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 { _, 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 { _, _ 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 setUpWithError() throws {
  try super.setUpWithError()
  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 { _, 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 { _, 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
    ) { _ 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now