はじめに
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 コンテキストレーシーバー
で検索すると引っかかると思います。
参考にした記事