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

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.

To test caught and completed state changes that asynchronously update in AppModel, you’ll add a callback closure. The first step is to write the test!

Open AppModelTests.swift and add the following test under // MARK: - State Changes:

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)
}

This test updates the appState using sut.pause then checks that stateChangedCallback gets triggered and sets observedState to the new value. You are using a few new things in this test:

  1. expectation(description:) is an XCTestCase method that creates an XCTestExpectation object. The description helps identify a failure in the test logs. You’ll see shortly how expected is used to track if and when the expectation is fulfilled.
  2. fulfill() is called on the expectation to indicate it has been fulfilled - specifically, the callback has occurred. Here stateChangedCallback will trigger on sut when a state change occurs.
  3. wait(for:timeout:) causes the test runner to pause until all expectations are fulfilled or the timeout time (in seconds) passes. The assertion will not be called until the wait completes.

The test won’t compile, because stateChangedCallback doesn’t yet exist. Open AppModel.swift, add the following to the class:

var stateChangedCallback: ((AppModel) -> Void)?

Adding this property allows the test to build. Now run it, and you’ll see the following failure in the console:

Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "callback happened".

The expectation never got fulfilled, so the test failed after the 1 second wait timeout.

To fix it, change appState in AppModel to match the following:

private(set) var appState: AppState = .notStarted {
  didSet {
    stateChangedCallback?(self)
  }
}

The callback is now triggered each time AppState is set.

Back in AppModelTests.swift, clean up the callback reference by adding the following to the top of tearDownWithError:

sut.stateChangedCallback = nil

Run the test again, and now it will pass!

Note: It is best practice to always call fulfill in the completion block, then test for errors or other negative conditions using XCTAssert after the wait. Timeout should not be used to signal a test failure, as it adds significant time to the test.

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)
}

These tests observe the startButton title to confirm it properly updates after model state changes.

observe(_:expectation:) will fulfill the passed expectation (exp) when the textLabel of sut.startButton is updated. This requires the ButtonObserver helper class, which you’re about to create!

Add a new Swift File to the Test Classes group and name it ButtonObserver.swift. Replace the contents of the file with the following:

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()
  }
}

ButtonObserver observes a UIButton for changes to its titleLabel’s text by using Key-Value Observing. When the text changes, a callback is made to observeValue(forKeyPath:of:change:context:). This object holds on to the supplied XCTestExpectation and fulfills it in that callback.

Next, open StepCountControllerTests.swift add the following test helpers under // MARK: - When:

func whenCaught() {
  AppModel.instance.setToCaught()
}

func whenCompleted() {
  AppModel.instance.setToComplete()
}

Build and run the StepCountControllerTests tests, and you’ll see a couple failures in the console:

XCTAssertEqual failed: ("Optional("Pause")") is not equal to ("Optional("Try Again")")

XCTAssertEqual failed: ("Optional("Pause")") is not equal to ("Optional("Start Over")")

The button titles aren’t updating when whenCaught() and whenCompleted() are called in your test, because there aren’t yet any hooks in the production code to do this. Fix that by adding the following to viewDidLoad in StepCountController.swift:

AppModel.instance.stateChangedCallback = { model in
  DispatchQueue.main.async {
    self.updateUI()
  }
}

stateChangedCallback is now used to update the UI when appState is updated in the model. Now the tests will pass and you’re ready to move on.

Note: Stopping execution in the debugger doesn’t pause the wait timeout. You just added a bunch of code, and if there was a mistake you might go back and debug the problem. This is common when writing tests, especially when they do not behave as expected. When the debugger pauses at a breakpoint and you explore for the logic error, be mindful that the test will probably fail due to timeout. Simply disable or remove the breakpoint and re-run once the issue is corrected.

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.

In addition to fulfilling expectations in arbitrary callbacks, there is also a feature that allows the test to wait for User Notifications.

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.

Activity Tracker Data Model Alert Center Alert View Controller steps updated reached 50% of goal display alert toast

AlertCenter uses Notifications to communicate with the view controllers which handle the alerts on screen. Because this happens asynchronously, it’s a good case to test using XCTestExpectation.

A stub implementation of AlertCenter and AlertCenterTests have been added to the project to speed things up.

To test out the notification behavior, open AlertCenterTests.swift and add the following test:

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)
}

expectation(forNotification:object:handler:) creates an expectation that fulfills when a notification posts. In this case, when AlertNotification.name is posted to sut, the expectation is fulfilled. The test then posts a new Alert and waits for that notification to be sent.

Note that it’s not generally a good idea to use a wait as the test assertion. It’s better to use an explicit assert call. wait only tests that an expectation was fulfilled and does not make any claims about the app’s logic. You’ll test the contents of the notification a little later in this chapter.

Build and test, and this test will fail. If you look at the error in the console, you’ll see a timeout failure:

Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "Expect notification 'Alert' from FitNess.AlertCenter".

Time to implement the application code to fix this! Open AlertCenter.swift, replace the stub implementation of postAlert(alert:) with the following:

func postAlert(alert: Alert) {
  let notification = Notification(
    name: AlertNotification.name,
    object: self)
  notificationCenter.post(notification)
}

This creates and posts the Notification your test is listening for. Note that the passed alert isn’t used currently, but you’ll circle back to this later.

Build and test. And the test will pass! :]

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)
}

This creates two expectations waiting for AlertNotification.name, posts two different alerts, and waits for both alerts to notify.

Build and test, and it will pass. However, this test is a little naïve. To see how, delete this line:

sut.postAlert(alert: alert2)

Now you’re only posting one of the two alerts tied to expectations the wait requires.

Test again, and it will still pass! This is because the two expectations are expecting the same thing. They run in parallel—they don’t stack. So as soon as one alert is posted, both expectations are fulfilled.

To solve this conundrum, you can use notification expectation’s expectedFulfillmentCount property refine the fulfillment condition. Replace testPostingTwoAlerts_generatesTwoNotifications() with the following:

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)
}

Setting expectedFulfillmentCount to two means the expectation won’t be met until fulfill() has been called twice before the timeout.

Run the test, and you’ll see it fails because you only called postAlert once. This is good proof your test is working as expected!

In the when section, add back the second postAlert under sut.postAlert(alert: alert1):

sut.postAlert(alert: alert2)

Run the test again, and you’ll see it pass.

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.

And of course, you can test for this scenario. Add the following test:

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)
}

This is almost exactly like the last one, except for this line:

exp.isInverted = true

When an expectation is inverted it indicates this test fails if the expectation is fulfilled and succeeds if the wait times out. Put another way, this test will fail if two notifications are triggered by the two alerts.

Right now, the test fails because the application code currently allows multiple alerts to post.

Open AlertCenter.swift. Add the following instance variable:

private var alertQueue: [Alert] = []

The alertQueue will be an important part of AlertCenter. It will help manage a potentially large stack of messages for the user, as they can accumulate in the background.

Next add the following statements to the top of postAlert(alert:):

guard !alertQueue.contains(alert) else { return }

alertQueue.append(alert)

If the same alert is passed to postAlert(alert:) twice, the second one will be ignored.

Build and test again. All green!

Be sure to run all the tests from time to time to make sure fixes for one test don’t break another.

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.

Create a new Unit Test Case Class file in the App Layer folder, under Cases. Name it RootViewControllerTests.swift.

Add the following import:

@testable import FitNess

Next, replace the test boilerplate in the class with:

var sut: RootViewController!

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

override func tearDownWithError() throws {
  sut = nil
  try super.tearDownWithError()
}

Finally, add a test for the base condition: that is, when the view controller is loaded, there are no alerts showing:

// MARK: - Alert Container

func testWhenLoaded_noAlertsAreShown() {
  XCTAssertTrue(sut.alertContainer.isHidden)
}

Run this and confirm it passes.

Next, add the following to test that the alert container is shown when there is an alert:

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)
}

An expectation will be fulfilled by AlertNotification.name and postAlert(alert:) is called to ultimately trigger the notification. After waiting for the expectation, XCTAssertFalse checks the alertContainer is visible.

Now it’s time to get the test to pass by adding the code to show the alert. Go back to RootViewController.swift and add the following at the bottom of viewDidLoad:

AlertCenter.listenForAlerts { center in
  self.alertContainer.isHidden = false
}

AlertCenter.listenForAlerts(_:) is a helper method that you’ll create to register for alert notifications, and run the passed closure. The closure will unhide the alertContainer when triggered.

Open AlertCenter.swift, find the “class helpers” extension and add the following:

class func listenForAlerts(
  _ callback: @escaping (AlertCenter) -> Void
) {
  instance.notificationCenter
    .addObserver(
      forName: AlertNotification.name,
      object: instance,
      queue: .main) { _ in
    callback(instance)
    }
}

listenForAlerts(_:) adds AlertCenter as an observer for the AlertNotification.name notification that triggers the callback. This will result in alertContainer displaying in RootViewController.

Build and run your new test and it should now pass.

Continuous refactoring

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

That is because the sut state is tied to the running UIApplication and is preserved between runs. If testWhenAlertsPosted_alertContainerIsShown() runs first and displays the alert, it will still be there when testWhenLoaded_noAlertsAreShown() checks if any are displayed.

To resolve this issue, you’ll refactor the code and build a way to clear out all the alerts and reset the view between tests.

First, you need an interface to the state of AlertCenter. Add the following test to AlertCenterTests.swift:

// MARK: - Alert Count
func testWhenInitialized_AlertCountIsZero() {
  XCTAssertEqual(sut.alertCount, 0)
}

This means that AlertCenter needs an alertCount variable for the test to compile. Add the following property to the class in AlertCenter.swift:

var alertCount: Int {
  return alertQueue.count
}

Build and test testWhenInitialized_AlertCountIsZero() and you’ll see it now passes.

When adding new functionality, it’s important to cover the basic conditions as well. Add the following to AlertCenterTests.swift:

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)
}

testWhenAlertPosted_CountIsIncreased() tests that posting an alert increases the alertCount you added for the prior test.

testWhenCleared_CountIsZero() tests a new method, clearAlerts(), which you need to create. First, you’ll want to run it in tearDownWithError, by adding the following to the top of the method:

AlertCenter.instance.clearAlerts()

Because AppModelTests indirectly mess with DataModel state, they can also trigger alerts that need to be cleared. Open AppModelTests.swift, add the following to the top of tearDownWithError:

AlertCenter.instance.clearAlerts()

This ensures the state of AlertCenter is reset after each test that modifies it. Open AlertCenter.swift, add the following to AlertCenter:

// MARK: - Alert Handling

func clearAlerts() {
  alertQueue.removeAll()
}

This allows you to remove all alerts from alertQueue, which can be used to solve your issues with persisted alerts between tests. But first, there is one more place you need to use your new alertCount.

Go back to RootViewController.swift and change the listenForAlerts callback block in viewDidLoad to:

self.alertContainer.isHidden = center.alertCount == 0

Now when an alert is triggered, you display alertContainer only if more than one alert is currently present. Are you dizzy yet? With TDD, adding functionality requires looping back and forth between the application and tests code.

Finally, you can fix the broken testWhenLoaded_noAlertsAreShown by adding to the top of tearDownWithError in RootViewControllerTests.swift:

AlertCenter.instance.clearAlerts()

Now alertQueue will clear after each test, preventing tests that modify the queue from impacting each other.

With the count reset, you just need to clear any existing alerts at the start of each test to avoid the persistence issue you observed in testWhenLoaded_noAlertsAreShown(). Add the following to the bottom of setUpWithError:

sut.reset()

Now all the tests will pass, regardless of execution order.

If you want to see the alert view in practice, temporarily replace startStopPause(_:) in StepCountController.swift with the following:

@IBAction func startStopPause(_ sender: Any?) {
  let alert = Alert("Test Alert")
  AlertCenter.instance.postAlert(alert: alert)
}

Now it’ll display an alert for any state change. Build and run. When the app loads tap Start.

Error: This image is missing a width attribute

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

The image has been hidden until this issue is resolved.

For now undo those changes and move on for more expectation testing.

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.

In particular, it will be useful to add the associated Alert to the notification’s userInfo.

Open AlertCenterTests.swift and add the following to AlertCenterTests:

// 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")
}

In addition to using a notification expectation, this test also sets up an additional listener for an AlertNotification. In the observation closure, the Alert that is expected to be in the userInfo is stored so it can be compared in the test assert.

Note: While you should strive for a single assert per test, it’s OK to have more than one if they both confirm the same truth. In this case, you’re trying to validate that the notification contains the same Alert object that was posted. Checking that the notification’s alert isn’t nil is part of that validation, as is comparing it to the posted alert.

To get this test to pass, you have to add the alert object to the notification. Open AlertCenter.swift change the let notification = ... line in postAlert(alert:) to:

let notification = Notification(
  name: AlertNotification.name,
  object: self,
  userInfo: [AlertNotification.Keys.alert: alert])

This adds the posted alert object to the notification so it can be observed in the test’s closure. Now run testNotification_whenPosted_containsAlertObject() and you should see another green test.

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.

To start off on a positive note, encourage the user by giving them alerts at certain milestones. When they reach 25%, 50%, and 75% of the goal, they should see an encouragement alert, and at 100% a congratulations alert.

There are already some hard coded values for these in an Alert extension.

Before writing the next set of tests, create a new helper file. Under the Test Extensions group add a new group, Alerts. Then add a new Swift file named Notification+Tests.swift.

Add the following code to the new file, below the Foundation import:

@testable import FitNess

extension Notification {
  var alert: Alert? {
    return userInfo?[AlertNotification.Keys.alert] as? Alert
  }
}

This helper extension will make it easier to get the Alert object out of the notification. You can be fairly confident this works because testNotification_whenPosted_containsAlertObject() tested similarly built userInfo. You could also go back and update that test to use this new helper. TDD For The Win!

Now you can start writing tests to check that milestone notifications are generated.

Open DataModelTests.swift add the following test to the end of DataModelTests:

// 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)
}

In this test, the optional handler closure is used when setting up the expectation. The closure takes the Notification as input and returns a Bool indicating whether or not the expectation should be fulfilled. Here you only fulfill the expectation when the alert is a .milestone25Percent. With the goal set to 400, setting steps to 100 should trigger that alert and fulfill your expectation.

To make this pass, you’ll need to update DataModel to trigger the 25 percent alert when appropriate.

First open DataModel.swift. Next, replace the steps var with the following:

var steps: Int = 0 {
  didSet {
    updateForSteps()
  }
}

Now changes in the step count will trigger updateForSteps(), which will post necessary milestone alerts.

Finally, add the following method below restart():

// 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)
  }
}

Now when steps hit 25% of the goal, you post Alert.milestone25Percent. Build and test testWhenStepsHit25Percent_milestoneNotificationGenerated() and it will pass when the alert is generated.

Previous tests let you know that because the alert is generated it will be shown to the user. You’ll have to wait for the next chapter to see the actual step counter in action.

On your own, add three more tests: one each for 50%, 75%, and 100% of completion with a goal of 400:

  • 50%: Use Alert.milestone50Percent and steps = 200 for the when condition.
  • 75%: Use Alert.milestone75Percent and steps = 300 for the when condition.
  • 100%: Use Alert.goalComplete and steps = 400 for the when condition.

Duplicate the if statement in updateForSteps for each of these conditions to get the tests to pass. With these separate if statements, updateForSteps will post all alerts up to the current threshold when triggered; you shouldn’t address that issue yet. You’ll also need to add AlertCenter.instance.clearAlerts() to the test’s tearDownWithError to flush out the alert queue each time.

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.

Still in DataModelTests.swift, add a new method under // MARK: - Given:

func givenExpectationForNotification(
  alert: Alert
) -> XCTestExpectation {
  let exp = expectation(
    forNotification: AlertNotification.name,
    object: nil) { notification -> Bool in
    return notification.alert == alert
  }
  return exp
}

This helper method creates an expectation that waits for a notification containing the passed alert. Next, refactor testWhenStepsHit25Percent_milestoneNotificationGenerated() to use this helper. Replace the expectation definition with the following:

let exp = 
  givenExpectationForNotification(alert: .milestone25Percent)

Do the same for the other three milestone tests.

Now you can write a test that checks that all of these alerts are generated, each in order.

Add the following test to DataModelTests:

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)
}

So far you’ve been using wait(for:timeout:) with an array of just one expectation. Here you can see why accepting an array is useful. It allows you to provide multiple expectations and wait for all of them to be fulfilled.

Also shown here is the optional enforceOrder parameter. This makes sure not only that all the expectations are fulfilled but that those fulfillments happen in the order specified by the input array.

The ordering check allows for sophisticated tests. For example, you could use this when writing a test for a multi-step process like image filtering or a network login that requires multiple API calls (like OAuth or SAML). These tests not only ensure all the steps happen in the necessary order in production code, but also validate that your test code isn’t going through a different flow than expected.

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.

When in progress, the app will continually update the step count, and it’s important to not spam the user at each step, but instead only alert them when a threshold is first crossed. In addition, the user has the option to clear the alerts, so the guard added to postAlert(alert:) won’t prevent a repeat alert if an earlier alert was cleared by the user.

Always testing first, open AlertCenterTests.swift and add this to the bottom of AlertCenterTests:

// 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)
}

This tests that if an alert is added and then cleared, there are no alerts left in the AlertCenter.

To pass the test, add the following method to the “Alert Handling” section of AlertCenter.swift:

func clear(alert: Alert) {
  if let index = alertQueue.firstIndex(of: alert) {
    alertQueue.remove(at: index)
  }
}

This removes the passed alert from the alertQueue. Run your tests and they should all pass again.

Next, open DataModelTests.swift and add the following:

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)
}

This is your busiest test yet, and it has a few parts:

  • The given section sets up a sequence of milestone alert expectations.
  • A separate observer watches for alerts and clears them from the AlertCenter. This ensures that repeated notifications don’t get ignored because they haven’t yet been dismissed by the user.
  • The when section increments steps to generate the alerts by crossing a series of the milestones individually. Using sleep or equivalent in tests should only be done sparingly as this drastically increases the test time. It’s necessary here to give time for the notifications to post and be cleared.
  • The then section uses wait to test that the expectations are fulfilled as expected. At the end of the test, you remove alertObserver to prevent it from impacting other tests.

Right now the test will pass, which violates the TDD step of writing a failing test first. That’s because right now it’s not enforcing that there should be a single notification per milestone. That has to be done in the expectation itself.

Still in DataModelTests.swift, replace givenExpectationForNotification(alert:) with the following:

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
}

This ditches the convenience method in order to create an XCTNSNotificationExpectation, which is a XCTestExpectation with more notification specific features. You set the expectedFulfillmentCount and assertForOverFulfill which will generate an assertion if the expectation is fulfilled more than the count.

Now the test will fail as a single alert is repeated for multiple steps. To get the test to pass, DataModel has to be modified to keep track of sent alerts.

Open DataModel.swift and add the following to the top of the class:

// MARK: - Alerts
var sentAlerts: [Alert] = []

Next, replace updateForSteps() with the following:

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)
}

This cleans up the code a little bit and now checks not just that the threshold was crossed but also that an alert wasn’t already sent. This way if a user crosses a threshold and dismisses the alert, they won’t see that same alert again.

Finally, add the following to the end of restart():

sentAlerts.removeAll()

This ensures that a restart clears out your alerts. Build and run, and the tests should all pass!

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.

These look for their eponymous conditions: KVO expectations observe changes to a keyPath and predicate expectations wait for their predicate to be true.

There’s one place where you’ve already used KVO for an expectation, and that’s with the ButtonObserver found in StepCountControllerTests.swift. You can replace that helper class completely using a KVO based XCTestExpectation. Rather than using the more fully featured XCTKVOExpectation, you’ll use a special XCTestExpectation initializer that provides KVO capabilities.

Delete ButtonObserver.swift. Next, open StepCountControllerTests.swift and add this method in the given section:

func expectTextChange() -> XCTestExpectation {
  return keyValueObservingExpectation(
    for: sut.startButton as Any,
    keyPath: "titleLabel.text")
}

This helper creates an expectation on startButton that observes the keyPath titleLabel.text. The same keyPath was used in the old ButtonObserver. This method accepts an optional handler block where you would check the observation to see if it meets the expectation. For these tests, only the first change needs to be observed, so you don’t supply the handler to filter fulfillment.

Next, in testController_whenCaught_buttonLabelIsTryAgain() and testController_whenComplete_buttonLabelIsStartOver() replace the let exp = ... and two observer lines with the following:

let exp = expectTextChange()

And change the waitForExpectations lines to:

wait(for: [exp], timeout: 1)

Build and test and the tests will pass as if nothing happened!

Challenge

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

  • Add AlertCenter tests addressing edge cases for clearing alerts such as clearing an empty queue and clearing the same alert multiple times.

  • Create tests for AlertViewController. Test that the text used for alertLabel’s updates to reflect a new alert, and that it uses the proper color for the given severity. This requires adding the ability to get the first alert out of the AlertCenter, and updating tests around that as well.

  • It wouldn’t be fair to the user if they didn’t get a warning of Nessie’s progress. Add tests in DataModelTests for Nessie catching up to 50% and then to 90%.

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))

Alternatively if your app uses a framework like RxSwift then you can use their RxBlocking and RxTest frameworks. See this tutorial for more information.

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.
© 2026 Kodeco Inc.