お茶漬けびより

"あなたに教わったことを、噛んでいるのですよ" 五等分の花嫁 7巻 「最後の試験が五月の場合」より

Android コンポーネント(Activity)の単体テスト

最近はスキレットで肉を焼くのに地味にハマっています。

f:id:pickles-ochazuke:20200508000431j:plain

さて今回は、Android のテストについて書きます。一般的なテストについてはここでは解説しません。

テストのピラミッド

テストは大きく分けて3つの層に分かれます。ピラミッドで表しているのをよく見ます。

developer.android.com

ピラミッドなので、一番下が太く、一番上が細い三角形△になっています。 太さはそのまま全体の中で占める割合を表しています。上から順番に並べると

  • 大規模テスト(UI テスト(E2Eテスト))
  • 中規模テスト(Integration(結合) テスト)
  • 小規模テスト(Unit(単体) テスト)

の順番です。一般的には、上に行くほど本番運用の再現性が高くなりますが、テストを開始してから完了するまでの時間(実行時間)や保守およびデバッグの労力が増えます。 先に挙げたURLのドキュメントを読むと以下のような割合が推奨されています。

一般的には、小規模テスト 70%、中規模テスト 20%、大規模テスト 10% の割合でテストを作成することをおすすめします。

今回作成するテストは、小規模テストにあたる単体テストです。

Android のテスト概要

Android のテストの実行方法は大きく分けて2種類あります。

  • 実機、エミュレータで実行するテスト
  • ローカルマシン内で実行するテスト

これは、先に挙げたピラミッドのどれかに当てはまるわけではありません。各層でどちらのテストも行うことが出来ます。

実機、エミュレータで実行するテスト

実際の使用に近いテストですので、本番運用の再現度が高いです。ただし、実行するまでの時間、テスト実行から完了までの時間が長くなります。

エミュレータは、Android Studio 標準で備えてある Android Emulator を使うのが一般的でしょう。

テストコードは、app/src/androidTest に配置されます。

インストゥルメントテスト、インストゥルメント化テストとも呼ばれ、意味的には実機テストのことだと思います。といっても実機に限らずエミュレータでも行えるので実機に近いテストといった感じでしょうか。

ローカルマシン内で実行するテスト

ローカルマシン、つまり開発しているPC内で行うテストです。テスト対象がAndroid に依存しないコードであれば、一般的な Java, Kotlin のコードをテストするように行えますが、Android フレームワークに依存する場合は、Robolectric のようなフレームワーク(テストフレームワーク?)を利用して行う必要があります。

Robolectric というのは、先と似た話になりますが、Android フレームワークに依存したコードを JVM 上で実行するためのフレームワークです。そのため、実機やエミュレータのテストと比べると再現性は落ちてしまいますが、実行速度は Android に依存しないテストに近い速度で実行できます。

robolectric.org

テストコードは app/src/test に配置されます。

また、Robolectric はテストを行うための API(ライブラリ?)を提供していますが、ここでは Robolectric は実行環境としてだけ使用し、テストコードは、AndroidX Test API を使用します。テストコードを AndroidX Test API に統一すると一つのソースコード単体テストと実機テストの両方でテストできるようになります。ここでは紹介しません(そのやり方を知らないので)。

参考

developer.android.com

Espresso とは

Espresso とは、Android UI テストのためのライブラリです。AndroidX に含まれています。Espresso を使うことで簡潔に書くことができます。同期的に処理が行われるので、信頼性の高いテスト結果が得られます

Espresso は4つの主なコンポーネントがあります。

  • Espresso
  • ViewMatchers
  • ViewActions
  • ViewAssertions

Espresso

onViewonData メソッドを使ってビューとやり取りするためのエントリポイントです。

ViewMatchers

Matcher<? super View> インタフェースを実装するオブジェクトのコンポーネントです。onView メソッドにこのコンポーネントを一つ以上渡して現在のビュー階層からビューを特定します。一意に特定できない場合は、例外が投げられます。

ViewActions

ViewInteraction.perform メソッドに渡すことができる ViewAction オブジェクトのコレクションです。

ViewAssertions

ViewInteraction.check メソッドに渡すことができる ViewAssertion オブジェクトのコレクションです。アサーションが失敗した場合、例外が投げられます。

Hamcrest

Espresso のコンポーネントではないですが、Espresso を使うときによく一緒に使われます。マッチャーオブジェクトを記述するためのフレームワークです。

hamcrest.org

Espresso を使った UI のテストコードは以下のようになります。

Espresso.onView(ViewMatchers.withId(R.id.helloWorld))
    .check(ViewAssertions.matches(ViewMatchers.withText("Hello World!")))

ViewMatchers.withId() でビューを特定する条件を指定し、onView() に渡します。次に check() の引数に指定した条件が指定のビューに一致するか検証します。ここでは、特定したビューが "Hello World!"という文字列を持っているか検証しています。

実践

ここからは、プロジェクトを作成し、実際にテストコードを書いてみます。

Android Studio のバージョンは、3.6.3 です。

プロジェクトの作成

次のような設定をして、プロジェクトを作成します。

  • Project Template
    • EmptyActivity
  • Configure
    • Name
      • ComponentTestExample2
    • Language
      • Kotlin
    • Minimum SDK
    • Use legacy android.support libraries
      • チェック入れない

環境

今回実行する環境です。

  • Android Gradle Plugin Version
    • 3.6.3
  • Gradle Version
    • 5.6.4
  • Kotlin Version
    • 1.3.71
  • Compile Sdk Version
    • 29
  • BuildT Tools Version
    • 29.0.0

プロジェクトを作成したら、 app/src/test/ExampleUnitTest.kt を開き、テストを実行します。ソースコード内の行数の右隣に緑の三角が横に向いたようなアイコンがあると思います。それをクリックすることで実行できます。

developer.android.com

テストの作成

先に実行したテストは、プロジェクトの内容と関係ないテストなので、MainActivity をテストするためのファイルを作成します。

テスト対象のファイルを開き(MainActivity.kt)、開いたら Ctrl+Shift+TWindowsの場合)を押します。すると図のようなメニューが開くので、Create New Test をクリックします。

f:id:pickles-ochazuke:20200507231427p:plain

f:id:pickles-ochazuke:20200507231517p:plain

Testing libraryJUnit4 にします。

f:id:pickles-ochazuke:20200507231539p:plain

OK をクリックするとChoose Destination Directory ウィンドウが開くので、/app/src/test/... の方を選択します。OK をクリックすると指定したディレクトリにテストファイルが作成されます。作成されたMainActivityTest.kt ファイルの中身は以下のようになっていました。

package com.example.ochadukebiyori.componenttestexample2

import org.junit.Assert.*

class MainActivityTest

テストの追加

MainActivity のレイアウトファイルを見ると画面の真ん中に "Hello World!" という文字が表示されているので、これをテストしたいと思います。レイアウトファイルを開いたついでに、"Hello World!" を表示しているTextViewhelloWorldという id を指定します。

テストは、@Test が付与されたメソッドがテストになります。MainActivityTest.kt を以下のようにします。

package ...
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    @Test
    fun 起動したらHelloWorldが表示されるべき() {
        onView(withId(R.id.helloWorld))
            .check(matches(withText("Hello World!")))
    }
}

@RunWith でテストランナーを指定します。AndroidJUnit4Android の標準的なテストランナーです。テストのメソッド名はわかりやすさを重視するため日本語で書いています。テスト内容は、テスト対象のビューを特定する id(helloWorld)がテキストに "Hello World!" という文字列を持っているか検証しています。

Espresso や Robolectric のようなライブラリは、デフォルトでは使えないので、build.gradle を編集し、モジュールを追加します。

dependencies {
    ...
    testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
    testImplementation 'androidx.test.ext:junit-ktx:1.1.1'
    testImplementation 'org.robolectric:robolectric:4.3.1'
    ...
}

実行すると次のようなエラーが出ると思います(ワーニングも出ますがそちらは無視します)。

java.lang.RuntimeException: No activities found. Did you forget to launch the activity by calling getActivity() or startActivitySync or similar?

これは、Activity が作成されていないのが原因です。次のコードを追加することでアクティビティが作成されます。

import androidx.test.core.app.launchActivity

...
class MainActivityTest {
  val scenario = launchActivity<MainActivity>()
  ...
}

launchActivity() を使うためには、モジュールを追加する必要があるので、build.gradle に次のように追加します。

dependencies {
  ...
  testImplementation 'androidx.test:core-ktx:1.2.0'
  ...
}

ActivityScenario は、アクティビティのライフサイクルのための API を提供してくれるモジュールです。

developer.android.com

実行すると次のようなエラーが出るかも知れません(あるいは、実行する前に赤い波線で示されているかも)

Cannot inline bytecode built with JVM target 1.7 into bytecode that  is being built with JVM target 1.6

これは、launchActivity()JDK1.7 以上じゃないと使えないためです。次のように build.gradle にターゲットを指定します。

android {
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

ついでに Robolectric でリソースを使えるようにするために以下も行います。

リソースを使用する場合は、次のように includeAndroidResources オプションを有効にします。基本的にビューの特定は id で行うため、必須になります。しかし、Android Studio 3.4 以上はデフォルトで有効になっている?ような記述が見られるので必要ないかもしれません。ですが、自分の環境では指定していない場合、エラーが出たので追加しています。

android {
  ...
  testOptions {
      unitTests {
          includeAndroidResources = true
      }
  }
}

gradle を同期して、実行すると次のようなエラーが出るかもしれません。

java.lang.UnsupportedOperationException: Failed to create a Robolectric sandbox: Android SDK 29 requires Java 9 (have Java 8)

これは、自分はあまり理解出来ていないのですが、 Robolectric は、ターゲットバージョンに合わせて環境が作られます。今回だと Android SDK 29 です。Robolectric を 29 で動かす場合は、 JDK が 9 以上である必要があるそうです。ですが、今は JDK 8 で動かしているため上記のようなエラーが出ます。

ここでは、Robolectric でのターゲットバージョンを 28 にすることで回避します。テストの再現性が落ちるので良くないですが、練習のため簡単な方法で回避します(本当は JDK 9 以上にするのが良いと思います)。

app/src/test/resources ディレクトリを追加し、そのディレクトリ内に robolectric.properties ファイルを追加します。そして、以下を追記します。

sdk=28

再度実行するとテストが通るはずです。

ActivityScenario はデバイスの状態を自動でクリーンアップ(初期化)しないため、テスト終了後もアクティビティが実行され続ける可能性があります。ですので、テストの最後には次のように明示的にクローズします。

...
  @Test
  fun 起動したらHelloWorldが表示されるべき() {
      onView(withId(R.id.helloWorld))
          .check(matches(withText("Hello World!")))
      scenario.close()
  }
...

再度テストを実行し、テストが通ることを確認します。

テストコードのリファクタリング

今回の目的は達成しましたが、途中で出てきた ActivityScenario は閉じることを忘れるとテストコードに予期せぬバグが発生する可能性があるため、それを避けるように修正します。

ActivityScenario は、テスト終了後もアクティビティを実行し続ける可能性があると書きました。このActivityScenario を管理してくれるクラスがいます。それは ActivityScenarioRule です(昔は、ActivityTestRuleでしたが、今は非推奨になっています)。

developer.android.com

このクラスを次のように書いておくと、テストの開始前と終了後にアクティビティの作成と開放を行ってくれます。

...
import org.junit.Rule
import androidx.test.ext.junit.rules.activityScenarioRule
...
class MainActivityTest {
    @get:Rule
    var rule= activityScenarioRule<MainActivity>()
...
}

先程追加した ActivityScenario の処理は不要になったので、削除しています。

テストを実行して、通るか確認しましょう。

ActivityScenarioRule は、ActivityScenario を持っているので次のように参照する事ができます。

val scenario = rule.scenario

アクティビティは自動で終了してくれますが、手動でアクティビティを終了しても問題ありません。

developer.android.com

お疲れさまでした。以上です。

ここまでの状態を Github の方に上げています。ブランチは ActivityUnitTestExample_1 です。

github.com

続きを書きました。

pickles-ochazuke.hatenablog.com

雑記

FGO アニメ見終わりました。マーリンって名が出るたびになんか聞き覚えあるなぁと思っていたのですが、ドラクエ5のまほうつかいが仲間になったときの名前でした。通りで魔法使いっぽい名前してるなと思いました。