Edition 17

Automating Cross-Platform Hybrid Apps

In these articles we tend to focus a lot on native mobile apps, and for good reason: automating native apps is a big reason people turn to Appium. One of Appium's greatest benefits, however, is that you can automate other varieties of app just as easily, using the same sort of client code---that's the beauty of building on top of the standard WebDriver protocol!

Hybrid apps are one of these "other varieties" of app, and they are unique in consisting of both native and web components. Hybrid apps are developed for a number of reasons, but one often-cited reason is to allow app developers the freedom to use the tools, languages, frameworks, and development process familiar from the web, while still producing an app that looks and feels right on a phone. The secret is in the "webview", a native component whose sole purpose is to display web content (either stored locally as HTML, JS, and CSS, or retrieved from some URL).

To demonstrate some of the interesting possibilities of hybrid apps, I've released v1.5.0 of TheApp, which looks like this:

Screenshot of the hybrid demo view

We have a few native controls (a text field and two buttons), and then some text directing us to go to a webpage. In fact, this text is itself some simple HTML, hosted in a webview with an invisible frame. If I enter a URL in the text box and click "Go", one of two things will happen:

  • If I've elected to visit https://appiumpro.com, the request will go through and the webview will load the Appium Pro site
  • If I try any other URL, I will get a native alert telling me I'm not allowed to navigate

Essentially, what I've built is a little web browser that only lets the user go to one site! (Why did I pick Appium Pro's site? Hmm....). OK, this is admittedly very useless (not to mention ugly on top). But it highlights the interactions possible between native components and the webview. Let's imagine a simple test scenario to prove this feature works:

  1. Navigate to the Hybrid Demo view (the one pictured above)
  2. Assert that there is a page loaded in the webview which is not Appium Pro to begin with
  3. Attempt to visit some URL which is also not Appium Pro (say Google)
  4. Assert that an alert pops up
  5. Attempt to navigate to Appium Pro's URL
  6. Assert that the title of the page inside the webview matches Appium Pro

The only steps we really need to cover in this guide are #3 and #6, the ones that are making a verification inside a webview. How do we do this? Every Appium session comes with the ability to switch between multiple "contexts". In a test of a native app, there is usually just one context available, and thus there's no point in worrying about contexts at all.

In hybrid apps, there are multiple contexts: one that represents the native portion of the app, and one or more contexts that represent each webview which is present (typically just one). With our Appium client, we can either get a list of the available contexts, or we can tell Appium to switch to a given context, using the Context API:

driver.getContextHandles(); // get a list of the available contexts
driver.context("NATIVE_APP"); // switch to the native context

In the example above, I show how to switch to the native context. But that's where we start out already, so it's not very useful. How do we switch to a webview context? Well, we don't know the name of a webview context before a session starts, so we have to use the getContextHandles command to figure out the correct string that refers to a webview context. I recommend using a little helper method like the following to do this without relying on any knowledge about the number of webview contexts present:

@Nullable
private String getWebContext(AppiumDriver driver) {
    ArrayList<String> contexts = new ArrayList(driver.getContextHandles());
    for (String context : contexts) {
        if (!context.equals("NATIVE_APP")) {
            return context;
        }
    }
    return null;
}

Basically, we just loop through all the available contexts and return the first one we find which is not the native context. This simple logic is good for most cases. We can then use the output of this method to get into a webview context using the context method:

String webContext = getWebContext(driver);
driver.context(webContext);

Once this command completes successfully, we're in a webview! Great. But what does that mean exactly? What it means is that, from this point on, any command we send from our Appium client is going to be taken to refer to the webpage inside the webview, rather than the native app. This means that what we are dealing with at this point is essentially a Selenium session. We can issue commands like getTitle() and get the title of the page inside the webview---something that would not work if we were in the native context! Element finding, clicks, etc..., all operate on the page inside the webview just as though you had fired up Selenium and were automating a desktop browser. (Certain device-level commands that don't make sense inside a webview continue to be interpreted in the native context, for example changing orientation).

Of course, at some point you might want to target native elements or behaviors again, in which case you simply have to re-run the context command with the value NATIVE_APP to tell Appium you want to go back to native mode. Armed with this knowledge, let's see how we would implement the test scenario we sketched out above:

WebDriverWait wait = new WebDriverWait(driver, 10);
final String title = "Appium Pro: The Awesome Appium Tips Newsletter";

wait
    .until(ExpectedConditions.presenceOfElementLocated(hybridScreen))
    .click();

MobileElement input = (MobileElement) wait
    .until(ExpectedConditions.presenceOfElementLocated(urlInput));

// Get into the webview and assert that we're not yet at the correct page
String webContext = getWebContext(driver);
driver.context(webContext);
Assert.assertNotEquals(driver.getTitle(), title);

// Go back into the native context and automate the URL button
driver.context("NATIVE_APP");
input.sendKeys("https://google.com");
WebElement navigate = driver.findElement(navigateBtn);
navigate.click();

// Assert that going to Google is not allowed
Thread.sleep(1000); // cheap way to ensure alert has time to show
driver.switchTo().alert().accept();

// Now try to go to Appium Pro
driver.findElement(clearBtn).click();
input.sendKeys("https://appiumpro.com");
navigate.click();

// Go back into the webview and assert that the title is correct
driver.context(webContext);
wait.until(ExpectedConditions.titleIs(title));

You can see that we make use of our getWebContext helper method by storing a reference to the webview context. (Really, we should also have a null check and throw if a webview context is not available, because that means our test will fail! But for now we assume the webview is always present). Then, we go through the steps outlined above: asserting that the initial webview page's title is not the same as the Appium Pro site, then proving we can't navigate to Google, then finally directing the app to navigate to Appium Pro, and asserting that the title of the webview page now reflects that situation. You can see that in webview mode, we can even use Selenium-only client features, like the titleIs expected condition.

The code above is completely cross-platform; I can run this just the same on both the iOS and Android versions of the TheApp. Once you're in a webview, iOS- and Android-specific issues mostly disappear. How is this possible? There is an awful lot of machinery that we've put into Appium to make hybrid and web testing seamless. On iOS we hook into the Remote Debugger port exposed by every webview when the app is in debug mode, and leverage the Selenium Atoms to facilitate use of the Selenium API via injecting JS. On Android, we run Chromedriver under the hood as a subprocess, and attach it to the particular webview we care about---this works because webviews on Android are backed by Chrome.

Speaking of Chrome, one wrinkle of hybrid automation on Android is that each release of Chromedriver has a minimum version of Chrome which it supports. This means that if you get a version of Appium which bundles Chromedriver version X, and the version of Chrome on your device is older than the minimum Chrome version for X, webviews will not be automatable. If you run into that situation, refer to the Chromedriver section of the Appium docs with instructions on using special flags to get versions of Chromedriver appropriate for your automation.

That's it for basic hybrid automation! There's a full code sample included below. In it you'll notice that there are no special capabilities being used to start the session---just the typical app capability. This is the main difference between web and hybrid automation with Appium: for web testing, you include the browserName capability and set it to Safari or Chrome, and you're automatically put into the web context on session start. For hybrid testing, you just use your own app reference as with native automation, and you're responsible for managing the contexts so Appium knows whether you're interested in native or web elements and actions.

import io.appium.java_client.AppiumDriver;
import io.appium.java_client.MobileBy;
import io.appium.java_client.MobileElement;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.ios.IOSDriver;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import javax.annotation.Nullable;
import org.junit.Assert;
import org.junit.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

public class Edition017_Hybrid_Apps {

    private String APP_IOS = "https://github.com/cloudgrey-io/the-app/releases/download/v1.5.0/TheApp-v1.5.0.app.zip";
    private String APP_ANDROID = "https://github.com/cloudgrey-io/the-app/releases/download/v1.5.0/TheApp-v1.5.0.apk";


    private static By hybridScreen = MobileBy.AccessibilityId("Webview Demo");
    private static By urlInput = MobileBy.AccessibilityId("urlInput");
    private static By navigateBtn = MobileBy.AccessibilityId("navigateBtn");
    private static By clearBtn = MobileBy.AccessibilityId("clearBtn");

    @Test
    public void testAppiumProSite_iOS() throws MalformedURLException {
        DesiredCapabilities capabilities = new DesiredCapabilities();
        capabilities.setCapability("platformName", "iOS");
        capabilities.setCapability("platformVersion", "11.3");
        capabilities.setCapability("deviceName", "iPhone 6");
        capabilities.setCapability("app", APP_IOS);

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

    @Test
    public void testAppiumProSite_Android() throws MalformedURLException {
        DesiredCapabilities capabilities = new DesiredCapabilities();
        capabilities.setCapability("platformName", "Android");
        capabilities.setCapability("deviceName", "Android Emulator");
        capabilities.setCapability("automationName", "UiAutomator2");
        capabilities.setCapability("app", APP_ANDROID);

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

    @Nullable
    private String getWebContext(AppiumDriver driver) {
        ArrayList<String> contexts = new ArrayList(driver.getContextHandles());
        for (String context : contexts) {
            if (!context.equals("NATIVE_APP")) {
                return context;
            }
        }
        return null;
    }

    public void actualTest(AppiumDriver driver) {
        WebDriverWait wait = new WebDriverWait(driver, 10);
        final String title = "Appium Pro: The Awesome Appium Tips Newsletter";

        try {
            wait
                .until(ExpectedConditions.presenceOfElementLocated(hybridScreen))
                .click();

            MobileElement input = (MobileElement) wait
                .until(ExpectedConditions.presenceOfElementLocated(urlInput));

            // Get into the webview and assert that we're not yet at the correct page
            String webContext = getWebContext(driver);
            driver.context(webContext);
            Assert.assertNotEquals(driver.getTitle(), title);

            // Go back into the native context and automate the URL button
            driver.context("NATIVE_APP");
            input.sendKeys("https://google.com");
            WebElement navigate = driver.findElement(navigateBtn);
            navigate.click();

            // Assert that going to Google is not allowed
            Thread.sleep(1000); // cheap way to ensure alert has time to show
            driver.switchTo().alert().accept();

            // Now try to go to Appium Pro
            driver.findElement(clearBtn).click();
            input.sendKeys("https://appiumpro.com");
            navigate.click();

            // Go back into the webview and assert that the title is correct
            driver.context(webContext);
            wait.until(ExpectedConditions.titleIs(title));
        } catch (InterruptedException ign) {
        } finally {
            driver.quit();
        }

    }
}