Androidテストハンズオン

  • 125
    Like
  • 0
    Comment
More than 1 year has passed since last update.

これは「DevFest Tokyo 2016 秋のコミュニティ祭り!」の発表資料です。

本ハンズオンの内容

DroidKaigi2016のAndroidアプリについてEspressoを使ってUI周りのテストコードを書いていきます。

image

https://play.google.com/store/apps/details?id=io.github.droidkaigi.confsched

Step 0:準備編

cloneする

https://github.com/cattaka/droidkaigi2016 をcloneします。

Shellからcloneする場合

$ cd <作業用のディレクトリ>
$ git clone https://github.com/cattaka/droidkaigi2016.git

プロジェクトを開き、ビルドと実行できることを確認する

Step 1:テスト用のライブラリを確認する

使用するテスト用のライブラリは次のものです。

  • JUnit
    • いわずとしれたJava用のユニットテストライブラリです。
  • Mockito
    • テスト時に各クラスをMock可するためのライブラリです。
    • 具体的には通信処理のように、テスト中に本番の動きをされたら困るコンポーネントの動きを差し替えるために使用します。
  • Espresso
    • Android用のUIテスト用のライブラリです。
    • JUnitのテストコードの中で、Activityを起動したり、Viewを操作(クリックやスワイプなど)したり、表示内容を確認する機能があります。

app/build.gradle の設定(導入済み)

app/build.gradle の設定を確認してみましょう。

dependencies {
    androidTestCompile('com.android.support.test:runner:0.4.1') {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    androidTestCompile('com.android.support.test:rules:0.4.1') {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.1') {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    androidTestCompile('com.android.support.test.espresso:espresso-contrib:2.2.1') {
        exclude group: 'com.android.support', module: 'support-annotations'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude group: 'com.android.support', module: 'recyclerview-v7'
    }
    androidTestCompile('com.android.support.test.espresso:espresso-intents:2.2.1') {
        exclude group: 'com.android.support', module: 'support-annotations'
    }
    androidTestCompile 'org.mockito:mockito-core:1.9.5'
    androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.2'
}

excludeの記述は実際のアプリで使用しているライブラリが、テスト用ライブラリがビルドされたバージョンと食い違っているとき、それに引きづられてバージョンが変わったり、ビルドに失敗するのを回避するためのものです。

参考:ライブラリの依存関係

$ ./gradlew :app:dependencies
## 省略 ##
androidTestCompile - Classpath for compiling the androidTest sources.
+--- com.android.support.test:runner:0.4.1
|    +--- com.android.support.test:exposed-instrumentation-api-publish:0.4.1
|    \--- junit:junit:4.12
|         \--- org.hamcrest:hamcrest-core:1.3
+--- com.android.support.test:rules:0.4.1
|    \--- com.android.support.test:runner:0.4.1 (*)
+--- com.android.support.test.espresso:espresso-core:2.2.1
|    +--- com.squareup:javawriter:2.1.1
|    +--- com.android.support.test:runner:0.4.1 (*)
|    +--- com.android.support.test:rules:0.4.1 (*)
|    +--- javax.inject:javax.inject:1
|    +--- org.hamcrest:hamcrest-library:1.3
|    |    \--- org.hamcrest:hamcrest-core:1.3
|    +--- org.hamcrest:hamcrest-integration:1.3
|    |    \--- org.hamcrest:hamcrest-library:1.3 (*)
|    +--- com.google.code.findbugs:jsr305:2.0.1
|    +--- javax.annotation:javax.annotation-api:1.2
|    \--- com.android.support.test.espresso:espresso-idling-resource:2.2.1
+--- com.android.support.test.espresso:espresso-contrib:2.2.1
|    +--- com.android.support.test.espresso:espresso-core:2.2.1 (*)
|    \--- com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework:2.0
|         \--- org.hamcrest:hamcrest-core:1.3
+--- com.android.support.test.espresso:espresso-intents:2.2.1
|    \--- com.android.support.test.espresso:espresso-core:2.2.1 (*)
+--- org.mockito:mockito-core:1.9.5
|    +--- org.hamcrest:hamcrest-core:1.1 -> 1.3
|    \--- org.objenesis:objenesis:1.0
\--- com.google.dexmaker:dexmaker-mockito:1.2
     +--- com.google.dexmaker:dexmaker:1.2
     \--- org.mockito:mockito-core:1.9.5 (*)
## 省略 ##

Step 2:Activityを起動しよう

まずは単純にMainActivityを表示するだけテストを作りましょう。

はじめかた

$ git checkout devfest/step_1

テストクラスを作成する

app/src/androidTest/ の下にMainActivityTestというテスト用のクラスを作成します。
クラスに@RunWith(AndroidJUnit4.class)アノテーションを付加します。これはテストクラスをどのように実行するかを決めるものです。指定しなかった場合は、現在のAndroidの場合はAndroidJUnit4がデフォルトになっているので、それが使われます。

範囲を選択_003.png

MainActivityTest.java

@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

}

テストメソッドを作成する

テストメソッドを作成しましょう。
publicなメソッドに@Testアノテーションを付加することでテストメソッドになります。

    @Test
    public void check_start_activity() {
        /* ここにテストする内容を書く */
    }

MainActivityを起動させる

Activityを起動させるにはActivityTestRuleという、JUnit4のRuleを使用します。これは@Ruleアノテーションをつけてpublicなメンバ変数として、定義することで使用できるようになります。

    @Rule
    public ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>(MainActivity.class, false, false);

    @Test
    public void check_start_activity() {
        MainActivity activity = activityTestRule.launchActivity(null);
        /* ここにテストする内容を書く */
    }

実行してMainActivityが起動できているか確認する

Activityが実行状態になれているかを確認しましょう。
確認にはActivity#isFinishing()がfalseであることで判断します。
判定にはJUnitの機能であるassertThatというstaticメソッドを使用します。

    @Test
    public void check_start_activity() {
        MainActivity activity = activityTestRule.launchActivity(null);
        assertThat(
                "MainActivity is running",
                activity.isFinishing(),
                Matchers.is(false)
        );
    }

Step 2の変更点

Step 3:テスト時に環境を切り替えられるようにする

はじめかた

$ git checkout devfest/step_2

テスト実行するときに実際にサーバーに通信していたり、通常実行時に保存されたデータ(SQLiteやSharedPreferences)にアクセスしてしまうと、データの状態で挙動が変わってしまい、正しくテストをすることができません。こういった問題を避けるために、テスト中はそれらのコンポーネントを差し替えましょう。

差し替えが必要なもの

DroidKaigi2016アプリで該当するものは次の3つです。
- データベース(OrmaDatabaseクラスが該当)
- SharedPreferences
- 通信処理(DroidKaigiClientクラスが該当)

image

差し替えの手段

コンポーネントを差し替えるにはアプリ内で実行状態に応じて切り替えることになります。愚直にやるとif文などで都度切り替えることになりコードが煩雑になります。これを回避するにはDI(dependency injection)ライブラリを使います。
DroidKaigi2016アプリではDagger2が使用されており、これらのコンポーネントは全てDagger2を介してActivityやFragmentから扱うようになっています。このため、テスト時はDagger2の設定を切り替えることで、コンポーネントの差し替えが可能になります。

MainApplicationクラスのAppComponentを差し替えられるようにする

各コンポーネントはMainApplicationクラスのメンバ変数のAppComponentから提供されるようになっています。AppComponentはAppModuleクラスから生成されます。これをテスト時に差し替えられるようにするため、setterメソッドを追加します。

MainApplication.java

public class MainApplication extends Application {
    /* 他は省略 */

    @VisibleForTesting
    public void setAppComponent(AppComponent appComponent) {
        this.appComponent = appComponent;
    }
}

テスト用のコンポーネントを生成する処理を作る

テスト時にAppComponentの代わりに各コンポーネントを提供するTestAppComponentクラスを作成します。

SharedPreferencesとOrmaDatabaseは本番実行時と異なる場所にデータを保存するインスタンスを返すようになっており、resetメソッドで初期化できるようになっています。
通信処理を司るコンポーネントであるDroidKaigiClientについては、テスト用に偽物のデータを返すように作られたTestDroidKaigiClientを返すようになっています。

app/src/androidTest/java/io/github/droidkaigi/test/TestAppModule.java

public class TestAppModule extends AppModule {
    static final String TEST_SHARED_PREF_NAME = "test_preferences";

    private Context context;
    private RenamingDelegatingContext rdContext;

    public Tracker tracker;
    public SharedPreferences sharedPreferences;
    public OrmaDatabase ormaDatabase;
    public DroidKaigiClient droidKaigiClient;

    public TestAppModule(Application app) {
        super(app);
        context = app;
        rdContext = new RenamingDelegatingContext(context, "test_");

        GoogleAnalytics ga = GoogleAnalytics.getInstance(context);
        ga.setDryRun(true);
        tracker = ga.newTracker(BuildConfig.GA_TRACKING_ID);
        tracker.enableAdvertisingIdCollection(true);
        tracker.enableExceptionReporting(true);

        sharedPreferences = context.getSharedPreferences(TEST_SHARED_PREF_NAME, Context.MODE_PRIVATE);
        ormaDatabase = super.provideOrmaDatabase(rdContext);

        AssetManager assets = InstrumentationRegistry.getContext().getAssets();
        droidKaigiClient = new TestDroidKaigiClient(assets);
    }

    public void reset() {
        sharedPreferences.edit().clear().apply();
        rdContext.deleteDatabase(OrmaDatabaseBuilderBase.getDefaultDatabaseName(context));
    }

    public void shutdown() {
    }

    @Override
    public Tracker provideGoogleAnalyticsTracker(Context context) {
        return tracker;
    }

    @Override
    public SharedPreferences provideSharedPreferences(Context context) {
        return sharedPreferences;
    }

    @Override
    public OrmaDatabase provideOrmaDatabase(Context context) {
        return ormaDatabase;
    }

    @Provides
    public DroidKaigiClient provideDroidKaigiClient(OkHttpClient client) {
        return droidKaigiClient;
    }
}

TestDroidKaigiClientの実装について

DroidKaigiClient と TestDroidKaigiClient の構成の違いは次のようになります。

  • DroidKaigiClientはそれぞれ本物のWebサーバーと通信します。
  • TestDroidKaigiClient はテスト側のAssetsである app/src/androidTest/assets/ に入れたテストデータを擬似的な通信結果として返します。

image

public class DroidKaigiClient {
    /* 省略 */
    @VisibleForTesting
    public DroidKaigiClient() {
        service = null;
        googleFormService = null;
        githubService = null;
    }
    /* 省略 */
}

差し替えるためのJUnit4のRuleを作る

JUnit4のRuleは作成してテストクラスに適用すると、各テストメソッドの実行の開始や終了に処理を行うことができます。これを利用してテスト時にMainApplicationのAppComponentを差し替えるようにします。

app/src/androidTest/java/io/github/droidkaigi/test/IsolateEnvRule.java

public class IsolateEnvRule implements TestRule {
    public TestAppModule appModule;

    @Override
    public Statement apply(Statement base, Description description) {
        return statement(base, description);
    }

    private Statement statement(final Statement base, final Description desc) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                before();
                try {
                    base.evaluate();
                } finally {
                    after();
                }
            }
        };
    }
    private void before() {
        MainApplication application = (MainApplication) InstrumentationRegistry.getTargetContext().getApplicationContext();
        appModule = new TestAppModule(application);
        appModule.reset();
        AppComponent testAppComponent = DaggerAppComponent.builder()
                .appModule(appModule)
                .build();
        application.setAppComponent(testAppComponent);
    }
    private void after() {
        appModule.shutdown();
    }
}

Step 3の変更点

Step4:SessionFeedbackActivityでフィードバックを送信するテスト

はじめかた

$ git checkout devfest/step_3

SessionFeedbackActivityTestクラスを作る

  • SessionFeedbackActivityTestクラスを作成する
  • IsolateEnvRuleルールを適用する
  • ActivityTestRuleルールを適用する
  • テストメソッドを作る
    • SessionFeedbackActivityの起動に講演情報が必要なのでテストデータから取得する
    • Intentを生成する
    • SessionFeedbackActivity#createIntentメソッドをpublicにする
    • SessionFeedbackActivityを起動する
public class SessionFeedbackActivityTest {
    @Rule
    public IsolateEnvRule isolateEnvRule = new IsolateEnvRule();

    public ActivityTestRule<SessionFeedbackActivity> activityTestRule = new ActivityTestRule<>(SessionFeedbackActivity.class, false, false);

    @Test
    public void check_request_on_click() {
        Session session;
        {   // 偽物の講演情報を取得する
            List<Session> sessions = isolateEnvRule.appModule.droidKaigiClient.getSessions("ja").toBlocking().first();
            session = sessions.get(0);
        }

        Context targetContext = InstrumentationRegistry.getTargetContext();
        Intent intent = SessionFeedbackActivity.createIntent(targetContext, session);
        SessionFeedbackActivity activity = activityTestRule.launchActivity(intent);
    }
}

テキストを入力して送信ボタンを押す

EditTextに入力したり、ViewをクリックしたりといったUIの操作にはEspressoの機能を使用します。EspressoではonViewから始まるメソッドチェーンを用いて次のように操作を書くことができます。

espresso_onview.png

EditTextまでスクロールして文字を入力する

SessionFeedbackActivityにはR.id.other_comments_feedback_textというEditTextが存在しています。このEditTextまでスクロールし、文字を入力させるには次のように書きます。

Espresso.onView(ViewMatchers.withId(R.id.other_comments_feedback_text))
                .perform(
                        ViewActions.scrollTo(),
                        ViewActions.replaceText("Hogehoge Fugafuga"));

補足:replaceTextによく似たtypeTextというものがありますが、記号などを入力した際に機種によって文字が化けることがあるため私は使用を避けています。

onViewやwithIdはテストコードを書いていると頻繁に使うため、いちいちクラス名をつけていると読みにくくなってしまいます。幸いEspressonやJUnitはほとんどがstaticメソッドで構成されているため、クラス名はJava言語のstatic importsの機能を使うことで省略すると読みやすくなります。

        onView(withId(R.id.other_comments_feedback_text))
                .perform(
                        scrollTo(),
                        replaceText("Hogehoge Fugafuga"));

Android Studioではメソッドにカーソルを当ててIntention Actions(Alt+Enter)を実行すると簡単にstatic importsにすることができます。

image

送信ボタンを押す

onViewを使って次のように書けます。

        onView(withId(R.id.submit_feedback_button))
                .perform(scrollTo(), click());

5段階評価の操作を行う

このアプリでは5段階評価のNumberRatingBarというSeekBarを継承したカスタムViewを使用しています。残念ながらEspressoの標準の機能ではこれを操作するViewActionは提供されていません。

しかし次のsetProgressメソッドのように、カスタムのViewActionを生成するものを作ることで簡単に対応することができます。

app/src/androidTest/java/io/github/droidkaigi/test/CustomViewAction.java

public class CustomViewAction {
    public static ViewAction setProgress(final int progress) {
        return new ViewAction() {
            @Override
            public void perform(UiController uiController, View view) {
                ((SeekBar) view).setProgress(progress);
                //or ((SeekBar) view).setProgress(progress);
            }

            @Override
            public String getDescription() {
                return "Set a progress";
            }

            @Override
            public Matcher<View> getConstraints() {
                return ViewMatchers.isAssignableFrom(SeekBar.class);
            }
        };
    }
}

これを使うと5段階評価の処理はonViewを使って次のように書けます。

        onView(withId(R.id.relevant_feedback_bar))
                .perform(scrollTo(), setProgress(1));
        onView(withId(R.id.as_expected_feedback_bar))
                .perform(scrollTo(), setProgress(2));
        onView(withId(R.id.difficulty_feedback_bar))
                .perform(scrollTo(), setProgress(3));
        onView(withId(R.id.knowledgeable_feedback_bar))
                .perform(scrollTo(), setProgress(4));

送信しようとした内容を確認する

送信ボタンを押した時、アプリは通信しようとするので、それがUI操作に沿ったものであるか確認しましょう。このときにDroidKaigiClient#submitSessionFeedbackメソッドが叩かれるので、その引数をチェックします。

手順は次のようになります。
- IsolateEnvRuleが持つdroidKaigiClientを、Mockitoの機能を使し、引数を後から確認できるようにする
- UI操作後、IsolateEnvRuleが持つdroidKaigiClientから渡された引数を取り出す
- 取り出した引数の中身を確認する

droidKaigiClientの引数を後から確認できるようにする

        DroidKaigiClient droidKaigiClient = Mockito.mock(
                DroidKaigiClient.class,
                new ForwardsInvocations(isolateEnvRule.appModule.droidKaigiClient)
        );
        isolateEnvRule.appModule.droidKaigiClient = droidKaigiClient;

渡された引数を取り出す

        ArgumentCaptor<SessionFeedback> captor = ArgumentCaptor.forClass(SessionFeedback.class);
        Mockito.verify(droidKaigiClient)
                .submitSessionFeedback(captor.capture());
        SessionFeedback value = captor.getValue();

取り出した引数の中身を確認する

        assertThat(value.relevancy, Matchers.is(1));
        assertThat(value.asExpected, Matchers.is(2));
        assertThat(value.difficulty, Matchers.is(3));
        assertThat(value.knowledgeable, Matchers.is(4));
        assertThat(value.comment, Matchers.is("Hogehoge Fugafuga"));

Step 4の変更点

Step 5:MainActivityから講演情報を表示する操作のテスト

MainActivityには講演のリストがあり、それをクリックするとSessionDetailActivityに遷移します。これをテストするにはActivityMonitorを使用することで確認することができます。

はじめかた

$ git checkout devfest/step_4

RecyclerViewの1つをクリックする

onViewを使って書けますが、2つ課題があります。

  • クリックしたいのはRecyclerViewの1つの項目なので、クリックしたいViewをwithIdで指定できない
  • MainActivity上にはR.id.recycler_viewが複数あるため、「どの」RecyclerViewなのかをwithIdで指定できない

RecyclerViewの1つの項目をクリックするには

EspressoでRecyclerViewを操作するのに、そのままでは泥臭いことをしなければならないのですが、幸いそのためのespresso-contribという追加ライブラリが提供されています。

このライブラリを使うとRecyclerViewの特定の要素にスクロールしたり、クリックすることが次のように書けます。

            onView(<ここにRecyclerViewを指定する>)
                    .perform(RecyclerViewActions.scrollToPosition(5));
            onView(<ここにRecyclerViewを指定する>)
                    .perform(RecyclerViewActions.actionOnItemAtPosition(5, click()));

関連情報:RecyclerViewの1つの項目の内部のViewへのperformやcheckを行う方法

特定のRecyclerViewを指定する

目的のRecyclerViewのインスタンスが取得できれば、withIdの代わりにMatchers.equalToが利用できます。

これを使うとRecyclerViewの特定の要素にスクロールしたり、クリックすることが次のように書けます。

        View recyclerView;
        {   // 少々強引だがRecyclerViewのインスタンスを取得する
            ViewPager viewPager = (ViewPager) activity.findViewById(R.id.view_pager);
            FragmentStatePagerAdapter adapter = (FragmentStatePagerAdapter) viewPager.getAdapter();
            Fragment sessionsTabFragment = adapter.getItem(0);
            recyclerView = sessionsTabFragment.getView();
        }

        onView(Matchers.equalTo(recyclerView))
                    .perform(RecyclerViewActions.scrollToPosition(5));
        onView(Matchers.equalTo(recyclerView))
                    .perform(RecyclerViewActions.actionOnItemAtPosition(5, click()));

SessionDetailActivityが起動したことを確認する

Activityが起動したか否かはActivityMonitorを使うことで確認できます。
対象となるActivity用のActivityMonitorを作成し、Instrumentation#addMonitor()で登録すると、それより後に起動したActivityのインスタンスを取得できるようになります。
ActivityMonitorの作成と登録は次のように書けます。

        Instrumentation.ActivityMonitor monitor = new Instrumentation.ActivityMonitor(SessionDetailActivity.class.getCanonicalName(), null, false);
        InstrumentationRegistry.getInstrumentation().addMonitor(monitor);

そしてUIの操作後に起動したかどうかは次のように書けます。

            Activity nextActivity = monitor.waitForActivityWithTimeout(5000);
            assertThat(nextActivity, Matchers.is(Matchers.notNullValue()));
            nextActivity.finish();

これらを繋げると次のようになります。

        Instrumentation.ActivityMonitor monitor = new Instrumentation.ActivityMonitor(SessionDetailActivity.class.getCanonicalName(), null, false);
        InstrumentationRegistry.getInstrumentation().addMonitor(monitor);
        try {
            onView(Matchers.equalTo(recyclerView))
                    .perform(RecyclerViewActions.scrollToPosition(5));
            onView(Matchers.equalTo(recyclerView))
                    .perform(RecyclerViewActions.actionOnItemAtPosition(5, click()));

            Activity nextActivity = monitor.waitForActivityWithTimeout(5000);
            assertThat(nextActivity, Matchers.is(Matchers.notNullValue()));
            nextActivity.finish();
        } finally {
            InstrumentationRegistry.getInstrumentation().removeMonitor(monitor);
        }

Step 5の変更点

Step 6:gradleからテストを実行する

はじめかた

$ git checkout devfest/step_5

コンソールからテストを実行する

コンソールからテストを実行するには「$ ./gradlew \connected[BuildVariant名]AndroidTest」を実行します。
今回はBuildVariantに「DevelopDebug」を使用しますので次のようになります。

$ ./gradlew connectedDevelopDebugAndroidTest

これを実行すると、テスト結果のレポートのxmlやhtmlが生成されます。

  • app/build/outputs/androidTest-results/connected/flavors/DEVELOP/TEST-Nexus 4 - 5.1.1-app-DEVELOP.xml
  • app/build/reports/androidTests/connected/flavors/DEVELOP/index.html
    • image

コードカバレッジを生成する

テスト時のコードカバレッジを計測するにはapp/build.gradleに次のオプションを追加します。

app/build.gradle

android {
    /* 省略 */
    buildTypes {
        debug {
            /* 省略 */
            testCoverageEnabled true
        }
        /* 省略 */
    }
    /* 省略 */
}

そして「./gradlew create[BuildVariant名]CoverageReport」を実行します。

$ ./gradlew createDevelopDebugCoverageReport

これを実行すると、コードカバレッジのレポートのecファイルやhtmlが生成されます。

  • app/build/outputs/code-coverage/connected/flavors/DEVELOP
  • app/build/reports/coverage/develop/debug/index.html
    • image
    • image

まとめ

  • テストを書くにはアプリの状態を作り出すことが大事
    • 内部的な状態:データベース、SharedPreferences
    • 外部に依存する状態:通信処理
  • Dagger2を使えばコンポーネントを差し替えられる
  • Espressoを使うとUI操作の処理が簡潔に書ける

関連書籍

アンドロイドアカデミア - TechBooster



  • 「Androidテストレシピ」にAndroidのテストの書き方のノウハウをまとめました。

Android改善プログラミング - TechBooster




- 「Espressoはアプリが長生きする秘訣」にEspressoの使い方や機能についてまとめました。