Monday, May 7, 2018

Multi-Platform BDD with Cucumber and Appium (Part 2)

Previously, on Battlestar Galactica...

In the last post, we set up a simple BDD test for an Android app. We defined some general BaseSteps that allowed us to look for text on the screen and click it. In this exercise we are going to abstract this out so that we can perform the same test on multiple platforms using Guice injection.

Source code for this version with the changes from Part 1 is available on the WITH_GUICE branch on GitHub.

As you saw in the last article, the biding between step definitions and feature files is handled via Java annotations. By adding the cucumber-guice library we can provide a platform strategy that will let us define these common step definitions across platforms based on execution time parameters.

If you are like me, you are going to be wondering about the decisions that have been make here from an architecture standpoint. So let's cover some of the weirdness here...

  1. cucumber-guice doesn't seem to know how to introspect abstract classes for step definitions. You need a concrete class for that.
  2. If you have two concrete classes with matching @Override methods, it is going to yell at you about duplicate step definitions.
  3. I have elected to overcome these difficulties by using a "Strategy" pattern. We have preserved our BaseSteps class from the previous example, but now it simply delegates calls to a strategy implementation @Inject-ed when it is created.
  4. We're going to spend more time looking at design patterns for BDD tests in the next post, but for now we just want to add muti-platform support to our testing scenario.
All that said, let's get started...

The first thing we want to do us update our dependencies in the integration module from the previous example we are adding two lines here:

dependencies {
    cucumberRuntime 'info.cukes:cucumber-java:1.2.5'
    cucumberCompile 'info.cukes:cucumber-java:1.2.5'
    cucumberCompile 'info.cukes:cucumber-guice:1.2.5'
    cucumberCompile 'com.google.inject:guice:4.2.0'
    cucumberCompile 'junit:junit:4.12'
    cucumberCompile 'io.appium:java-client:3.3.0'
}


The cucumber-guice dependency is new. Because, for some FSM damned reason, the cucumber-guice module doesn't have a declared dependency on Guice, we also add a dependency on Guice. I really got nothing here. You have to do it or it just won't work.

Now we have the option to inject dependencies into our step definitions. If all you want is some simple state definitions, you can do that with the regular javax.inject annotation, or the Cucumber annotations, but we will get to that. Right now, though, we want to change our execution based on platform, so we need to modify our build.gradle to pass a new parameter to the test execution.

cucumber {
    formats = ['pretty', 'json:build/cucumber.json', 'junit:build/cucumber.xml']
    jvmOptions {
        maxHeapSize = '512m'
        environment 'apk', rootProject.project(":app").buildDir.getAbsolutePath() +
                "/outputs/apk/debug/app-debug.apk"
        environment 'platform', System.getProperty("platform") == null ?
                "android" :
                System.getProperty("platform");
    }
}

So we are just going to read a system property here and default to android if it is undefined, then pass that value to the execution environment for Cucumber.

Now, we have a bit of boilerplate to create. Again going to interesting design decisions, cucumber-guice doesn't allow you to simply declare modules to include. However, you can provide a factory class for the Guice injector it will use. Just so you understand the subtleties of this, lets be clear about what is happening here: Cucumber scans the classpath for method implementations with a step definition (@Given, @When, @Then), and then with cucumber-guice, asks the Injector for an implementation of that class. This is why you can't have multiple simple implementations of a step definition floating around, and why we have opted for a strategy pattern to provide multi-platform implementations. Again, we will look at this in more detail in the next post. All this hand-waving aside, we need to create an InjectorSource implementation:

public class ConfiguredInjectorSource implements InjectorSource {
    @Override
    public Injector getInjector() {
        return Guice.createInjector(CucumberModules.SCENARIO,
           new CucumberModule());
    }
}

When we create our Guice injector, we need to include the SCENARIO module. This defines a @ScenarioScoped annotation that we can use later when creating our injected classes. We aren't going to use it for this exercise, but it is required to bootstrap cucumber-guice. Here we have added this default module and our module.

Finally, we need to declare the CucumberModule class. We are going to read the environment variable we defined in our Gradle file and do an implementation swap.

public class CucumberModule extends AbstractModule {

    private static final Logger LOGGER = Logger.getLogger(
       CucumberModule.class.getCanonicalName()
    );
    @Override
    protected void configure() {
        String platform = System.getenv("platform");
        LOGGER.info("Configuring run for platform: "+platform);
        switch(platform){
            case "android":
                bind(BaseStepsStrategy.class)
                   .to(AndroidBaseSteps.class)
                   .in(Singleton.class);
                break;
            case "ios":
                bind(BaseStepsStrategy.class)
                   .to(IOSBaseSteps.class)
                   .in(Singleton.class);
                break;
            default:
                throw new RuntimeException(
                   "Unknown platform environment variable: " +
                   platform);
        }
    }
}


This feels like a crazy number of lines for what we are doing here, but you know, cope. We read the environment variable, we log it so the user knows what is going on, then we do a switch around the BaseStepsStrategy to provide an implementation.

This is all well and good, but our test execution is still going to depend on BaseSteps from the previous exercise. So what we are going to do is rework this class to delegate to our strategy implementations, declare itself as a @Singleton, and get the strategy implementation injected into it.

@Singleton
public class BaseSteps {
    private final BaseStepsStrategy strategy;
    @Inject
    public BaseSteps(BaseStepsStrategy baseSteps){
        this.strategy = baseSteps;
    }

    @Given("I have launched the application")
    public  void startApp() throws IOException{
        strategy.startApp();
    }

    @When("I click the \"(.*)\" button")
    public void clickByText(String text){
        strategy.clickByText(text);
    }

    @Then("the \"(.*)\" is gone")
    public void assertMissing(String text){
        strategy.assertMissing(text);
    }
}

What about all the code we had from the previous exercise? Well, we're gonna copy and paste that into our AndroidBaseSteps class, but first, we're gonna create a generic abstract strategy here.

public abstract class BaseStepsStrategy<T extends WebDriver> {

    private T driver;
    BaseStepsStrategy() throws MalformedURLException {
        this.driver = createDriver();
    }

    protected abstract T createDriver() throws MalformedURLException;
    T getDriver(){
        return this.driver;
    }

    public abstract void startApp() throws IOException;
    public abstract void clickByText(String text);
    public abstract void assertMissing(String text);
}

I know that WebDriver seems like a weird thing to extend from here, but MobileDriver for Appium actually extends from WebDriver -- I guess no one has gone back and just made "AppDriver" as a thing from the Selenium group.  Because we want the "driver" to continue to be a singleton in the runtime, we have kicked it out to a createDriver() method. The reason is obvious when we create our AndroidBaseSteps class.

public class AndroidBaseSteps extends BaseStepsStrategy<AndroidDriver> {

    public AndroidBaseSteps() throws MalformedURLException {
        super();    }

    @Override
    protected AndroidDriver createDriver() throws MalformedURLException {
        File app = new File(System.getenv("apk"));
        DesiredCapabilities capabilities = new DesiredCapabilities();
        capabilities.setCapability("deviceName","Android Emulator");
        capabilities.setCapability("app", app.getAbsolutePath());
        capabilities.setCapability("appPackage", "net.kebernet.appium_cucumber");
        capabilities.setCapability("appActivity", ".MainActivity");
        return new AndroidDriver<>(new URL("http://127.0.0.1:4723/wd/hub"),
           capabilities);
    }

    @Override
    public void startApp() throws IOException {
        getDriver().resetApp();
    }

    @Override
    public void clickByText(String text) {
        getDriver().findElementByAndroidUIAutomator(
                      "new UiSelector().textContains(\""+text+"\")")
                    .click();
    }

    @Override
    public void assertMissing(String text) {
        MobileElement element = null;
        try {
            element = (MobileElement) getDriver()
               .findElementByAndroidUIAutomator(
                   "new UiSelector().textContains(\"" + text + "\")");
        } catch(NoSuchElementException e){
            //expected exception;
        }
        assertTrue(element == null);
    }
}


Aside from a bit of boilerplate, this looks pretty much exactly like out BaseSteps class from the first example. We are creating the driver, by delegation, in the constructor, but since this is a generic implementation, we can use getDriver() everywhere and know we are starting with an AndroidDriver. We do have a new cast in the assertMissing() method, but that is a small price to pay.

We are now back to a "known good" state for our application and tests. Now, let's look at making our "integration" suite work with iOS.

We will start with our iOS app, that is basically the same as our Android app...

Next we need to implement our IOSBaseSteps class. This is going to look largely like our Android version we already have, only this time we are going to use the XPath selector. We are also adding a new configuration to the environment configuration in the build.gradle to pass a path to our .app build.

public class IOSBaseSteps extends BaseStepsStrategy<IOSDriver<MobileElement>> {


    public IOSBaseSteps() throws MalformedURLException {
        super();
    }

    @Override
    protected IOSDriver<MobileElement> createDriver() throws MalformedURLException {
        File app = new File(System.getenv("app"));
        DesiredCapabilities capabilities = new DesiredCapabilities();
        capabilities.setCapability(MobileCapabilityType.PLATFORM_NAME, "iOS");
        capabilities.setCapability(MobileCapabilityType.PLATFORM_VERSION, "11.3");
        capabilities.setCapability(MobileCapabilityType.DEVICE_NAME, "iPhone Simulator");
        capabilities.setCapability(MobileCapabilityType.APP, app.getAbsolutePath());
        return new IOSDriver<>(new URL("http://127.0.0.1:4723/wd/hub"), capabilities);
    }

    @Override
    public void startApp() throws IOException {
        getDriver().resetApp();
    }

    @Override
    public void clickByText(String text) {
        MobileElement element = getDriver().findElementByXPath(
              "//*[contains(@label, '"
              + text + "')]");
        element.click();
    }

    @Override
    public void assertMissing(String text) {
        MobileElement element = null;
        try {
            element = getDriver().findElementByXPath(
               "//*[contains(@label, '"
               + text + "')]");
        } catch(NoSuchElementException e){
            //expected execption;
        }
        assertNull(element);
    }

And our change to the build.gradle file:

cucumber {
    formats = ['pretty', 'json:build/cucumber.json', 'junit:build/cucumber.xml']
    jvmOptions {
        maxHeapSize = '512m'
        environment 'apk', rootProject.project(":app").buildDir.getAbsolutePath() +
                "/outputs/apk/debug/app-debug.apk"
        environment 'platform', System.getProperty("platform") == null ?
                "android" :
                System.getProperty("platform")

        environment 'app', rootProject.projectDir.getAbsolutePath() +
                "/ios/DerivedData/appium-cucumber/Build/Products/" +
                "Debug-iphonesimulator/appium-cucumber.app"
    }
}

Now we can run our sample tests on the iOS app:

$../gradlew -Dplatform=ios cucumber



> Task :integration:cucumber 
Gradle now uses separate output directories for each JVM language, but this build assumes a single directory for all classes from a source set. This behaviour has been deprecated and is scheduled to be removed in Gradle 5.0
May 07, 2018 12:10:39 PM cucumber.inject.CucumberModule configure
INFO: Configuring run for platform: ios
Feature: Click the button
  Clicking buttons is clever

  Scenario: I see a button and click it.  # features/hello.feature:4
    Given I have launched the application # BaseSteps.startApp()
    When I click the "Click Me" button    # BaseSteps.clickByText(String)
    Then the "Click Me" is gone           # BaseSteps.assertMissing(String)

1 Scenarios (1 passed)r
3 Steps (3 passed)
2m37.075s



BUILD SUCCESSFUL in 2m 57s

No comments:

Post a Comment