Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

5. Test Expectations
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

In the previous chapters you built out the app’s state based upon what the user can do with the Start button. The main part of the app relies on responding to changes as the user moves around and records steps. These actions create events outside the program’s control. XCTestExpectation is the tool for testing things that happen outside the direct flow.

In this chapter you’ll learn:

  • General test expectations
  • Notification expectations

Use this chapter’s starter project instead of continuing on from the previous’ final, as it has some additions to help you out.

Using an expectation

XCTest expectations have two parts: the expectation and a waiter. An expectation is an object that you can later fulfill. The wait method of XCTestCase tells the test execution to wait until the expectation is fulfilled or a specified amount of time passes.

In the last chapter you built out the app states corresponding to direct user action: in progress, paused, and not started. In this chapter you’ll add support for caught and completed.

These state transitions occur in response to asynchronous events outside the user’s control.

Nessie overtakes user User reaches goal Caught Completed Not Started In Progress Paused start() pause() resume() Start restart()

The red-shaded states have already been built. You’ll be adding the grey states.

Writing an asynchronous test

In order to react to an asynchronous event, the code needs a way to listen for a change. This is commonly done through a closure, a delegate method, or by observing a notification.

func testAppModel_whenStateChanges_executesCallback() {
  // given
  givenInProgress()
  var observedState = AppState.notStarted

  // 1
  let expected = expectation(description: "callback happened")
  sut.stateChangedCallback = { model in
    observedState = model.appState
    // 2
    expected.fulfill()
  }

  // when
  sut.pause()

  // then
  // 3
  wait(for: [expected], timeout: 1)
  XCTAssertEqual(observedState, .paused)
}
var stateChangedCallback: ((AppModel) -> Void)?
Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "callback happened".
private(set) var appState: AppState = .notStarted {
  didSet {
    stateChangedCallback?(self)
  }
}
sut.stateChangedCallback = nil

Testing for true asynchronicity

The last test checks that the callback is called in direct response to an update on the sut. Next, you’ll tackle a more indirect usage via updates to the view controller. Open StepCountControllerTests.swift at the end of // MARK: - Terminal States add the following two tests:

func testController_whenCaught_buttonLabelIsTryAgain() {
  // given
  givenInProgress()
  let exp = expectation(description: "button title change")
  let observer = ButtonObserver()
  observer.observe(sut.startButton, expectation: exp)

  // when
  whenCaught()

  // then
  waitForExpectations(timeout: 1)
  let text = sut.startButton.title(for: .normal)
  XCTAssertEqual(text, AppState.caught.nextStateButtonLabel)
}

func testController_whenComplete_buttonLabelIsStartOver() {
  // given
  givenInProgress()
  let exp = expectation(description: "button title change")
  let observer = ButtonObserver()
  observer.observe(sut.startButton, expectation: exp)

  // when
  whenCompleted()

  // then
  waitForExpectations(timeout: 1)
  let text = sut.startButton.title(for: .normal)
  XCTAssertEqual(text, AppState.completed.nextStateButtonLabel)
}
import XCTest

class ButtonObserver {
  var token: NSKeyValueObservation?

  func observe(_ button: UIButton, expectation: XCTestExpectation) {
    token = button
      .observe(\.titleLabel?.text, options: [.new]) { _, _ in
        expectation.fulfill()
      }
  }

  deinit {
    token?.invalidate()
  }
}
func whenCaught() {
  AppModel.instance.setToCaught()
}

func whenCompleted() {
  AppModel.instance.setToComplete()
}
XCTAssertEqual failed: ("Optional("Pause")") is not equal to ("Optional("Try Again")")

XCTAssertEqual failed: ("Optional("Pause")") is not equal to ("Optional("Start Over")")
AppModel.instance.stateChangedCallback = { model in
  DispatchQueue.main.async {
    self.updateUI()
  }
}

Waiting for notifications

In the next phase of app building, you’ll add a feature to visually notify the users when an event happens, such as meeting a milestone goal or when Nessie catches up.

Building the alert center

One important feature for an activity app or game is to update the user when important events happen. In FitNess these updates are managed by an AlertCenter. When something interesting happens, the code will post Alerts to the AlertCenter. The alert center is responsible for managing a stack of messages to display to the user.

Ixzipolv Xyapjar Fido Rujat Otezr Hebpat Upukf Deav Tephcevcor jmazl iwcitak sioxjiv 29% ih diaq fetszon esalh jaupq

func testPostOne_generatesANotification() {
  // given
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)
  let alert = Alert("this is an alert")

  // when
  sut.postAlert(alert: alert)

  // then
  wait(for: [exp], timeout: 1)
}
Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "Expect notification 'Alert' from FitNess.AlertCenter".
func postAlert(alert: Alert) {
  let notification = Notification(
    name: AlertNotification.name,
    object: self)
  notificationCenter.post(notification)
}

Waiting for multiple events

Next, try testing if posting two alerts sends two notifications. Add the following to the end of AlertCenterTests:

func testPostingTwoAlerts_generatesTwoNotifications() {
  //given
  let exp1 = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)
  let exp2 = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)
  let alert1 = Alert("this is the first alert")
  let alert2 = Alert("this is the second alert")

  // when
  sut.postAlert(alert: alert1)
  sut.postAlert(alert: alert2)

  // then
  wait(for: [exp1, exp2], timeout: 1)
}
sut.postAlert(alert: alert2)
func testPostingTwoAlerts_generatesTwoNotifications() {
  //given
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)
  exp.expectedFulfillmentCount = 2
  let alert1 = Alert("this is the first alert")
  let alert2 = Alert("this is the second alert")

  // when
  sut.postAlert(alert: alert1)

  // then
  wait(for: [exp], timeout: 1)
}
sut.postAlert(alert: alert2)

Expecting something not to happen

Good test suites not only test when things happen according to plan, but also check that certain side effects do not occur. One of things the app should not do is spam the user with alerts. Therefore, if a specific alert is posted twice, it should only generate one notification.

func testPostDouble_generatesOnlyOneNotification() {
  //given
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)
  exp.expectedFulfillmentCount = 2
  exp.isInverted = true
  let alert = Alert("this is an alert")

  // when
  sut.postAlert(alert: alert)
  sut.postAlert(alert: alert)

  // then
  wait(for: [exp], timeout: 1)
}
exp.isInverted = true
private var alertQueue: [Alert] = []
guard !alertQueue.contains(alert) else { return }

alertQueue.append(alert)

Showing the alert to a user

In the app’s architecture, the RootViewController is responsible for showing alerts to the user via its alertContainer view.

@testable import FitNess
var sut: RootViewController!

override func setUpWithError() throws {
  try super.setUpWithError()
  sut = getRootViewController()
}

override func tearDownWithError() throws {
  sut = nil
  try super.tearDownWithError()
}
// MARK: - Alert Container

func testWhenLoaded_noAlertsAreShown() {
  XCTAssertTrue(sut.alertContainer.isHidden)
}
func testWhenAlertsPosted_alertContainerIsShown() {
  // given
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: nil,
    handler: nil)
  let alert = Alert("show the container")

  // when
  AlertCenter.instance.postAlert(alert: alert)

  // then
  wait(for: [exp], timeout: 1)
  XCTAssertFalse(sut.alertContainer.isHidden)
}
AlertCenter.listenForAlerts { center in
  self.alertContainer.isHidden = false
}
class func listenForAlerts(
  _ callback: @escaping (AlertCenter) -> Void
) {
  instance.notificationCenter
    .addObserver(
      forName: AlertNotification.name,
      object: instance,
      queue: .main) { _ in
    callback(instance)
    }
}

Continuous refactoring

When you only run testWhenLoaded_noAlertsAreShown(), it will pass. If you run all the tests in RootViewControllerTests, then testWhenLoaded_noAlertsAreShown() may fail.

// MARK: - Alert Count
func testWhenInitialized_AlertCountIsZero() {
  XCTAssertEqual(sut.alertCount, 0)
}
var alertCount: Int {
  return alertQueue.count
}
func testWhenAlertPosted_CountIsIncreased() {
  // given
  let alert = Alert("An alert")

  // when
  sut.postAlert(alert: alert)

  // then
  XCTAssertEqual(sut.alertCount, 1)
}

func testWhenCleared_CountIsZero() {
  // given
  let alert = Alert("An alert")
  sut.postAlert(alert: alert)

  // when
  sut.clearAlerts()

  // then
  XCTAssertEqual(sut.alertCount, 0)
}
AlertCenter.instance.clearAlerts()
AlertCenter.instance.clearAlerts()
// MARK: - Alert Handling

func clearAlerts() {
  alertQueue.removeAll()
}
self.alertContainer.isHidden = center.alertCount == 0
AlertCenter.instance.clearAlerts()
sut.reset()
@IBAction func startStopPause(_ sender: Any?) {
  let alert = Alert("Test Alert")
  AlertCenter.instance.postAlert(alert: alert)
}

Error: This image is missing a width attribute

Please provide one in the form of ![width=50%](images/alert_showing.png)

Getting specific about notifications

To make sure the UI is updated effectively, it will be useful to add additional information to the alert notification beyond the name.

// MARK: - Notification Contents
func testNotification_whenPosted_containsAlertObject() {
  // given
  let alert = Alert("test contents")
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: sut,
    handler: nil)

  var postedAlert: Alert?
  sut.notificationCenter.addObserver(
    forName: AlertNotification.name,
    object: sut,
    queue: nil) { notification in
    let info = notification.userInfo
    postedAlert = info?[AlertNotification.Keys.alert] as? Alert
  }

  // when
  sut.postAlert(alert: alert)

  // then
  wait(for: [exp], timeout: 1)
  XCTAssertNotNil(postedAlert, "should have sent an alert")
  XCTAssertEqual(
    alert,
    postedAlert,
    "should have sent the original alert")
}
let notification = Notification(
  name: AlertNotification.name,
  object: self,
  userInfo: [AlertNotification.Keys.alert: alert])

Driving alerts from the data model

In order to drive engagement and give the user a sense of fulfillment as they near their goal, it’s important to present messages to the user as they reach certain milestones.

@testable import FitNess

extension Notification {
  var alert: Alert? {
    return userInfo?[AlertNotification.Keys.alert] as? Alert
  }
}
// MARK: - Alerts
func testWhenStepsHit25Percent_milestoneNotificationGenerated() {
  // given
  sut.goal = 400
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: nil) { notification -> Bool in
    return notification.alert == Alert.milestone25Percent
  }

  // when
  sut.steps = 100

  // then
  wait(for: [exp], timeout: 1)
}
var steps: Int = 0 {
  didSet {
    updateForSteps()
  }
}
// MARK: - Updates due to distance
func updateForSteps() {
  guard let goal = goal else { return }
  if Double(steps) >= Double(goal) * 0.25 {
    AlertCenter.instance.postAlert(
      alert: Alert.milestone25Percent)
  }
}

Testing for multiple expectations

Your new milestone notification tests all seem pretty similar. This is an indicator that you should refactor them to reduce repeated code.

func givenExpectationForNotification(
  alert: Alert
) -> XCTestExpectation {
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: nil) { notification -> Bool in
    return notification.alert == alert
  }
  return exp
}
let exp = 
  givenExpectationForNotification(alert: .milestone25Percent)
func testWhenGoalReached_allMilestoneNotificationsSent() {
  // given
  sut.goal = 400
  let expectations = [
    givenExpectationForNotification(alert: .milestone25Percent),
    givenExpectationForNotification(alert: .milestone50Percent),
    givenExpectationForNotification(alert: .milestone75Percent),
    givenExpectationForNotification(alert: .goalComplete)
  ]

  // when
  sut.steps = 400

  // then
  wait(for: expectations, timeout: 1, enforceOrder: true)
}

Refining Requirements

The previous set of unit tests have one flaw when it comes to validating the app. They test a snapshot of the app’s state and do not consider that the app is dynamic.

// MARK: - Clearing Individual Alerts
func testWhenCleared_alertIsRemoved() {
  // given
  let alert = Alert("to be cleared")
  sut.postAlert(alert: alert)

  // when
  sut.clear(alert: alert)

  // then
  XCTAssertEqual(sut.alertCount, 0)
}
func clear(alert: Alert) {
  if let index = alertQueue.firstIndex(of: alert) {
    alertQueue.remove(at: index)
  }
}
func testWhenStepsIncreased_onlyOneMilestoneNotificationSent() {
  // given
  sut.goal = 10
  let expectations = [
    givenExpectationForNotification(alert: .milestone25Percent),
    givenExpectationForNotification(alert: .milestone50Percent),
    givenExpectationForNotification(alert: .milestone75Percent),
    givenExpectationForNotification(alert: .goalComplete)
  ]

  // clear out the alerts to simulate user interaction
  let alertObserver = AlertCenter.instance.notificationCenter
    .addObserver(
      forName: AlertNotification.name,
      object: nil,
      queue: .main) { notification in
        if let alert = notification.alert {
          AlertCenter.instance.clear(alert: alert)
        }
    }

  // when
  for step in 1...10 {
    self.sut.steps = step
    sleep(1)
  }

  // then
  wait(for: expectations, timeout: 20, enforceOrder: true)
  AlertCenter.instance.notificationCenter
    .removeObserver(alertObserver)
}
func givenExpectationForNotification(
  alert: Alert) -> XCTestExpectation {

  let exp = XCTNSNotificationExpectation(
    name: AlertNotification.name,
    object: AlertCenter.instance,
    notificationCenter: AlertCenter.instance.notificationCenter)
  exp.handler = { notification -> Bool in
    return notification.alert == alert
  }
  exp.expectedFulfillmentCount = 1
  exp.assertForOverFulfill = true
  return exp
}
// MARK: - Alerts
var sentAlerts: [Alert] = []
private func checkThreshold(percent: Double, alert: Alert) {
  guard !sentAlerts.contains(alert),
    let goal = goal else {
      return
  }
  if Double(steps) >= Double(goal) * percent {
    AlertCenter.instance.postAlert(alert: alert)
    sentAlerts.append(alert)
  }
}

func updateForSteps() {
  checkThreshold(percent: 0.25, alert: .milestone25Percent)
  checkThreshold(percent: 0.50, alert: .milestone50Percent)
  checkThreshold(percent: 0.75, alert: .milestone75Percent)
  checkThreshold(percent: 1.00, alert: .goalComplete)
}
sentAlerts.removeAll()

Using other types of expectations

The bulk of the time you’re testing asynchronous processes, you’ll use a regular XCTestExpectation. XCTNSNotificationExpectation covers most other needs. For specific uses, there are two other stock expectations: XCTKVOExpectation and XCTNSPredicateExpectation.

func expectTextChange() -> XCTestExpectation {
  return keyValueObservingExpectation(
    for: sut.startButton as Any,
    keyPath: "titleLabel.text")
}
let exp = expectTextChange()
wait(for: [exp], timeout: 1)

Challenge

This tutorial only scratched the surface of testing asynchronous functions. Here are some things to add to the app with test coverage:

Key points

  • Use XCTestExpectation and its subclasses to make tests wait for asynchronous process completion.
  • Test expectations help test properties of the asynchronicity, like order and number of occurrences, but XCTAssert functions should still be used to test state.

Where to go from here?

So much app code is asynchronous by nature—disk and network access, UI events, system callbacks, and so on. It’s important to understand how to test that code, and this chapter gives you a good start. Many popular 3rd party testing frameworks also have functions that make writing these types of tests easier. For example Quick+Nimble allows you to write an assert, expectation and wait in one line:

expect(alerts).toEventually(contain(alert1, alert2))
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