Friday, May 4, 2018

BDD For Android Hello World


Today I am going to demonstrate a very basic bootstrap of BDD for Android using Cucumber and Appium.

You can find all of the code on GitHub, natch.

The first step is to get Appium installed and working. I found this to be the best startup instructions, but it is a little old, so YMMV.

First, the app. We have a very simple Android app here with a button. You click it, and it goes away.


Now, we want to test our app. We need to set up a new Gradle module parallel to our application project, as the plugin environment we need to make this work isn't going to play well with the Android plugin. Here I have called the project "integration" to indicate this for performing integration testing.

Lets break down the build.gradle real fast:

Apply our plugins:

buildscript {
    repositories {
        maven {
            url "http://repo.bodar.com"
        }
        maven {
            url "https://plugins.gradle.org/m2/"
        }
    }
    dependencies {
        classpath "com.github.samueltbrown:gradle-cucumber-plugin:0.9"
    }
}
plugins {
    id 'java'
    id "com.github.samueltbrown.cucumber" version "0.9"
    id 'idea'
}

Next we need to make sure our app gets built for the Cucumber plugin to run.

tasks.cucumber.dependsOn(":app:assembleDebug")


And point the cucumber plugin to the debug build of the application, so we can find it. Here we are going to pass an environment variable pointing to the APK file we want to test.

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"    
     }
}

repositories {
    jcenter()
    maven {
        url 'https://repository-saucelabs.forge.cloudbees.com/release'
    }
}

Next we set up the dependencies we need for the Cucumber environment. These come in as two new Gradle configuration scopes. Finally, we are just going to use a little config magic to make sure IntelliJ IDEA can properly resolve the dependencies while we are editing these things.

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

idea {
    module {
        testSourceDirs += file('src/cucumber/java')
        scopes.TEST.plus.add(configurations.cucumberCompile)
    }
}

sourceCompatibility = "1.8"
targetCompatibility = "1.8"

Cucumber allows us to write test directives using a natural language format. The idea here is that the natural language syntax is easier to maintain as your requirements change. Our sample .feature file in src/cucumber/resources looks like so:


Feature: Click the button
  Clicking buttons is clever

  Scenario: I see a button and click it.
    Given I have launched the application
    When I click the "Click Me" button
    Then the "Click Me" is gone

The first line is the name of the feature we are testing with this file. The second line is just a descriptor. It is then followed by 1..n "Scenarios" that are basically sequences of test steps. Here, the scenario is named "I see a button and click it."

Next we have a number of steps. These are in the format of "Given/When/Then". Given steps are basically there to establish the baseline for the test. Here, we are just launching the app, but you could have a "Give" that navigates to a page, or logs in, or whatever.  When steps are basically your interaction operations. Then steps are your assertion operations.

These steps, however, are going to require some code to work. So lets get into that. Cucumber is going to look for your steps to be defined in some Java classes with methods annotated with the step definition. You can use regular expressions to pull data from the step line, as well as some other {} -type templating.

So let's look at the first one: "Given I have launched the application." This is a simple method call which we annotate with the @Given annotation...


public class BaseSteps {
    protected AndroidDriver<MobileElement> driver;
    @Given("I have launched the application")
    public void startApp() throws IOException {
        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");
        driver = new AndroidDriver<>(new URL("http://127.0.0.1:4723/wd/hub"),
                   capabilities);
        driver.resetApp();
    }

Here we start by reading the apk environment variable we configured in the Gradle file, configure the DesiredCapabilities object, and create an AndroidDriver.  For prophylactic reasons, we do a resetApp() call to just kill and restart the app so we know we have a good state. This will connect to the Appium node.js server, which in turn will install the apk file on the running emulator and spin it up.

Next we want to click our button, so let's look at that one.

@When("I click the \"(.*)\" button")
public void clickByText(String text){
    driver.findElementByAndroidUIAutomator(
              "new UiSelector().textContains(\"" +
              text + "\")"
           ).click();
}

Here we are selecting the text inside the quotation marks using a regex selector. Then we take the text and go to the driver and use findElementByAndroidUIAutomator() to select it. Here we are constructing a small Java snippet in a String to perform the UI selection on the device, then we call click() to perform the action.

Finally, we want to validate the behavior: that the button goes away. To do that we look for the button again, but expect it to be gone...
@Then("the \"(.*)\" is gone")
public void assertMissing(String text){
    MobileElement element = null;
    try {
        element = driver.findElementByAndroidUIAutomator(
               "new UiSelector().textContains(\"" + 
                text + "\")"
               );
    } catch(NoSuchElementException e) {
        //expected exception;
    }

    assertTrue(element == null);
}

If the driver fails to select the element, it will throw a NoSuchElementException, but we want that to happen here.

Finally we run our feature suite by doing gradle cucumber:


Executing tasks: [cucumber]

Configuration on demand is an incubating feature.
:app:preBuild UP-TO-DATE
:app:preDebugBuild UP-TO-DATE
:app:compileDebugAidl UP-TO-DATE
:app:compileDebugRenderscript UP-TO-DATE
:app:checkDebugManifest UP-TO-DATE
:app:generateDebugBuildConfig UP-TO-DATE
:app:prepareLintJar UP-TO-DATE
:app:mainApkListPersistenceDebug UP-TO-DATE
:app:generateDebugResValues UP-TO-DATE
:app:generateDebugResources UP-TO-DATE
:app:mergeDebugResources UP-TO-DATE
:app:createDebugCompatibleScreenManifests UP-TO-DATE
:app:processDebugManifest UP-TO-DATE
:app:splitsDiscoveryTaskDebug UP-TO-DATE
:app:processDebugResources UP-TO-DATE
:app:generateDebugSources UP-TO-DATE
:app:javaPreCompileDebug UP-TO-DATE
:app:compileDebugJavaWithJavac UP-TO-DATE
:app:compileDebugNdk NO-SOURCE
:app:compileDebugSources UP-TO-DATE
:app:mergeDebugShaders UP-TO-DATE
:app:compileDebugShaders UP-TO-DATE
:app:generateDebugAssets UP-TO-DATE
:app:mergeDebugAssets UP-TO-DATE
:app:transformClassesWithDexBuilderForDebug UP-TO-DATE
:app:transformDexArchiveWithExternalLibsDexMergerForDebug UP-TO-DATE
:app:transformDexArchiveWithDexMergerForDebug UP-TO-DATE
:app:mergeDebugJniLibFolders UP-TO-DATE
:app:transformNativeLibsWithMergeJniLibsForDebug UP-TO-DATE
:app:processDebugJavaRes NO-SOURCE
:app:transformResourcesWithMergeJavaResForDebug UP-TO-DATE
:app:validateSigningDebug UP-TO-DATE
:app:packageDebug UP-TO-DATE
:app:assembleDebug UP-TO-DATE
:integration:compileJava NO-SOURCE
:integration:processResources NO-SOURCE
:integration:classes UP-TO-DATE
:integration:jar UP-TO-DATE
:integration:assemble UP-TO-DATE
:integration:compileTestJava NO-SOURCE
:integration:processTestResources NO-SOURCE
:integration:testClasses UP-TO-DATE
:integration:compileCucumberJava
:integration:processCucumberResources UP-TO-DATE
:integration:cucumberClasses
: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
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)
3 Steps (3 passed)
0m12.263s


BUILD SUCCESSFUL in 16s
30 actionable tasks: 2 executed, 28 up-to-date
1:18:07 PM: Task execution finished 'cucumber'.

No comments:

Post a Comment