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.
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:
-
expectation(description:)is anXCTestCasemethod that creates anXCTestExpectationobject. Thedescriptionhelps identify a failure in the test logs. You’ll see shortly howexpectedis used to track if and when the expectation is fulfilled. -
fulfill()is called on the expectation to indicate it has been fulfilled - specifically, the callback has occurred. HerestateChangedCallbackwill trigger onsutwhen a state change occurs. -
wait(for:timeout:)causes the test runner to pause until all expectations are fulfilled or thetimeouttime (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
fulfillin the completion block, then test for errors or other negative conditions usingXCTAssertafter thewait. 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
waittimeout. 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.
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
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
Alertobject 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.milestone50Percentandsteps = 200for the when condition. - 75%: Use
Alert.milestone75Percentandsteps = 300for the when condition. - 100%: Use
Alert.goalCompleteandsteps = 400for 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
stepsto generate the alerts by crossing a series of the milestones individually. Usingsleepor 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
waitto test that the expectations are fulfilled as expected. At the end of the test, you removealertObserverto 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
AlertCentertests 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 foralertLabel’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
DataModelTestsfor Nessie catching up to 50% and then to 90%.
Key points
- Use
XCTestExpectationand 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
XCTAssertfunctions 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.