Handling Alert Interruptions in iOS UI Tests
Unexpected system alerts can interrupt your test from interacting with UI elements, causing failures to occur. Here are some scenarios to consider:
A Background Operation Failed
The app under test presents an alert using UIAlertController
because a background task failed.
iOS System Notifications & Alerts
The iCloud account associated to your test device is running out of cloud storage space, causing warning alerts to popup at random intervals.
Accessing User Data and Resources
The app under test is performing an action that requires a system permission to be granted. For example, if the app requires access to location data or photo content.
To gracefully handle these scenarios, we can add a UI interruption monitor to our XCUITests. In this concise guide, I'll explain how to do this.
Contents of this Guide
- Adding a UI Interruption Monitor
- Demonstration Example: Apple Maps
- Registering a UI Interruption Monitor
- Unregistering a UI Interruption Monitor
- Demonstration Example: Apple Maps (Continued)
- Quirky Edge Case
- Updating our UI Interruption Monitor Handler
Adding a UI Interruption Monitor
XCTest has an instance method called addUIInterruptionMonitor()
that we can use to monitor and react to system alerts. Here is its method declaration:
func addUIInterruptionMonitor(withDescription handlerDescription: String, handler: @escaping (XCUIElement) -> Bool) -> NSObjectProtocol
handlerDescription
takes an arbitrary string that we can provide for debugging purposes to describe what alert we intend to handle.handler
accepts a closure that will be executed once an alert blocks a touch action. The closure is a function that takes anXCUIElement
as input (representing the top level UI element on the screen) and returns a boolean value. We'll use the closure to craft custom logic on how we want to handle the alert interruption and returntrue
if our logic handled the situation orfalse
if it did not.- The return value is a
NSObjectProtocol
, which is an identifier token we can optionally use to unregister the handler. I'll explain this in more detail in the following section.
Demonstration Example: Apple Maps
To demonstrate this in action, I'd like to share a reproducible example that you can copy and run from an XCUITest with an iPhone Simulator as the target:
import XCTest
class AppleMapsUITests: XCTestCase {
let app = XCUIApplication(bundleIdentifier: "com.apple.Maps")
override func setUpWithError() throws {
// Reset the location permission before starting the test case for debugging purposes
app.resetAuthorizationStatus(for: .location)
continueAfterFailure = false
}
override func tearDownWithError() throws {
app.terminate()
}
func testOpenAppleMaps() throws {
app.launch()
// Add a UI Interruption Monitor
let locationDialogMonitor = addUIInterruptionMonitor(withDescription: "Location Permission Alert") { (alertElement) -> Bool in
for i in 0..<alertElement.buttons.count - 1 {
let buttonElement = alertElement.buttons.element(boundBy: i)
print("Button Label: \(buttonElement.label)")
}
return true
}
// Performing an arbitrary action in the Maps app
// This action will get blocked by the permission alert, triggering our UI Interruption Monitor
app.swipeUp()
// Remove the UI Interruption Monitor
self.removeUIInterruptionMonitor(locationDialogMonitor)
}
}
I'll summarize the demonstration code above:
- This test will launch the Apple Maps app, which will prompt for location permission to be granted once it launches.
- In
setUpWithError()
we are revoking the existing authorization for the location permission before Apple Maps launches to ensure we see the location permission alert each time the test is run. - In
testOpenAppleMaps()
we register a UI Interruption Monitor for handling the Location Permission Alert on screen, which runs a closure once our test tries to interact with something that is blocked by an interruption. In this case the interaction that is blocked is the attempt to swipe up. The closure prints out the button label values in the alert, which are Allow Once, Allow While Using App, and Don’t Allow. This corroborates what we see onscreen during the test:
- At the end of our test we remove the UI Interruption Monitor.
If you run the code example above, you will see that the label values of the buttons in the alert are printed to the console. However, the test ultimately fails. This behavior is correct, as we have not dismissed the alert yet. We will address this momentarily. But before we do, I'd like to explain the register and unregister part of the test.
Registering a UI Interruption Monitor
When you call addUIInterruptionMonitor()
from your code it gets pushed to an internal stack managed by XCTest. Once an interruption occurs onscreen that blocks a UI interaction in your test, the internal stack will pop each monitor from the stack and execute it.
Note that this is last-in, first-out (LIFO) order. Therefore, the last UI Interruption Monitor you declare will be tried first. If a UI Interruption Monitor returns false
, the next monitor item on the stack will be popped off and executed, until the stack is empty.
Unregistering a UI Interruption Monitor
XCTest will remove all remaining UI Interruption Monitors on its internal stack when the test completes, so it's not a requirement to explicitly remove it. However, in test scenarios where you know the number of times specific alerts can potentially appear, it makes sense to remove their corresponding monitors once they have been handled.
We can remove a UI Interruption Monitor by calling removeUIInterruptionMonitor()
and providing a monitor's identifier token, which is the return value of calling addUIInterruptionMonitor()
. For context, here is the method declaration of removeUIInterruptionMonitor()
:
func removeUIInterruptionMonitor(_ monitor: NSObjectProtocol)
Demonstration Example: Apple Maps (Continued)
Quirky Edge Case
There's a quirky edge case in the demonstration example that I'd like to focus on our attention on. Copy the button label names printed to the console from the test and paste them in a text editor:
Button Label: Allow Once
Button Label: Allow While Using App
Button Label: Don’t Allow
For Don’t Allow, notice that the apostrophe is a right single quotation mark ’
(Unicode: U+2019) and not the usual apostrophe character '
(Unicode: U+0027) we type on our keyboard. Therefore, if we wanted to tap on Don’t Allow, we would use:
alertElement.buttons["Don’t Allow"].tap()
Updating our UI Interruption Monitor Handler
Now that we understand the trick for selecting Don't Allow, we will update our handler closure to tap on it. First, let's update our handler logic to first check if the alert dialog contains the string, "to use your location?" before tapping on Don't Allow. A convenient way to perform this is to create a helper function for checking if a label contains a particular text string as an extension to XCUIElement outside of our test case:
extension XCUIElement {
func labelContains(text: String) -> Bool {
let predicate = NSPredicate(format: "label CONTAINS %@", text)
return staticTexts.matching(predicate).firstMatch.exists
}
}
- This helper function uses a predicate string to define the conditions for searching the text string. I recommend taking a look at this cheat sheet to get the practical gist.
- The predicate string in our helper function is straightforward. We check to see if the label contains our text.
%@
is a format specifier placeholder, which will be the String object we pass in.
Now update testOpenAppleMaps()
to the following:
func testOpenAppleMaps() throws {
app.launch()
// Register a UI Interruption Monitor
let locationDialogMonitor = addUIInterruptionMonitor(withDescription: "Location Permission Alert") { (alertElement) -> Bool in
let partialPermissionMessage = "to use your location?"
if alertElement.labelContains(text: partialPermissionMessage) {
alertElement.buttons["Don’t Allow"].tap()
return true
}
return false
}
// Performing an arbitrary action in the Maps app
app.swipeUp()
// Unregister the UI Interruption Monitor
self.removeUIInterruptionMonitor(locationDialogMonitor)
}
The demonstration example should now look like:
import XCTest
extension XCUIElement {
// Helper for checking if a label contains a particular string
func labelContains(text: String) -> Bool {
let predicate = NSPredicate(format: "label CONTAINS %@", text)
return staticTexts.matching(predicate).firstMatch.exists
}
}
class AppleMapsUITests: XCTestCase {
let app = XCUIApplication(bundleIdentifier: "com.apple.Maps")
override func setUpWithError() throws {
// Reset the location permission before starting the test case for debugging purposes
app.resetAuthorizationStatus(for: .location)
continueAfterFailure = false
}
override func tearDownWithError() throws {
app.terminate()
}
func testOpenAppleMaps() throws {
app.launch()
// Register a UI Interruption Monitor
let locationDialogMonitor = addUIInterruptionMonitor(withDescription: "Location Permission Alert") { (alertElement) -> Bool in
let partialPermissionMessage = "to use your location?"
if alertElement.labelContains(text: partialPermissionMessage) {
alertElement.buttons["Don’t Allow"].tap()
return true
}
return false
}
// Performing an arbitrary action in the Maps app
app.swipeUp()
// Unregister the UI Interruption Monitor
self.removeUIInterruptionMonitor(locationDialogMonitor)
}
}
Now if we run our test, the location alert is gracefully handled, allowing the swipe up action to occur uninterrupted.
Member discussion