UI Testing Deeplinks and Universal Links in iOS

UI Testing Deeplinks and Universal Links in iOS

Did you know it's possible to terminate your app in the middle of an XCUITest and launch it again from somewhere else? Let's see how to use this trick to test that deeplinks and universal links are properly launching our app when executed from Safari or iMessage.

It's tricky to test iOS features because while you can write unit tests to guarantee that your abstraction of it works, you can't really unit test that iOS will correctly call what you think will be called. In the case of deeplinks, what iOS does differs depending on what's the current state of your app (closed or in background) and which delegates you support (AppDelegate versus SceneDelegates), which commonly leads to very confusing bug reports in the point of view of the developer who isn't aware of this fact.

But unless you're for some reason not supporting iOS 11 in 2020, you can perfectly test "app launch" related features and any other AppDelegate/SceneDelegate related feature through UI Tests. This is because it was in iOS 11 where XCUI started supporting the ability to launch and control system apps. Today, we can make a test that boots Safari, types an URL and deeplinks back to our app. We can even terminate our app (which doesn't stop the test!) to check that our app behaves correctly if it's launched from said deeplink!

UI Testing Deeplinks (from a backgrounded app)

To begin, let's UI test a Safari deeplink when our app is already running in the background.

Launching other apps in the middle of a UI test is similar to launching our own, with the simple difference that you pass a different bundle identifier instead. In the case of Safari, the bundle identifier is "com.apple.mobilesafari":

func testDeeplinkFromSafari() {
    let app = XCUIApplication()
    app.launch()
    let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
    safari.launch()
}

If you run this, you'll get a simple test that launches your app and switches to Safari right after.

Now, to deeplink back to our app, we can control Safari just like we would in a regular UI test. In this case, we can grab a hold of the address bar, type our link and press the "go" button. If everything works correctly Safari will deeplink back to our app, allowing us to assert that the deeplink logic in our app is working as expected.

func testDeeplinkFromSafari() {
    // Launch our app
    let app = XCUIApplication()
    app.launch()
    // Launch Safari and deeplink back to our app
    openFromSafari("swiftrocks://profile")
    // Make sure Safari properly switched back to our app before asserting
    XCTAssert(app.wait(for: .runningForeground, timeout: 5))
    // Assert that the deeplink worked by checking if we're in the "Profile" screen
    XCTAssertTrue(app.navigationBars["Profile"].exists)
}

private func openFromSafari(_ urlString: String) {
    let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
    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.buttons["URL"].tap()
    let keyboardTutorialButton = safari.buttons["Continue"]
    if keyboardTutorialButton.exists {
        keyboardTutorialButton.tap()
    }
    safari.typeText(urlString)
    safari.buttons["Go"].tap()
    _ = confirmationButton.waitForExistence(timeout: 2)
    if confirmationButton.exists {
        confirmationButton.tap()
    }
}

It's good to add the additional wait(for: .runningForeground) assertion for safety as that makes the test check that the app switching actually worked before attempting to assert anything else. If it fails for some reason, you'll know it was because the app failed to switch instead of something not being present in the UI of your app.

You may also notice that there's some additional logic in our Safari handling; Safari sometimes shows a "What's new" screen, which we treat by first finding and tapping the "Continue" button, if it exists, which can also happen when opening the keyboard for the same time. Additionally, when executing deeplinks you might sometimes get a "Open in X?" confirmation, which is treated by finding and tapping the "Open" button.

UI Testing Deeplinks (that launches the app / from a killed app)

The issue I faced that prompted me to write this article is that iOS processes deeplinks differently according to the current state of the app. For example, in SceneDelegates, deeplinks will trigger your scene(_:openURLContexts:) method, but if the app is launched as a result of the deeplink, no method is called. Instead, you need to access it from the urlContexts property of your scene. Thus, when UI testing, you also need to have a test that operates on an app that is not running.

One may think that a UI test would fail if your app terminates, but that's actually not the case! You can make a test that terminates and reboots an app as much as you like by using these special methods from XCUIApplication:

app.launch() // Launches the app (or reboots it/launches it again)
app.terminate() // Terminates the app (which does not stops the test!)
app.activate() // Puts the app in the foreground, if it was backgrounded

As mentioned, if you terminate the app, you're free to launch it again in the same test. It's not necessary to reassign your XCUIApplication instance -- all assertions will work normally as if the app was never terminated in the first place.

Thus, to test that our deeplinks work correctly when the app isn't launched, we can simply close the app before opening Safari. It's not necessary to launch it again, as that will happen naturally as iOS attempts to open our deeplink.

func testDeeplinkFromSafari_fromBackgroundedApp() {
    openSafariDeeplink(terminateFirst: false)
}

func testDeeplinkFromSafari_thatLaunchesTheApp() {
    openSafariDeeplink(terminateFirst: true)
}

func openSafariDeeplink(terminateFirst: Bool) {
    let app = XCUIApplication()
    app.launch()

    if terminateFirst {
        app.terminate()
    }

    // Launch Safari and deeplink back to our app
    openFromSafari("swiftrocks://profile")
    // Make sure Safari properly switched back to our app before asserting
    XCTAssert(app.wait(for: .runningForeground, timeout: 5))
    // Assert that the deeplink worked by checking if we're in the "Profile" screen
    XCTAssertTrue(app.navigationBars["Profile"].exists)
}

An alternative for this is to simply never call app.launch(), but personally I had mixed results with it. Launching the app also installs it, so never launching it resulted in flaky tests. Launching and terminating it however works 100% of the time.

UI Testing Universal Links (from a backgrounded app)

The testing process of universal links is very similar to the deeplinks' one, with an important difference: for some god knows why reason, universal links don't work in the simulator's Safari. It's unclear if that's on purpose or if it's really a bug, but while universal links work fine on your device's Safari, they will not work on the simulator's one.

This means we unfortunately can't use our Safari wrapper for them, but luckily you can still test universal links by using the Messages app. We can then test our universal links by opening the Messages app, clicking on a contact, sending them a universal link and tapping the newly sent message's link bubble to trigger it.

To launch Messages, we use the bundle identifier "com.apple.MobileSMS".

private func openFromMessages(_ urlString: String) {
    let messages = XCUIApplication(bundleIdentifier: "com.apple.MobileSMS")
    messages.launch()
    XCTAssert(messages.wait(for: .runningForeground, timeout: 5))

    // Dismiss "What's New" if needed
    let continueButton = messages.buttons["Continue"]
    if continueButton.exists {
        continueButton.tap()
    }
    // Dismiss iOS 13's "New Messages" if needed
    let cancelButton = messages.navigationBars.buttons["Cancel"]
    if cancelButton.exists {
        cancelButton.tap()
    }

    // Open the first available chat
    let chat = messages.cells.firstMatch
    XCTAssertTrue(chat.waitForExistence(timeout: 5))
    chat.tap()
    // Tap the text field
    messages.textFields["iMessage"].tap()

    // Dismiss Keyboard tutorial if needed
    let keyboardTutorialButton = messages.buttons["Continue"]
    if keyboardTutorialButton.exists {
        keyboardTutorialButton.tap()
    }

    messages.typeText("Link: \(urlString)")
    messages.buttons["sendButton"].tap()

    let bubble = messages.links.firstMatch
    XCTAssertTrue(bubble.waitForExistence(timeout: 5))
    sleep(3)
    bubble.tap()
}

The logic to open a link from Messages is a little longer because it sometimes takes a couple more taps before being able to click our link. In this case, we may need to dismiss up to three 3 onboarding screens before being able to send a message. Additionally, before tapping the link, we sleep(3) to give iOS enough time to load our app's metadata. If you don't wait, sometimes iOS will fail to properly open your app.

The result, however, is the same from when we tested deeplinks in Safari. When you call this method, iOS will switch to Messages and attempt to switch back to your app via your universal link.

func testUniversalLinkFromMessages() {
    // Launch our app
    let app = XCUIApplication()
    app.launch()
    // Launch Messages and univesal link back to our app
    openFromMessages("https://swiftrocks.com/profile")
    // Make sure Messages properly switched back to our app before asserting
    XCTAssert(app.wait(for: .runningForeground, timeout: 5))
    // Assert that the universal link worked by checking if we're in the "Profile" screen
    XCTAssertTrue(app.navigationBars["Profile"].exists)
}

UI Testing Universal Links (that launches the app / from a killed app)

Like with deeplinks, iOS's behavior differs slightly when launching your app as a result of tapping a universal link. When using SceneDelegates for example, you need to instead fetch them from a scene's userActivities property.

To confirm that our app can properly handle this, we can use the same trick we used for the deeplinks and terminate our app before executing the test.

func testUniversalLinkFromMessages_fromBackgroundedApp() {
    openMessagesUniversalLink(terminateFirst: false)
}

func testUniversalLinkFromMessages_thatLaunchesTheApp() {
    openMessagesUniversalLink(terminateFirst: true)
}

func openMessagesUniversalLink(terminateFirst: Bool) {
    let app = XCUIApplication()
    app.launch()

    if terminateFirst {
        app.terminate()
    }

    // Launch Messages and univesal link back to our app
    openFromMessages("https://swiftrocks.com/profile")
    // Make sure Messages properly switched back to our app before asserting
    XCTAssert(app.wait(for: .runningForeground, timeout: 5))
    // Assert that the universal link worked by checking if we're in the "Profile" screen
    XCTAssertTrue(app.navigationBars["Profile"].exists)
}