Last active
December 22, 2024 22:53
-
-
Save BrentMifsud/1c7232fb95c47463a037e73fecf09c80 to your computer and use it in GitHub Desktop.
Xcode UI Testing Helpers
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import XCTest | |
/// Bundle identifiers for apples native apps. Used to open other apps during UI testing. | |
/// | |
/// More can be found here: https://support.apple.com/en-ca/guide/deployment/depece748c41/web | |
enum AppleBundleIdentifiers: String { | |
case safari = "com.apple.mobilesafari" | |
case springboard = "com.apple.springboard" | |
} | |
extension XCUIApplication { | |
convenience init(appleBundleID: AppleBundleIdentifiers) { | |
self.init(bundleIdentifier: appleBundleID.rawValue) | |
} | |
} | |
extension XCTestCase { | |
/// Waits for the existence of a ui element before performing some action. | |
/// - Parameters: | |
/// - element: the `XCUIElement` to wait for | |
/// - timeout: The `TimeInterval` to wait for the `XCUIElement` existence | |
/// - optional: Whether the XCUIElement is optional or not. Use this for views that may or may not appear. | |
/// - onElementFound: closure that runs if the element is found | |
/// - onRequiredElementMissing: closure that runs when a non-optional element is not found. | |
func waitForExistance( | |
of element: XCUIElement, | |
timeout: TimeInterval = 2, | |
optional: Bool, | |
onElementFound: (XCUIElement) -> Void = { _ in }, | |
onRequiredElementMissing: (() -> Void)? = nil | |
) { | |
if element.waitForExistence(timeout: timeout) { | |
onElementFound(element) | |
} else if !optional { | |
XCTFail("Element: \(element) did not become visible in provided time.") | |
onRequiredElementMissing?() | |
} | |
} | |
/// ui interruption monitors are super unreliable for picking up alerts. Use springboard to intercept the alert. | |
/// | |
/// This method returns the monitor so that it can be safely disposed of after using `removeUIInterruptionMonitor(monitor:)` | |
/// - Parameters: | |
/// - alertPredicate: an NSPredicate for finding the alert dialog. | |
/// - timeout: the `TimeInterval` to wait for the alert to appear | |
/// - optional: Is the alert optional | |
/// - alertTrigger: the action that will trigger the alert to pop-up | |
/// - alertHandler: what to do with the alert | |
func handleSystemAlert( | |
alertPredicate: NSPredicate, | |
timeout: TimeInterval = 2, | |
optional: Bool, | |
alertTrigger: () -> Void, | |
alertHandler: (XCUIElement) -> Void | |
) { | |
alertTrigger() | |
let systemAlert = Springboard.springboard.alerts.matching(alertPredicate).element | |
if systemAlert.waitForExistence(timeout: timeout) { | |
alertHandler(systemAlert) | |
} else if !optional { | |
XCTFail(""" | |
Expected system alert with predicate: "\(alertPredicate.predicateFormat)" did not become visible within provided timeout | |
""") | |
} | |
} | |
/// Take a screenshot and add it as an attachement to the test case | |
/// - Parameter name: description to append ot the name of the screenshot | |
func takeScreenshot(named name: String) { | |
// Take the screenshot | |
let fullScreenshot = XCUIScreen.main.screenshot() | |
// Create a new attachment to save our screenshot | |
// and give it a name consisting of the "named" | |
// parameter and the device name, so we can find | |
// it later. | |
let screenshotAttachment = XCTAttachment( | |
uniformTypeIdentifier: "public.png", | |
name: "\(name)-\(UUID()).png", | |
payload: fullScreenshot.pngRepresentation, | |
userInfo: nil | |
) | |
// Usually Xcode will delete attachments after | |
// the test has run; we don't want that! | |
screenshotAttachment.lifetime = .keepAlways | |
// Add the attachment to the test log, | |
// so we can retrieve it later | |
add(screenshotAttachment) | |
} | |
/// Utilize safari to open a deeplink during a UI Test | |
func open(deepLink urlString: String, for app: XCUIApplication) { | |
openFromSafari("\(urlString)") | |
XCTAssert(app.wait(for: .runningForeground, timeout: 5)) | |
} | |
private func openFromSafari(_ urlString: String) { | |
let safari = XCUIApplication(appleBundleID: .safari) | |
safari.launch() | |
// Make sure Safari is really running before asserting | |
XCTAssert(safari.wait(for: .runningForeground, timeout: 5)) | |
// Type the deeplink and execute it | |
let firstLaunchContinueButton = safari.buttons["Continue"] | |
if firstLaunchContinueButton.exists { | |
firstLaunchContinueButton.tap() | |
} | |
safari.textFields["Address"].tap() | |
let keyboardTutorialButton = safari.buttons["Continue"] | |
if keyboardTutorialButton.exists { | |
keyboardTutorialButton.tap() | |
} | |
safari.typeText(urlString) | |
safari.buttons["go"].tap() | |
let openButton = safari.buttons["Open"] | |
_ = openButton.waitForExistence(timeout: 2) | |
if openButton.exists { | |
openButton.tap() | |
} | |
} | |
} | |
extension XCUIElement { | |
func labelContains(text: String) -> Bool { | |
let predicate = NSPredicate(format: "label CONTAINS %@", text) | |
return staticTexts.matching(predicate).firstMatch.exists | |
} | |
func clearText() { | |
guard let stringValue = self.value as? String else { | |
return | |
} | |
var deleteString = String() | |
for _ in stringValue { | |
deleteString += XCUIKeyboardKey.delete.rawValue | |
} | |
typeText(deleteString) | |
} | |
enum ScrollDirection { | |
case up | |
case down | |
case left | |
case right | |
} | |
/// Scrolls to a given `XCUIElement` and fails if it is not visible within the provided timeout. | |
/// - Parameters: | |
/// - direction: the direction to scroll | |
/// - element: the element to find | |
/// - timeout: how long to scroll before giving up | |
func scrollToElement(scrollDirection direction: ScrollDirection = .down, element: XCUIElement, timeout: TimeInterval = 5) { | |
let timeOutDate = Date() + timeout | |
while !element.visible() && Date() < timeOutDate { | |
switch direction { | |
case .down: | |
swipeUp() | |
case .up: | |
swipeDown() | |
case .left: | |
swipeRight() | |
case .right: | |
swipeLeft() | |
} | |
} | |
if !element.visible() { | |
XCTFail("Scrolling to element timed out. Element not visible.") | |
} | |
} | |
/// Returns whether the `XCUIElement` is visible on screen | |
func visible() -> Bool { | |
guard self.exists && !self.frame.isEmpty else { | |
return false | |
} | |
return XCUIApplication().windows.element(boundBy: 0).frame.contains(self.frame) | |
} | |
} | |
/// A singleton representing iOS's homescreen application. | |
/// | |
/// Can be used to perform automated tasks outside of your own app. | |
class Springboard { | |
static let springboard = XCUIApplication(appleBundleID: .springboard) | |
private init() {} | |
/// Delete the app via springboard | |
/// - Parameter appName: the name of your app as seen underneath your app icon on the home screen | |
/// | |
/// Note: Tested only on iOS 16. It SHOULD work on iOS 15 and potentially iOS 14 as well | |
class func deleteApp(named appName: String) { | |
XCUIApplication().terminate() | |
let appIcon = springboard.icons[appName] | |
if appIcon.waitForExistence(timeout: 1) { | |
// long press the app icon to reveal the context menu | |
appIcon.press(forDuration: 1.3) | |
// tap the remove app button from the context menu | |
springboard.buttons["Remove App"].tap() | |
// tap delete app button after the alert appears | |
let deleteAppButton = springboard.alerts.buttons["Delete App"] | |
if deleteAppButton.waitForExistence(timeout: 1) { | |
deleteAppButton.tap() | |
} else { | |
fatalError("Failed to delete app. Could not find Delete App Button") | |
} | |
// tap confirm delete button after the alert appears | |
let confirmDeleteButton = springboard.alerts.buttons["Delete"] | |
if confirmDeleteButton.waitForExistence(timeout: 1) { | |
confirmDeleteButton.tap() | |
} else { | |
fatalError("Failed to delete app. Could not find confirm deletion button") | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment