Automated permissions testing

Permissions testing via an extended XCUIApplication, ready to interact with iOS SpringBoard.

Previously we’ve covered deeplinks with automated tests. Let’s now do the same for permissions. We’ll only showcase doing so for notifications, but the same would apply to others: camera, photos, location, calendar, reminders, microphone, etc.

Prerequisites

First we need to empower UI testing with ability to interact with SpringBoard - and thus be able to delete the app-under-test between test cases.

Download private headers XCUIApplication.h and XCUIElement.h from WebDriverAgent. Then add a bridge header file, we’ll name it Bridge.h, that imports the downloaded headers:

// Bridge.h
#import "XCUIApplication.h"
#import "XCUIElement.h"

For the test-target, set Objective-C Bridging Header to ${ROOT}/<MyAppTests>/.../Bridge.h:

Bridging Header setting

Now we start using the newly gotten private APIs:

import XCTest

final class Springboard {
  static let springboard = XCUIApplication(privateWithPath: nil, bundleID: "com.apple.springboard")!

Add a helper that deletes our app-under-test:

class func deleteMyApp() {
  XCUIApplication().terminate()

  // Resolve the query for the SpringBoard rather than launching it
  springboard.resolve()

  let icon = springboard.icons["MyApp"]
  XCTAssert(icon.isHittable)

  // Bring up the little "X" button to delete the app
  icon.press(forDuration: 1.3)

  // The little "X" button is, it seems, not exposed directly
  let xButtonCoordinate = CGVector(dx: (icon.frame.minX + 3) / springboard.frame.maxX,
                                   dy: (icon.frame.minY + 3) / springboard.frame.maxY)
  // Delete the app
  springboard.coordinate(withNormalizedOffset: xButtonCoordinate).tap()
  springboard.alerts.buttons["Delete"].tap()

  // Stop icons from jiggling
  XCUIDevice.shared().press(.home)
}

(Sidenote: why class rather than static? First one is 1 character shorter. Joking aside, it’s a toss-up since Springboard class is final.)

We also don’t have to hardcode the app name and can replace above "MyApp" with below myAppName:

private class var myAppName: String {
  // XCUIApplication().path example:
  // "/Users/{name}/Library/Developer/Xcode/DerivedData/MyApp-{someID}/Build/Products/Debug-iphonesimulator/MyApp.app"
  let appName = XCUIApplication().path
    .components(separatedBy: "/").last!
    .components(separatedBy: ".").dropLast()
  return Array(appName).first!
}

Finally, verify everything works thus far by calling Springboard.deleteMyApp() in, say, test class’s setUp(). Here’s how it should look like:

SpringBoard automated app delete

Permissions testing

We now have all prerequisites for permissions testing. With the private XCUIApplication(privateWithPath:bundleID:) constructor at our disposal, we call it with "com.apple.Preferences" as the bundleID:. It returns us a handle on Settings.app, and with it we get to reproducibly set our app’s notification settings and then verify that the app responds accordingly.

final class Springboard {
  static let springboard = XCUIApplication(privateWithPath: nil, bundleID: "com.apple.springboard")!
  static let settings = XCUIApplication(privateWithPath: nil, bundleID: "com.apple.Preferences")!  static let settingsIcon = springboard.icons["Settings"]

First add a helper function:

private class func openSettings() {
  // Make sure home screen is frontmost
  XCUIApplication().terminate()

  // Go to SpringBoard's first screen
  XCUIDevice.shared().press(.home)
  Thread.sleep(forTimeInterval: 0.5)
  XCTAssert(settingsIcon.isHittable)

  // Launch Settings.app from a known state
  settings.terminate()

  settingsIcon.tap()
}

Finally, notifications. These are slightly more tricky, as, besides notifications being ON or OFF, we also have to consider the 3rd state, when the app hasn’t yet requested notifications permissions and thus might not be listed in Settings. Taking this aside for now, here’s how to turn notifications ON or OFF:

enum NotificationsState {
  case on, off
}

class func turnNotifications(_ desiredState: NotificationsState) {
  openMyAppSectionInSettings()
  settingsTable.cells.staticTexts["Notifications"].tap()

  var currentState: NotificationsState {
    let soundsCell = settingsTable.cells.staticTexts["Sounds"]
    return soundsCell.exists && soundsCell.isHittable
      ? .on
      : .off
  }

  if (desiredState == .on && currentState == .off)
    || (desiredState == .off && currentState == .on) {
    let allowNotificationsSwitch = settings.switches.element(boundBy: 0)
    allowNotificationsSwitch.tap()
  }
}

See the sample project which puts the above together. Also note that not all permissions require deletion of the app; e.g. location permissions can be reset in Settings > General:

class func resetLocationAndPrivacy() {
  openSettings()

  settings.tables.staticTexts["General"].tap()
  settings.tables.staticTexts["Reset"].tap()
  settings.tables.staticTexts["Reset Location & Privacy"].tap()
  settings.buttons["Reset Warnings"].tap()
  settings.terminate()
}

But Wait, There’s More!

Not only Settings, we can interact with other Apple’s apps as well - below are some of the bundle IDs.

App Name	| Bundle ID
---------------------------------
App Store	| com.apple.AppStore
Calculator	| com.apple.calculator
Calendar	| com.apple.mobilecal
Camera		| com.apple.camera
Clock		| com.apple.mobiletimer
Compass		| com.apple.compass
Contacts	| com.apple.MobileAddressBook
FaceTime	| com.apple.facetime
Files       | com.apple.DocumentManager # iOS 11+
iCloud Drive| com.apple.iclouddriveapp  # iOS 10
Find Friends| com.apple.mobileme.fmf1
Find iPhone	| com.apple.mobileme.fmip1
Health		| com.apple.health
Home		| com.apple.home
iBooks		| com.apple.ibooks
iTunes Store| com.apple.mobilestore
Mail		| com.apple.mobilemail
Maps		| com.apple.Maps
Messages	| com.apple.mobilesms
Music		| com.apple.Music
News		| com.apple.news
Notes		| com.apple.mobilenotes
Phone		| com.apple.mobilephone
Photos		| com.apple.mobileslideshow
Podcasts	| com.apple.podcasts
Reminders	| com.apple.reminders
Safari		| com.apple.mobilesafari
Settings	| com.apple.Preferences
Siri        | com.apple.SiriViewService
Stocks		| com.apple.stocks
Tips		| com.apple.tips
TV			| com.apple.tv
Videos		| com.apple.videos
Voice Memos	| com.apple.voicememos
Wallet		| com.apple.passbook
Watch		| com.apple.bridge
Weather		| com.apple.weather