androidのテストを書きたい

テストのコードラボやドキュメントを読みながらテストの書き方を勉強して自分で作成したアプリにテストコードを導入してみた。
ただコードラボは書き方が変わっている部分があったり、使っているライブラリなどによって躓いたこともあったので、調べたことや参考にした記事などまとめていく。
Advanced Android in Kotlin 05.1: Testing Basics  |  Android Developers
Advanced Android in Kotlin 05.2: Introduction to Test Doubles and Dependency Injection  |  Android Developers
Advanced Android in Kotlin 05.3: Testing Coroutines and Jetpack integrations  |  Android Developers


LiveData

InstantTaskExecutorRule()を追加する

アーキテクチャコンポーネント(AC)関連のテストの時に追加する。
ACがbackground executorをテスト用に変換して、テスト結果が同期的に順番に得られるようにする。

 testImplementation "android.arch.core:core-testing:1.1.0"
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()

ObserverForever()で値を監視する

これを使うことでlifecyclerOwnerなしでliveDataの監視ができる。

val observer = Observer<Int>{}

try {
    sampleViewModel.data.observeForever(observer)
    ...
}finally {
    sampleViewModel.data.removeObserver(observer)
}

参考
Advanced Android in Kotlin 05.1: Testing Basics  |  Android Developers
InstantTaskExecutorRule  |  Android Developers
LiveDataのUnitTest. ちょっとやっかいな部分もあったので、LiveDataのテストをどう書くのかまとめ… | by Kenji Abe | Medium
以下の記事はliveDataのobserverの設定と解除、値の取得をUtilでまとめる方法
Unit-testing LiveData and other common observability problems | by Jose Alcérreca | Android Developers | Medium


Coroutine

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"

runTest

コルーチンを含むテスト全体をrunTestでラップする。

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

メインディスパッチャの変更

UnitTestではローカル JVM 上で実行されるため、Android UI スレッドをラップする Mainディスパッチャは使用できず、テスト対象のコードがメインスレッドを参照すると、単体テスト中に例外がスローされる。
そのためMainディスパッチャをTestDispatchersに置き換える必要がある。
この操作は各UnitTestで重複するため、テストルールとして抽出しておく。

class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest {
        ...
    }
}

TestDispatchers

新しく開始されるコルーチンのスケジューリング方法が異なる2つのTestDispatchersがある。

  • StandardTestDispatcher

テストスレッドが空いているときにコルーチンを実行する。
テスト中の新規コルーチンの動作を細かく制御でき、本番環境のコードにおけるコルーチンのスケジューリングに似ているため、同時実行を重視してみる時に使用。

  • UnconfinedTestDispatcher

新しいコルーチンが開始されると、現在のスレッドで積極的に開始する。
コルーチンを使用した単純なテストをするだけならこっちで良い。

参考
Testing Kotlin coroutines on Android  |  Android Developers


Hilt

androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'

@HiltAndroidTestとHiltAndroidRuleを追加する

Hilt を使用する UI テストには @HiltAndroidTest アノテーションをつけHilt コンポーネントを生成させる。
テストクラスに HiltAndroidRuleを追加するが、他にも追加するテストルールがある時はルールを1つにまとめるか、HiltAndroidRuleを最初に実行する。

@get:Rule
  var rule = RuleChain.outerRule(HiltAndroidRule(this)).
        around(SettingsActivityTestRule(...))
@get:Rule(order = 0)
var HiltAndroidRule = HiltAndroidRule(this)

@get:Rule(order = 1)
var settingsActivityTestRule = SettingsActivityTestRule()

Applicationを設定する

インストゥルメンテーションテストの場合は新しいテストランナーを作成してGradleファイルでテストランナーを設定する。 (Robolectricを使ったテストの時はまた違った書き方だけどやってみてないので省略)

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}
android {
    defaultConfig {
        testInstrumentationRunner "com.example.android.dagger.CustomTestRunner"  //作成したテストランナーのclass path
    }
}

テストに型を注入する

@Injectをつけた型を注入するよう、hiltRule.inject()を実行する。

@Inject
lateinit var analyticsAdapter: AnalyticsAdapter

@Before
fun init() {
    hiltRule.inject()
}

本番環境とは違うモックインスタンスを注入したい場合は、擬似依存関係を定義した新しいHiltモジュールを作成して、上記と同様に注入する。

@Module
@TestInstallIn(
    components = [SingletonComponent::class],  
    replaces = [AnalyticsModule::class]  //置き換えたい本番用のHiltモジュールを指定する
)
abstract class FakeAnalyticsModule {

  @Singleton
  @Binds
  abstract fun bindAnalyticsService(fakeAnalyticsService: FakeAnalyticsService): AnalyticsService 
  // FakeAnalyticsServiceを代わりに使うようにバインディング
}

これでHiltを使ったテスト全てでモックのインスタンスに置き換わる。 単一のテストだけ置き換えたい時は、テストに@UninstallModulesアノテーションをつけてモジュールを使わないように設定し、そのテスト内で新しくモジュールを定義する。

@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {

  @Module
  @InstallIn(SingletonComponent::class)
  abstract class TestModule {

    @Singleton
    @Binds
    abstract fun bindAnalyticsService(
      fakeAnalyticsService: FakeAnalyticsService
    ): AnalyticsService
  }

※余談
実行時「error: cannot find symbol import dagger.hilt.android.internal.testing.root.DaggerDefault_HiltComponents_SingletonC;」が出た。
(これと同じエラーHilt, androidTest build failed in versions >= 2.40.4 · Issue #3162 · google/dagger · GitHub) 依存関係まわりを色々変更してみようと試行錯誤していたけど、自分の場合はFakeで作ったクラス(上のコードでいうFakeAnalyticsService)に@Inject constructor() をつけ忘れてただけだった。

参考
Hilt testing guide  |  Android Developers