2021年8月24日火曜日

Jetpack Compose + ViewModel + Navigation + Hilt で UI テスト

テスト対象の Compose @HiltAndroidApp class MyApplication : Application() @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } } @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController, startDestination = "Screen1") { composable("Screen1") { Screen1(hiltViewModel()) { navController.navigate("Screen2/$it") } } composable( route = "Screen2/{value}", arguments = listOf( navArgument("value") { type = NavType.IntType }, ) ) { val arguments = requireNotNull(it.arguments) val value = arguments.getInt("value") Screen2(value) } } } @Composable fun Screen1( viewModel: Screen1ViewModel, onClickItem: (Int) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(viewModel.values) { value -> Text( text = value.toString(), modifier = Modifier .fillMaxWidth() .clickable { onClickItem(value) } .padding(16.dp) ) } } } @Composable fun Screen2(value: Int) { Text( text = "value = $value", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } @HiltViewModel class Screen1ViewModel @Inject constructor( valueProvider: ValueProvider ) : ViewModel() { val values: List<Int> = valueProvider.getValues() } interface ValueProvider { fun getValues(): List<Int> } class DefaultValueProvider @Inject constructor() : ValueProvider { override fun getValues(): List<Int> = (0 until 20).map { Random.nextInt() } } @Module @InstallIn(SingletonComponent::class) interface ValueProviderModule { @Binds @Singleton fun bindValueProvider(provider: DefaultValueProvider): ValueProvider }

MyApp composable では最初に Screen1が表示される。
Screen1 にはランダムな Int 値のリストが表示され、タップすると Screen2 に遷移し、タップした値が表示される。

Screen1 のリストをタップして Screen2 に遷移し、タップしたところの値が表示されていることをテストしたいとする。

Screen1 の引数の Screen1ViewModel は hiltViewModel() で取得しているので、テストでも Hilt が動くようにしたい。
ただし、Screen1ViewModel で使っている ValueProvidier はテスト用のものに差し替えたい。


1. テスト用のライブラリを追加する

参考 (Hilt) : https://developer.android.com/training/dependency-injection/hilt-testing#testing-dependencies
参考 (Compose) : https://developer.android.com/jetpack/compose/testing#setup dependencies { ... androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" }
2. テスト時に HiltTestApplication が使われるように CustomTestRunner を用意する

参考 : https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests

(継承元のApplicationが必要な場合は https://developer.android.com/training/dependency-injection/hilt-testing#custom-application に方法が書いてある)

app/src/androidTest class CustomTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } build.gradle (app) android { ... defaultConfig { ... testInstrumentationRunner "com.sample.myapplication.CustomTestRunner" } ... } この設定をしないと

java.lang.IllegalStateException: Hilt test, com.sample.myapplication.MyAppTest, cannot use a @HiltAndroidApp application but found com.sample.myapplication.MyApplication. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.

のようなエラーがでる


3. @AndroidEntryPoint がついたテスト用の Activity を debug に用意する

app/src/debug @AndroidEntryPoint class HiltTestActivity : ComponentActivity() app/src/debug/AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.sample.myapplication"> <application> <activity android:name=".HiltTestActivity" android:exported="false" /> </application> </manifest>
4. テストを書く

テストクラスに @HiltAndroidTest をつける

HiltAndroidRule が先になるように oder を指定する。
参考 :
https://developer.android.com/training/dependency-injection/hilt-testing#multiple-testrules

createAndroidComposeRule() に 3. で作った HiltTestActivity を指定する。createComposeRule() だと

Given component holder class androidx.activity.ComponentActivity does not implement interface dagger.hilt.internal.GeneratedComponent or interface dagger.hilt.internal.GeneratedComponentManager

のようなエラーがでる

binding の差し替えは https://developer.android.com/training/dependency-injection/hilt-testing#testing-features に書いてある。 @UninstallModules(ValueProviderModule::class) @HiltAndroidTest class MyAppTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<HiltTestActivity>() @BindValue val repository: ValueProvider = object : ValueProvider { override fun getValues(): List<Int> { return listOf(100) } } @Test fun test() { composeTestRule.setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } // Screen1 に 100 が表示されている composeTestRule.onNodeWithText("100").assertIsDisplayed() // Screen1 の 100 が表示されている Node をクリック composeTestRule.onNodeWithText("100").performClick() // Screen2 に value = 100 が表示されている composeTestRule.onNodeWithText("value = 100").assertIsDisplayed() } }



3 件のコメント:

  1. Kotlinよくわかんない ボタンを押して画面が変わればいいのに基盤が大げさになりすぎたかも 悲しみ http://rb.tabirepo.online/archives/1020

    返信削除
  2. なぜ今“6G”か?2030年を見据えた富士通の覚悟

    返信削除
  3. http://www.jflabo.sakura.ne.jp/2021/
    こんな感じのシステムです。アンドロイドアプリの
    メソッド名とイベントが変わったみたいで
    なかなか作れません 悩ましいです。

    自動車やバス 車両 車載機にも応用されていくのかなぁ

    返信削除