はじめに
Androidでのインテグレーションテストを行う際に役立つテストルールを作成したくて、DroidKaigi2022のリポジトリにあるRobotTestRuleを眺めていました。
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を呼び出すと思います。
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です。
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を渡しているところがこのカスタムルールのミソとなっております。
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が使用されています。
val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
HiltTestActivityはComponentActivityのサブクラスです。
@AndroidEntryPoint
class HiltTestActivity : ComponentActivity()
なので、普段次のように呼び出している内容とほとんど変わりません。
private val composeTestRule = createAndroidComposeRule<ComponentActivity>()
operator fun invoke() {}
クラスに、operator fun invoke() {}を実装すると、インスタンス作成の変数名()の実行によって呼び出されるメソッドを実装することができる。
val example = Example()
example() // invoke()が呼び出される。
context()
context()はコンテキストレシーバーと呼ばれるKotlinの機能です。
実例としてAboutScreenTestを見てみましょう。
@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プロパティが存在します。
class RobotTestRule(
private val testInstance: Any
) : TestRule {
val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
// ...
}
そして、AboutScreenRobotでは、context(RobotTestRule)をcheckAboutVisible()に対して適用しています。
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()の呼び出しを表しています。
@Test
fun checkAboutVisible() {
// この呼び出しはAboutScreenRobotインスタンスのinvoke()を呼び出している
aboutScreenRobot(robotTestRule) {
checkAboutVisible()
}
}
それでは、invoke()の実装を見てみましょう。
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として受け取ります。
function: context(RobotTestRule) AboutScreenRobot.() -> Unit
そして、ラムダは上のように定義されているので、呼び出す場合は、RobotTestRuleのインスタンスを第一引数に、AboutScreenRobotのインスタンスを第二引数にそれぞれ順番通りに渡してfunction()を呼び出します。
function(robotTestRule, this@AboutScreenRobot)
これにより、スコープ内のRobotTestRuleがコンテキストとして強制されているAboutScreenRobotのcheckAboutVisible()を呼び出すことができるようになっているのです。
まとめ
最初、context()が全くわからず、調べてみてもAndroidのContextばかり検索に引っかかってしまい苦労しました。
調べるときはkotlin context receiversかkotlin コンテキストレーシーバーで検索すると引っかかると思います。
参考にした記事