Android + HiltのUI自動化テスト時に、テストケースごとにDI設定を変更する

こんにちは。
現在、業務でAndroidのUIテストを取り扱っています。
その中で、「テストケース毎にHiltのDI設定を切り替えることができないか？」と疑問に思い調査しました。
備忘録としてまとめていきます。

# テスト時にDI設定を切り替える
アプローチは2種類あります。
- 統合テスト全体で切り替える
- テストクラス毎に切り替える

まずは両者に共通で必要な事項を説明します。

## 共通事項
[公式のリファレンス](https://developer.android.com/training/dependency-injection/hilt-testing)に載っている中で関係あるものを抽出しています。
詳しく知りたい方はリンク先を見ていただければ。
### テスト用の依存関係追加
モジュールの依存関係に以下を追加します。
バージョンは適宜読み替えてください。
``` Groovy:
dependencies {
    // For instrumented tests.
    androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
    // ...with Kotlin.
    kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'
}
```

### UIテストの設定
テストクラスの先頭に`@HiltAndroidTest`アノテーションを付与してください。
これがあると、各テストがHiltコンポーネントを生成します。

テストクラスに`HiltAndroidRule`を追加する必要もあります。これは、コンポーネントの状態を管理し、テストのインジェクションに使用されるものです。

``` Kotlin:
@HiltAndroidTest
class SampleTest {

  @get:Rule
  var hiltRule = HiltAndroidRule(this)

  // UI tests here.
}
```

### テストランナーの設定
統合テストでHiltテストアプリを使用するには、新しいテストランナーを設定する必要があります。
これにより、プロジェクト内のすべての統合テストでHiltが機能するようになります。次の手順を行います。

1. androidTest フォルダに AndroidJUnitRunner を拡張するカスタムクラスを作成します。
2. newApplication 関数をオーバーライドし、生成される Hilt テストアプリの名前を渡します。

``` Groovy:
// A custom runner to set up the instrumented application class for tests.
class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}
```

次に、このテストランナーをモジュールのGradleファイルで設定します。次のように完全なクラスパスを使用してください。
``` Groovy: 
android {
    defaultConfig {
        // Replace com.example.android.dagger with your class path.
        testInstrumentationRunner "com.example.android.dagger.CustomTestRunner"
    }
}
```

## 置き換える前のDI設定（モジュール）
今回は以下の様なモジュールを想定します。
``` Kotlin:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Singleton
    @Binds
    abstract fun bindSampleRepository(
        impl: SampleRepositoryImpl
    ): SampleRepository

    @Singleton
    @Binds
    abstract fun bindTestRepository(
        impl: TestRepositoryImpl
    ): TestRepository
}
```

## 統合テスト全体で置き換える
`androidTest`フォルダ配下に以下の様なクラスを作成します。
``` Kotlin:
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {

    @Singleton
    @Binds
    abstract fun bindSampleRepository(
        impl: FakeSampleRepository
    ): SampleRepository

    @Singleton
    @Binds
    abstract fun bindTestRepository(
        impl: FakeTestRepository
    ): TestRepository
}
```
これにより、統合テスト内では`RepositoryModule`で定義した設定の代わりに`FakeRepositoryModule`で定義した設定に基づき依存性の注入が行われます。

## 単一のテストクラス内でのみ置き換える
テストクラスの先頭に
`@UninstallModule`アノテーションを付与することで、そのテストクラス内で本番環境のDI設定を無視することができます。
``` Kotlin:
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class SampleTest { ... }
```
次に、テストクラス内でDI設定を追加する必要があります。
これは2種類方法があります。
### テストクラス内にモジュールを作成する
``` Kotlin:
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class SampleTest {

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

    @Singleton
    @Binds
    abstract fun bindSampleRepository(
        impl: FakeSampleRepository
    ): SampleRepository

    @Singleton
    @Binds
    abstract fun bindTestRepository(
        impl: FakeTestRepository
    ): TestRepository
  }

  ...
}
```
### @BindValueアノテーションを利用する
テスト内でオブジェクトのモックをしたい場合などに便利です。
``` Kotlin:
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class SampleTest {

    @BindValue
    val mockSampleRepository = mockk<SampleRepository>()

    // もちろんモックじゃなくてもよい
    @BindValue
    val fakeTestRepository: TestRepository = FakeTestRepository()
}
```
この方法で、単一のテストクラス内でのみ適用するモジュールを変更することができます。

::: note warn
なお、`@UninstallModule`で無効にできるのは`@InstallIn`のモジュールだけです。
前述の`@TestInstallIn`モジュールをアンインストールしようとするとコンパイルエラーが発生します。
:::

# テストケース毎にDI設定を切り替えたい
上記の法方では、テスト全体や、テストクラス毎にDI設定を切り替えることができますが、テストケース毎に切り替えることはできません。
モジュール内にフラグとなる変数を定義し、フラグによって注入するインスタンス(あるいはそのプロパティ)を変更するようにすればよいのではと試しました。
``` Kotlin:androidTestファイル配下にテストモジュールを作成
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
object TestRepositoryModule {
    var fakePattern = 0
    
    @Singleton
    @Provide
    fun providesSampleRepository(): SampleRepository {
        return FakeSampleRepository(fakePattern)
    }

    @Singleton
    @Provide
    fun providesTestRepository(): TestRepository {
        return FakeTestRepository(fakePattern)
    }
}
```
``` Kotlin:Fakeクラスの例
class FakeSampleRepository @Inject constructor(
    private val fakePattern: Int
): SampleRepository {

    // fakePatternの値によって実装を変える
    override fun method(): String {
        return when(fakePattern) {
            0 -> "zero"
            1 -> "one"
            2 -> "two"
            else -> "error"
        }
    }
}
```
テストコードでは、各テストの開始時にテストケースに合わせてfakePatternを変更します。
``` Kotlin:

    @Test
    fun methodTest_one() {
        TestRepositoryModule.fakePattern = 1
        // any tests...
    }
```
この方法でテストケース毎にDI設定を切り替えることができました。

:::note
Singletonであっても各テストケースのたびにに依存性注入が行われるので、連続で2個以上のテストを実行する場合も大丈夫です。
:::

:::note warn
依存性の注入は、はじめてそのオブジェクトが参照されるときに行われるので、必ず参照の前にfakePatternを変更してください。
例えば、`@Before`アノテーションでテスト実行前のセットアップ時にオブジェクトの参照が行われないように注意してください。
:::