Wait for a Notification Before Continuing Swift
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.
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) -> ())? 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. In 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: NSObject { var expectation: XCTestExpectation? weak var button: UIButton? func observe(_ button: UIButton, expectation: XCTestExpectation) { self.expectation = expectation self.button = button button.addObserver(self, forKeyPath: "titleLabel.text", options: [.new], context: nil) } override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { expectation?.fulfill() } deinit { button?.removeObserver(self, forKeyPath: "titleLabel.text") } } 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.
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 setUp() { super.setUp() sut = loadRootViewController() } override func tearDown() { sut = nil super.tearDown() } // 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) -> ()) { 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) }
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
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)) 6. Dependency Injection & Mocks 4. Test Expressions
Source: https://www.raywenderlich.com/books/ios-test-driven-development-by-tutorials/v1.0/chapters/5-test-expectations
0 Response to "Wait for a Notification Before Continuing Swift"
Post a Comment