NativeDriver を使ってみた

Googleが公開している、Androidのテストフレームワーク
WebViewではない、ネイティブなアプリケーションのUIテストができる。

nativedriver

http://code.google.com/p/nativedriver/

で、早速使ってみた。

jarをつくる

jarが配布されているわけではない。なので、まずは上記ページからソースをチェックアウトして、Antでビルドを行う(このあたりちょっとメンドイ)。

以下の2つのjarを使うことになる。

server-standalone.jar

テスト対象のプロジェクトに組み込むもの。clientとはjson形式で通信を行うらしい。

client-standalone.jar

テストを記述するプロジェクトに組み込むもの。ちなみに、JUnit3ベースで実行するため、通常のjavaプロジェクトとする(AndroidプロジェクトだとJUnitが違うためか、うまく動かなかった)

テスト対象アプリを作る

テスト対象がないと試すことができない。APIデモを対象にしてもよいが、せっかくなので久々にAndroidアプリを作ってみた。

Native Driverは、レイアウトを定義するxmlで指定したidを対象に「クリック」や「値の入力」「内容の確認」等を行うので、わかりやすい名前にするのが吉。
こんな感じで作った。

main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:padding="4dip"
    android:gravity="center_horizontal"
    android:layout_width="match_parent" android:layout_height="match_parent" android:weightSum="1">
    <TextView android:id="@+id/textView1" android:textAppearance="?android:attr/textAppearanceLarge" android:text="0" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="right" android:layout_weight="0.11" android:textSize="40sp"></TextView>
    <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout1" android:layout_height="wrap_content" android:orientation="vertical">
        <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout2" android:layout_height="wrap_content" android:baselineAligned="false" android:weightSum="1">
            <Button android:id="@+id/button7" android:text="7" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_weight="0.20"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button8" android:layout_weight="0.20" android:text="8"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button9" android:layout_weight="0.20" android:text="9"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button_times" android:layout_weight="0.20" android:text="×"></Button>
        </LinearLayout>
    </LinearLayout>
    <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout3" android:layout_height="wrap_content" android:orientation="vertical">
        <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout6" android:layout_height="wrap_content" android:weightSum="1">
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button4" android:layout_weight="0.20" android:text="4"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button5" android:text="5" android:layout_weight="0.20"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button6" android:layout_weight="0.20" android:text="6"></Button>
            <Button android:layout_height="wrap_content" android:id="@+id/button_minus" android:text="-" android:layout_width="wrap_content" android:layout_weight="0.20"></Button>
        </LinearLayout>
    </LinearLayout>
    <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout4" android:layout_height="wrap_content" android:orientation="vertical">
        <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout7" android:layout_height="wrap_content" android:weightSum="1">
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button1" android:layout_weight="0.20" android:text="1"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button2" android:layout_weight="0.20" android:text="2"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button3" android:layout_weight="0.20" android:text="3"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button_plus" android:layout_weight="0.20" android:text="+"></Button>
        </LinearLayout>
    </LinearLayout>
    <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout5" android:layout_height="wrap_content" android:orientation="vertical">
        <LinearLayout android:layout_width="match_parent" android:id="@+id/linearLayout8" android:layout_height="wrap_content" android:weightSum="1">
            <Button android:layout_height="wrap_content" android:id="@+id/button0" android:text="0" android:layout_width="wrap_content" android:layout_weight="0.20"></Button>
            <Button android:layout_height="wrap_content" android:id="@+id/button_clear" android:text="C" android:layout_width="29dp" android:layout_weight="0.20"></Button>
            <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/button_result" android:layout_weight="0.20" android:text="="></Button>
            <Button android:layout_height="wrap_content" android:id="@+id/button_div" android:text="÷" android:layout_width="wrap_content" android:layout_weight="0.20"></Button>
        </LinearLayout>
    </LinearLayout>
</LinearLayout>

ここで忘れないで行わないといけないことがある。

AndroidManifest.xmlの設定

instrumentとパーミッションの設定を行う。
instrumentというのは、JavaバイトコードVMの間にインターセプトできる技術らしい。この機能を使い、ボタンのクリックをテストケースから行う。

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="orz.yanagin.android"
  android:versionCode="1"
  android:versionName="1.0">
  <uses-sdk android:minSdkVersion="8" />

  <application android:icon="@drawable/icon" android:label="@string/app_name">
    <activity android:name=".CalcActivity" android:label="@string/app_name">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>

  </application>

  <!-- Without supports-screens, the touch event simulation might not reach
    the specified view due to coordinate translation. -->
  <supports-screens android:largeScreens="true"
    android:normalScreens="true"
    android:smallScreens="true"
    android:anyDensity="true" />

  <!-- The instrumentation and uses-permissions elements are necessary to
    enable the Android NativeDriver server in this application. TODO(matvore):
    add a link to getting started documentation -->
  <instrumentation android:targetPackage="orz.yanagin.android"
    android:name="com.google.android.testing.nativedriver.server.ServerInstrumentation" />
  <uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.WAKE_LOCK" />

</manifest>

android:targetPackage属性は、テスト対象となるActivityのパッケージ名を記述する。

テストを記述するプロジェクトを作る

Androidプロジェクトを作るとき、対になる形でテストプロジェクトを作れるが、上述したようにJUnit3を使いたいのでココは普通のJavaプロジェクトとする。

robotiumも触ってみたが、やはり素のJUnitが使えるのが嬉しい。
こんな感じでテストケースが作れる。

CalcActivityNativedriverTest.java

package orz.yanagin.android.test;

import junit.framework.TestCase;

import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;

import com.google.android.testing.nativedriver.client.AndroidNativeDriver;
import com.google.android.testing.nativedriver.client.AndroidNativeDriverBuilder;

public class CalcActivityNativedriverTest extends TestCase {

  private AndroidNativeDriver driver;

  @Override
  protected void setUp() {
    driver = getDriver();
  }

  @Override
  protected void tearDown() {
    driver.quit();
  }

  protected AndroidNativeDriver getDriver() {
    return new AndroidNativeDriverBuilder().withDefaultServer().build();
  }

  public void testClick0() {
    driver.startActivity("orz.yanagin.android.CalcActivity");

    WebElement textView = driver.findElement(By.id("textView1"));
    assertEquals("0", textView.getText());

    driver.findElement(By.id("button1")).click();
    textView = driver.findElement(By.id("textView1"));
    assertEquals("1", textView.getText());

    driver.findElement(By.id("button_plus")).click();
    assertEquals("1", textView.getText());

    driver.findElement(By.id("button3")).click();
    assertEquals("3", textView.getText());

    driver.findElement(By.id("button_result")).click();
    assertEquals("4", textView.getText());

    driver.findElement(By.id("button_clear")).click();
    assertEquals("0", textView.getText());
  }

}

で、いざ実行

その前に、2つやることがある。

instrumentの設定

コマンドプロンプトで以下のコマンドを実行する。

adb shell am instrument [Activityのパッケージ]/com.google.android.testing.nativedriver.server.ServerInstrumentation

Activityのパッケージには、AndroidManifest.xmlで記載したパッケージと同じものを使う。

実際にはこんな感じ

adb shell am instrument com.example.android.apis/com.google.android.testing.nativedriver.server.ServerInstrumentation

このとき以下のようなエラーが発生する場合は、実機/エミュレータにアプリをインストールして上記コマンドを実行すると解消される。

android.util.AndroidExceptioin: NSTRUMENTATION_FAILED:

ポートフォワードの設定

クライアントとサーバー間ではHTTPでjsonをやり取りするため、そのための設定。

adb forward tcp:54129 tcp:54129

いよいよ実行

CalcActivityNativedriverTestを普通にJUnitで実行する。
このとき、いくつかハマったのでメモ。

java.net.SocketExceptionが発生する

org.openqa.selenium.WebDriverException: java.net.SocketException: Software caused connection abort: recv failed

こんな感じのエラーが発生。この場合は、先ほど実行したコマンドをたたきなおせばよい。

org.apache.http.conn.HttpHostConnectExceptionが発生する

org.openqa.selenium.WebDriverException: org.apache.http.conn.HttpHostConnectException: Connection to http://localhost:54129 refused

これは、端末を外してしまったときなどに遭遇する。ポートフォワードの設定を再度実行すればよい。

oqa.selenium.WebDriverExceptionが発生する

oqa.selenium.WebDriverException: [テスト対象Activity] in loader dalvik.system.PathClassLorg.openader[/data/app/[Activityのパッケージ]-1.apk:/data/app/[Activityのパッケージ]-1.apk]

最初とは別のプロジェクトで試そうとしたところ発生。最初のプロジェクトでは問題なく実行できる。
エラーメッセージを見ると、クラスローダが期待したapkを呼んでいないっぽい。なので、最初に試したAndroidアプリケーションを削除して再度実行すると、問題なく動いた。かわりに最初のプロジェクトが動かなくなった。
これは仕様なのかは解らないが、これだとCIツールと連携して複数のプロジェクトをテストできないので、ちょっとなんとかして欲しいところ。

感想など

Android標準のテストと比べて、使い慣れたJUnitでテストが書けるのがとにかくラク
また、robotiumと比べると、レイアウトを記述したxmlで定義したidを対象にテストが書けるところが良いと感じた(robotiumだと、ボタンのラベルを指定するなど、いまいちスマートじゃない気がする)。