これは「DevFest Tokyo 2016 秋のコミュニティ祭り!」の発表資料です。
本ハンズオンの内容
DroidKaigi2016のAndroidアプリについてEspressoを使ってUI周りのテストコードを書いていきます。
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がデフォルトになっているので、それが使われます。
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 3:テスト時に環境を切り替えられるようにする
はじめかた
$ git checkout devfest/step_2
テスト実行するときに実際にサーバーに通信していたり、通常実行時に保存されたデータ(SQLiteやSharedPreferences)にアクセスしてしまうと、データの状態で挙動が変わってしまい、正しくテストをすることができません。こういった問題を避けるために、テスト中はそれらのコンポーネントを差し替えましょう。
差し替えが必要なもの
DroidKaigi2016アプリで該当するものは次の3つです。
- データベース(OrmaDatabaseクラスが該当)
- SharedPreferences
- 通信処理(DroidKaigiClientクラスが該当)
差し替えの手段
コンポーネントを差し替えるにはアプリ内で実行状態に応じて切り替えることになります。愚直にやるとif文などで都度切り替えることになりコードが煩雑になります。これを回避するにはDI(dependency injection)ライブラリを使います。
DroidKaigi2016アプリではDagger2が使用されており、これらのコンポーネントは全てDagger2を介してActivityやFragmentから扱うようになっています。このため、テスト時はDagger2の設定を切り替えることで、コンポーネントの差し替えが可能になります。
MainApplicationクラスのAppComponentを差し替えられるようにする
各コンポーネントはMainApplicationクラスのメンバ変数のAppComponentから提供されるようになっています。AppComponentはAppModuleクラスから生成されます。これをテスト時に差し替えられるようにするため、setterメソッドを追加します。
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/ に入れたテストデータを擬似的な通信結果として返します。
- TestDroidKaigiClientの実装はこちら
- テスト用データ
- app/src/androidTest/assets/DroidKaigiService/sessions_ja.json
- app/src/androidTest/assets/GithubService/contributors.json
- DroidKaigiClientクラスはデフォルトコンストラクタが無いため、@VisibleForTestingをつけたものを追加します
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();
}
}
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から始まるメソッドチェーンを用いて次のように操作を書くことができます。
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にすることができます。
送信ボタンを押す
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 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 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
コードカバレッジを生成する
テスト時のコードカバレッジを計測するには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
まとめ
- テストを書くにはアプリの状態を作り出すことが大事
- 内部的な状態:データベース、SharedPreferences
- 外部に依存する状態:通信処理
- Dagger2を使えばコンポーネントを差し替えられる
- Espressoを使うとUI操作の処理が簡潔に書ける
関連書籍
アンドロイドアカデミア - TechBooster
- 「Androidテストレシピ」にAndroidのテストの書き方のノウハウをまとめました。