DroidKaigi2022のRobotTestRuleを紐解いてみた

# はじめに

Androidでのインテグレーションテストを行う際に役立つテストルールを作成したくて、DroidKaigi2022のリポジトリにある`RobotTestRule`を眺めていました。

```kotlin
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)
    }
}
```

するとカスタムテストルール初心者の僕にとってはわからない単語やクラスなどが散らばっていて何が何やらさっぱりわからなかったので、今回忘備録としてまとめてみました。

# `HiltAndroidAutoInjectRule`

`outerRule()`に渡されている`HiltAndroidAutoInjectRule`クラスは、独自に実装されたカスタムルールです。`Hilt`で提供されているクラスではありません。

通常は、テストごとに次のように`RuleChain`を呼び出すと思います。

```kotlin
private val hiltRule = HiltAndroidRule(this)
private val composeTestRule = createAndroidComposeRule<ComponentActivity>()

@get:Rule
val rule: RuleChain = RuleChain
    .outerRule(hiltRule)
    .around(composeTestRule)

@Before
fun setUp() {
    hiltRule.inject()
}
```

上記の処理を何度もテストごとに記述するのを避けるために作成されたのが次の`hiltAndroidAutoInjectRule`です。

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

それでは、`HiltInjectRule`とは何なのでしょうか？引数として`hiltAndroidRule`を渡しているところがこのカスタムルールのミソとなっております。

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

注目すべきは`starting()`内で`rule.inject()`を呼び出している箇所です。
これにより、通常だと`@Before`を付けた`setup()`などで`hiltRule.inject()`などを呼び出してDIを行っているのですが、それをルールに閉じ込めることでテストを書く際は何も設定することなく自動的にテストに必要なDIを実行することができるようになっています。

`TestWatcher`はテストの内容を変更することなく、テストの実行前やテストの成功後などにそのテストの内容を記録するために用意されているルールの基底クラスです。そして、`starting()`はテストに実行前に呼び出されるメソッドです。

ちなみに、`TestWatcher`は`TestRule`のサブクラスです。なので、`around()`に対して渡すことが可能となっています。

# `HiltTestActivity`

`composeTestRule`を作成するときに、次のように見慣れない`HiltTestActivity`が使用されています。

```kotlin
val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
```

`HiltTestActivity`は`ComponentActivity`のサブクラスです。

```kotlin
@AndroidEntryPoint
class HiltTestActivity : ComponentActivity()
```

なので、普段次のように呼び出している内容とほとんど変わりません。

```kotlin
private val composeTestRule = createAndroidComposeRule<ComponentActivity>()
```

# `operator fun invoke() {}`

クラスに、`operator fun invoke() {}`を実装すると、`インスタンス作成の変数名()`の実行によって呼び出されるメソッドを実装することができる。

```kotlin
val example = Example()
example() // invoke()が呼び出される。
```

https://star-zero.medium.com/kotlin%E3%81%AEinvoke-operator-5fe733e75738

# `context()`

`context()`はコンテキストレシーバーと呼ばれるKotlinの機能です。

https://pspdfkit.com/blog/2022/kotlin-context-receivers/

実例として`AboutScreenTest`を見てみましょう。

```kotlin
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AboutScreenTest {
    @get:Rule val robotTestRule = RobotTestRule(this)
    @Inject lateinit var aboutScreenRobot: AboutScreenRobot

    @Test
    fun checkAboutVisible() {
        aboutScreenRobot(robotTestRule) {
            checkAboutVisible()
        }
    }
}
```

`robotTestRule`プロパティの`RobotTestRule`の実装には`composeTestRule`プロパティが存在します。

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

    val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
    // ...
}
```

そして、`AboutScreenRobot`では、`context(RobotTestRule)`を`checkAboutVisible()`に対して適用しています。

```kotlin
class AboutScreenRobot @Inject constructor() {
    context(RobotTestRule)
    fun checkAboutVisible() {
        composeTestRule.onNodeWithText("What is DroidKaigi?")
    }
    // ...
}
```

上の内容だと`composeTestRule`にアクセスするために、`RobotTestRule`というコンテキストがスコープに存在するということを強制しています。

これにより、`RobotTestRule`へのインスタンスにアクセスできない場合は`checkAboutVisible()`を呼び出せないということを表します。

そして、このコンテキスト上の強制があることでこのメソッドがアクセスできるスコープの中に`RobotTestRule`のインスタンスが存在することが保障されるため、そのプロパティの`composeTestRule`にアクセスして`onNodeWithText()`を呼び出すことが可能となっています。

では、どのように`RobotTestRule`をコンテキスト上に強制した上で`checkAboutVisible()`を呼び出せば良いのでしょうか？

まず、`aboutScreenRobot(robotTestRule) {}`の呼び出しは`invoke()`の呼び出しを表しています。

```kotlin
@Test
fun checkAboutVisible() {
    // この呼び出しはAboutScreenRobotインスタンスのinvoke()を呼び出している
    aboutScreenRobot(robotTestRule) {
        checkAboutVisible()
    }
}
```

それでは、`invoke()`の実装を見てみましょう。

```kotlin
class AboutScreenRobot @Inject constructor() {
    // ...
    operator fun invoke(
        robotTestRule: RobotTestRule,
        function: context(RobotTestRule) AboutScreenRobot.() -> Unit
    ) {
        robotTestRule.composeTestRule.setContent {
            AboutScreenRoot()
        }
        function(robotTestRule, this@AboutScreenRobot)
    }
}
```

第二引数の`function`として、`AboutScreenRobot`の拡張関数がラムダとして定義されています。
なので、`invoke()`の呼び出しに渡されたラムダは`AboutScreenRobot`のインスタンスを`this`として受け取ります。

```kotlin
function: context(RobotTestRule) AboutScreenRobot.() -> Unit
```

そして、ラムダは上のように定義されているので、呼び出す場合は、`RobotTestRule`のインスタンスを第一引数に、`AboutScreenRobot`のインスタンスを第二引数にそれぞれ順番通りに渡して`function()`を呼び出します。

```kotlin
function(robotTestRule, this@AboutScreenRobot)
```

これにより、スコープ内の`RobotTestRule`がコンテキストとして強制されている`AboutScreenRobot`の`checkAboutVisible()`を呼び出すことができるようになっているのです。

# まとめ

最初、`context()`が全くわからず、調べてみてもAndroidの`Context`ばかり検索に引っかかってしまい苦労しました。

調べるときは`kotlin context receivers`か`kotlin コンテキストレーシーバー`で検索すると引っかかると思います。

# 参考にした記事

https://qiita.com/maple_syrup/items/dfb9c9936bb48588fae9#testwatcher%E3%83%86%E3%82%B9%E3%83%88%E5%AE%9F%E8%A1%8C%E6%99%82%E3%81%AE%E8%A8%98%E9%8C%B2%E3%82%92%E8%A1%8C%E3%81%86

https://dk521123.hatenablog.com/entry/34933920

https://junit.org/junit4/javadoc/4.12/org/junit/rules/TestWatcher.html

https://star-zero.medium.com/kotlin%E3%81%AEinvoke-operator-5fe733e75738

https://qiita.com/takahirom/items/46053e031041405e2a9e

http://y-anz-m.blogspot.com/2021/08/jetpack-compose-viewmodel-navigation_24.html

https://pspdfkit.com/blog/2022/kotlin-context-receivers/

https://droidkaigi.jp/2022/timetable/363729

https://developer.android.com/jetpack/compose/testing?hl=ja