コンポーザブルのテストを爆速にする方法

# はじめに

コンポーザブルのテストを行う際に、AndroidTestディレクトリを使ってテストを行うことが多いと思います。その際に、毎回エミュレーターを立ち上げて実行するため一回のテストにかかる時間は通常のユニットテストと比べて多くかかります。

そこで、エミュレーターを使わずにコンポーザブルに対するテストを行うために、`Robolectric`を使ってユニットテストとしてテストを行ってみようと思います。

:::note
この方法は、Droid Kaigi 2022のアプリでも採用されています。
:::
https://github.com/DroidKaigi/conference-app-2022

# TL;DR

結果として、Robolectricを使って**unitTest**でコンポーザブルをテストするようにした結果、大幅な時間の短縮につながりました。

|テストの種類|時間|
|---|---|
|unitTest| 3 sec 411 ms|
|androidTest|46 sec|

# 依存関係の追加やテストのための設定

一番大事なのが、依存関係の設定です。
`testImplementation`や、`androidTestImplementation`を正しく使用しないとエラーが発生してテストが正しく実行されない可能性がありますのでご注意ください。

## モジュールレベルのbuild.gradleの設定

**unitTest**と**androidTest**で必要な設定と依存関係のみにしぼって**build.gradle**の内容をまとめました。

これらを参考に、自身のプロジェクトに依存関係を追加してみてください。

```groovy
plugins {
    // Kapt
    id 'kotlin-kapt'
    // Hilt
    id 'dagger.hilt.android.plugin'
}

android {
    defaultConfig {
        testInstrumentationRunner "com.takagimeow.myapplication.core.testing.TestRunner"
    }
    kotlinOptions {
        freeCompilerArgs = ["-Xcontext-receivers"]
    }
    testOptions {
        unitTests.includeAndroidResources = true
    }
}

dependencies {
    // Hilt
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
    implementation "androidx.hilt:hilt-navigation-compose:1.0.0"

    // Test
    testImplementation 'junit:junit:4.13.2'
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
    testImplementation "io.mockk:mockk:1.12.4"
    testImplementation "org.robolectric:robolectric:4.9"

    // Hilt for Test
    testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    testImplementation 'androidx.compose.ui:ui-test-junit4'
    kaptTest "com.google.dagger:hilt-android-compiler:$hilt_version"

    // AndroidTest
    androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
    androidTestImplementation "com.google.dagger:dagger:$hilt_version"
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
    kaptAndroidTest "com.google.dagger:dagger-compiler:$hilt_version"
}

```

`testOptions`ブロックで、`unitTests.includeAndroidResources = true`を設定しています。

もし、この設定を行わない場合は次のエラーが発生します。

```shell
Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=org.robolectric.default/androidx.activity.ComponentActivity } -- see https://github.com/robolectric/robolectric/pull/4736 for details
```

## ルートレベルのbuild.gradleの設定

この**build.gradle**では、共通の`hilt_version`の定義などを行います。加えてテストの実行に必要な依存関係も追加しています。

```groovy
buildscript {
    ext {
        hilt_version = '2.44.2'
    }
    dependencies {
        classpath("com.google.dagger:hilt-android-gradle-plugin:$hilt_version")
        // Test
        classpath("junit:junit:4.13.2")
    }
}
```

# テストに必要なファイルを作成する

![名称未設定のデザイン (40).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/577119/c40b7ee0-9aef-5909-67ab-7600c519ca21.png)

:::note warn
画像に表示されているファイルには作成する必要のないファイルも含まれています。
:::

今回は、Droid Kaigi2022に則って、`RobotTestRule`というカスタムルールを実装してコンポーザブルを**unitTest**ディレクトリ内でテストしたいため、下記の４つのファイルを作成しています。

## testingパッケージ

`testing`パッケージには、各テストにて共通で使えるカスタムルールに必要なファイルを作成します。

### HiltAndroidAUtoInjectRule

詳しい実装内容については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/core/testing/src/main/java/io/github/droidkaigi/confsched2022/testing/HiltAndroidAutoInjectRule.kt

```kotlin
package com.takagimeow.myapplication.core.testing

import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

class HiltAndroidAutoInjectRule(
    private val testInstance: Any
) : TestRule {
    override fun apply(base: Statement?, description: Description?): Statement {
        println(testInstance::class)
        val hiltAndroidRule = HiltAndroidRule(testInstance)
        return RuleChain
            .outerRule(hiltAndroidRule)
            .around(HiltInjectRule(hiltAndroidRule))
            .apply(base, description)
    }
}
```

### HiltInjectRule

詳しい実装については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/core/testing/src/main/java/io/github/droidkaigi/confsched2022/testing/HiltInjectRule.kt

```kotlin
package com.takagimeow.myapplication.core.testing

import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description

class HiltInjectRule(private val rule: HiltAndroidRule) : TestWatcher() {
    override fun starting(description: Description?) {
        super.starting(description)
        rule.inject()
    }
}

```

### HiltTestActivity

詳しい実装については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/core/testing/src/main/java/io/github/droidkaigi/confsched2022/testing/HiltTestActivity.kt

```kotlin
package com.takagimeow.myapplication.core.testing

import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class HiltTestActivity : ComponentActivity()
```

この`HiltTestActivity`をテスト時に使用するとエラーが発生することがあります。その解決方法を次の章にて解説しています。

### RobotTestRule

詳しい実装については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/core/testing/src/main/java/io/github/droidkaigi/confsched2022/testing/RobotTestRule.kt

```kotlin
package com.takagimeow.myapplication.core.testing

import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement

class RobotTestRule(
    private val testInstance: Any
) : TestRule {

    val composeTestRule = createAndroidComposeRule<HiltTestActivity>()

    override fun apply(base: Statement?, description: Description?): Statement {
        return RuleChain
            .outerRule(HiltAndroidAutoInjectRule(testInstance))
            .around(composeTestRule)
            .apply(base, description)
    }
}
```

この設定に辿り着くまでに、いろいろ試行錯誤している最中は、`.outerRule(HiltAndroidAutoInjectRule(testInstance))`の呼び出し部分で何度もエラーが発生してしまいました。

```shell
java.lang.IllegalStateException: Hilt test, com.takagimeow.myapplication.feature.addmember.AddMemberScreenTest, cannot use a @HiltAndroidApp application but found com.takagimeow.myapplication.MyApplication. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.
```

`createAndroidComposeRule()`を呼び出す際に、`ComponentActivity`ではなく、独自に作成した`HiltTestActivity`を指定すると、次のエラーが発生しました。

```shell
Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.takagimeow.myapplication.debug/com.takagimeow.myapplication.core.testing.HiltTestActivity } -- see
```

これを解決するためには、**test**ディレクトリに新しく**AndroidManifest.xml**を配置するのではなく、**debug**ディレクトリに**AndroidManifest.xml**を配置します。


:::note warn
testディレクトリにAndroidManifest.xmlを配置して、HiltTestActivityを登録したとしてもテスト時に認識されないため上記のエラーは解決しません
:::

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application>
        <activity
            android:name=".core.testing.HiltTestActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MyApplication"
            >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
```

テスト時は、`debug`ビルドタイプが適用されます。
そのため、**debug**ディレクトリ内のAndroidManifest.xmlに`HiltTestActivitiy`を登録することで、テスト時に認識させることができるようになります。

もう一つ注意点として、**build.gradle**にて次の設定がされていることを必ず確認してください。

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

https://twitter.com/new_runnable/status/1620359837518544897

### TestRunner

このテストランナーは、**androidTest**にて実行されるインストゥルーメンテーションテストを行う際にHiltテストアプリを使う場合にのみ作成が必要なものです。

**unitTest**におけるテストを行う際には作成する必要は特にありませんので、このままスルーしてもかまいません。

```kotlin
package com.takagimeow.myapplication.core.testing

import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication

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

ちなみに、作成したテストランナーはモジュールレベルの**build.gradle**にて次のように設定します。

```groovy
defaultConfig {
    testInstrumentationRunner "com.takagimeow.myapplication.core.testing.TestRunner"
}
```

カスタムテストランナーを作成しない場合は、`androidx.test.runner.AndroidJUnitRunner`を設定します。

```groovy
defaultConfig {
    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
```

## 実際のテストに必要なファイル

### XxxScreenRobot

詳しい実装については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/feature/about/src/test/java/io/github/droidkaigi/confsched2022/feature/about/AboutScreenRobot.kt

```kotlin
package com.takagimeow.myapplication.feature.addmember

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.onNodeWithText
import com.takagimeow.myapplication.core.testing.RobotTestRule
import com.takagimeow.myapplication.R
import javax.inject.Inject

class AddMemberScreenRobot @Inject constructor() {

    context(RobotTestRule)
    fun checkAddMemberVisible() {
        composeTestRule.onNodeWithText(composeTestRule.activity.getString(R.string.title_add_member)).assertExists()
    }

    @OptIn(ExperimentalMaterial3Api::class)
    operator fun invoke(
        robotTestRule: RobotTestRule,
        function: context(RobotTestRule) AddMemberScreenRobot.() -> Unit
    ) {
        robotTestRule.composeTestRule.setContent {
            // これが実際にテスト対象のコンポーザブル
            AddMemberScreen()
        }
        function(robotTestRule, this@AddMemberScreenRobot)
    }
}
```

### XxxScreenTest

詳しい実装については、こちらを参照してください。

https://github.com/DroidKaigi/conference-app-2022/blob/main/feature/about/src/test/java/io/github/droidkaigi/confsched2022/feature/about/AboutScreenTest.kt

```kotlin
package com.takagimeow.myapplication.feature.addmember

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.takagimeow.myapplication.core.testing.RobotTestRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import javax.inject.Inject

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
// @Config(application = HiltTestApplication::class)
class AddMemberScreenTest {
    @get:Rule val robotTestRule = RobotTestRule(this)
    @Inject lateinit var addMemberScreenRobot: AddMemberScreenRobot
    
    @Test
    fun smoke_test() {
        addMemberScreenRobot(robotTestRule) {
            checkAddMemberVisible()
        }
    }
}

```

## Robolectricの設定

Robolectricを使用してコンポーザブルをテストするため、使用するアプリケーションを**robolectric.properties**ファイルを使って設定する必要があります。

ここでは、`dagger.hilt.android.testing.HiltTestApplication`を指定します。

```properties
application=dagger.hilt.android.testing.HiltTestApplication
# https://github.com/robolectric/robolectric/issues/6593
instrumentedPackages=androidx.loader.content
```

テストごとに個別に指定することも可能です。

```kotlin
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
@Config(application = HiltTestApplication::class)
class AddMemberScreenTest {
    ...
}
```

もし、`@HiltAndroidTest`を使っているにも関わらず、上記の設定を怠った場合は次のエラーが発生します。

```shell
java.lang.IllegalStateException: Hilt test, com.takagimeow.myapplication.feature.addmember.AddMemberScreenTest, cannot use a @HiltAndroidApp application but found com.takagimeow.myapplication.MyApplication. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.
```

# まとめ

同じテストの内容を`androidTest`と`unitTest`で実行してみた結果、次のような結果になりました。

|テストの種類|時間|
|---|---|
|unitTest| 3 sec 411 ms|
|androidTest|46 sec|

これだけの差が生まれますので、少々設定は面倒ですが、**Robolectrict**を導入してコンポーザブルを**unitTest**内でテストをできるようにして良かったと感じています。


# 参考にした記事

https://developer.android.com/training/dependency-injection/hilt-testing?hl=ja#groovy

https://stackoverflow.com/questions/65058281/java-lang-runtimeexception-hilt-classes-generated-from-hiltandroidtest-are-mis

https://stackoverflow.com/questions/18715983/robolectric-cant-find-resource-or-manifest-file/55941395#55941395