Edition 15

Testing iOS Push Notifications

One common requirement for many testers is to ensure that iOS Push Notifications are getting sent correctly from a backend service. Push notifications, of course, are an integral part of the experience of many apps, letting the user know when a message has been received, or some event has occurred that might make them open up your app. Testing this part of the user experience is therefore essential.

The automation tools provided by Apple don't make this easy. XCUITest has no built-in API methods for inspecting push notifications received. Nor is there any way to even simulate push notifications on a simulator, which makes real devices a hard requirement for automation.

Thankfully, Appium has the facility to automate the entire device UI from the perspective of a user interacting with the screen. This means we can do crazy things like swipe to open the notifications shade and read the element hierarchy there to determine if our notification has arrived. This tip was brought to my attention by Telmo Cardoso at AppiumConf 2018, and I recommend checking out his talk for a bunch of interesting stuff.

Let's dive in and see how to do this. First of all, this is what you'll need to make it work:

  1. A real iPhone or iPad with the ability to receive push notifications, set up as part of a provisioning profile. The device should be set up to allow push notifications for your app to show up on the lock screen.
  2. Appium set up for real device testing of iOS apps (see the Appium docs for more info). Note that you may have to have a paid Apple developer account to automate real iOS devices.
  3. Your app as an exported .ipa file, signed and installable on your phone. (Probably best to deploy from Xcode first before running with Appium, to make sure the app can be provisioned on your device).
  4. Your Apple organization ID, and the appropriate signing certificates, etc..., (we'll need to sign Appium's iOS engine WebDriverAgent to run on your device too, in addition to your app).
  5. A way to trigger your backend app service to send a push notification to a specific device, based on the device udid, from your test code. This might mean pestering your backend devs to give you an API you can call, or a library you can mix in with your Appium test code.

With all this in hand, our test flow should look like:

  1. Launch an Appium session with our app.
  2. Close out our app, to mimic the experience of a user receiving a push notification while doing something else.
  3. Trigger the backend service to send a push notification to the device under test. Ideally this call should block until the notification has been confirmed sent. (Otherwise, we can run the next step in a loop until successful).
  4. Swipe open the notification shade on the device.
  5. Check the elements on the notification shade for one that matches the message that was sent via the backend service.
  6. Swipe closed the notification shade.
  7. Re-open your app if you want, to verify that the app state has also updated in conjunction with the notification received.

Such a flow could look like this in Java:

driver.terminateApp(BUNDLE_ID);

// here is where your code should trigger the push notification and wait a bit

showNotifications();
driver.findElement(By.xpath("//XCUIElementTypeCell[contains(@label, 'notification text')]"));
hideNotifications();

driver.activateApp(BUNDLE_ID);

The terminateApp and activateApp commands are self-explanatory, and simply require the bundle ID of your app. The only mystery here is in the showNotifications and hideNotifications methods. These are not driver methods, but rather helper methods I've implemented to take care of swiping the notifications shade down (to open) and up (to close). The implementation currently uses the TouchAction interface to set up the swipe. Assuming we have a field called screenSize in our test class which has previously queried the screen dimensions from Appium, then our notifications helpers look like:

private void showNotifications() {
    manageNotifications(true);
}

private void hideNotifications() {
    manageNotifications(false);
}

private void manageNotifications(Boolean show) {
    int yMargin = 5;
    int xMid = screenSize.width / 2;
    PointOption top = PointOption.point(xMid, yMargin);
    PointOption bottom = PointOption.point(xMid, screenSize.height - yMargin);

    TouchAction action = new TouchAction(driver);
    if (show) {
        action.press(top);
    } else {
        action.press(bottom);
    }
    action.waitAction(WaitOptions.waitOptions(Duration.ofSeconds(1)));
    if (show) {
        action.moveTo(bottom);
    } else {
        action.moveTo(top);
    }
    action.perform();
}

Essentially, we have one method with any real logic, and all it does is set up the appropriate TouchAction actions for a swipe, namely a press, followed by a wait, followed by a moveTo. There's some other logic here to make sure we're setting our start and stop points correctly: exactly in the middle of the screen horizontally, and from top to bottom (with a padding of 5 pixels on either side).

That's it! With the notification shade down, we can just regular old findElement commands to locate our arrived notification. In this case, we use an xpath query based on a portion of the label, with text corresponding to what we want to verify in terms of the notification that was received from our backend service.

This tip illustrates an important point about using Appium. Sometimes, we look for specific features within Appium to help us accomplish our tasks. It would be great, we think, if Appium had a "getPushNotifications" command we could use to easily check whether a notification arrived. But such a command would take us out of the realm of UI testing. When using Appium, we can simply ask ourselves, "How would a user check this?" and more often than not, we're able to do the same thing with Appium. In this case, the "advanced" trick described in this article is really not a trick at all; it's just thinking like a user!

Check out the following code below for a full example. It won't run without modification, because due to Apple's security policies around running apps on real devices, you'll need to kick off the test with your own (signed) app, and include your own signing details and so on. And as always, the code is up at the Appium Pro repo on GitHub:

import io.appium.java_client.AppiumDriver;
import io.appium.java_client.TouchAction;
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.touch.WaitOptions;
import io.appium.java_client.touch.offset.PointOption;
import java.net.URL;
import java.time.Duration;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.remote.DesiredCapabilities;

public class Edition015_Push_Notifications {

    // these values all need to be replaced with your own app which is signed for a real device,
    // and a WDA_BUNDLE_ID which works with your wildcard app id
    private String BUNDLE_ID = "com.company.yourapp";
    private String WDA_BUNDLE_ID = "com.company.webdriveragent";
    private String APP = "/path/to/yourapp.ipa";
    private String XCODE_ORG_ID = "ABCDEFG123";

    private AppiumDriver driver;
    private Dimension screenSize;

    @Test
    public void testPushNotifications() throws Exception {

        DesiredCapabilities capabilities = new DesiredCapabilities();
        capabilities.setCapability("platformName", "iOS");
        capabilities.setCapability("platformVersion", "11.2");
        capabilities.setCapability("deviceName", "iPhone 6s");
        capabilities.setCapability("app", APP);
        capabilities.setCapability("udid", "auto");
        capabilities.setCapability("xcodeOrgId", XCODE_ORG_ID);
        capabilities.setCapability("xcodeSigningId", "iPhone Developer");
        capabilities.setCapability("updatedWDABundleId", WDA_BUNDLE_ID);

        driver = new IOSDriver(new URL("http://localhost:4723/wd/hub"), capabilities);

        try {
            // get screen size so we can swipe correctly
            screenSize = driver.manage().window().getSize();

            // close app and wait for push notification to arrive
            driver.terminateApp(BUNDLE_ID);

            // here is where your code should trigger the push notification and wait a bit

            // now pull down the notification shade, check for the message we're expecting, and
            // then close the shade again ('TWITTER' is just an example of an expected message)
            showNotifications();
            driver.findElement(By.xpath("//XCUIElementTypeCell[contains(@label, 'TWITTER')]"));
            hideNotifications();

            // finally, we can reactivate our app in order to verify that the message is present,
            // or whatever we need to do
            driver.activateApp(BUNDLE_ID);
        } finally {
            driver.quit();
        }
    }

    private void showNotifications() {
        manageNotifications(true);
    }

    private void hideNotifications() {
        manageNotifications(false);
    }

    private void manageNotifications(Boolean show) {
        int yMargin = 5;
        int xMid = screenSize.width / 2;
        PointOption top = PointOption.point(xMid, yMargin);
        PointOption bottom = PointOption.point(xMid, screenSize.height - yMargin);

        TouchAction action = new TouchAction(driver);
        if (show) {
            action.press(top);
        } else {
            action.press(bottom);
        }
        action.waitAction(WaitOptions.waitOptions(Duration.ofSeconds(1)));
        if (show) {
            action.moveTo(bottom);
        } else {
            action.moveTo(top);
        }
        action.perform();
    }
}