Slide 1

Slide 1 text

© DeNA Co., Ltd. 1 Roborazzi + Jetpack Composeで スクリーンショットを撮るときに役立つTips集 2024.01.31 TOYAMA Sumio (sumio_tym) Android Test Night #9

Slide 2

Slide 2 text

© DeNA Co., Ltd. 2 自己紹介 ● 氏名: 外山 純生 (TOYAMA Sumio) @sumio_tym (旧Twitter) / @sumio (GitHub) ● 所属: DeNA SWET第二グループ (Software Engineer in Test) ● 業務内容: 主にAndroidにおける 品質のボトルネック解決 ● その他: 「Androidテスト全書」執筆 https://peaks.cc/sumio_tym/android_testing

Slide 3

Slide 3 text

© DeNA Co., Ltd. 3 お話しすること Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsをいくつかご紹介します ● スクロールが必要な画面を撮る ● Coilによる画像読み込みを含む画面を撮る ● 再生途中のアニメーションを撮る ● 非同期処理の終了を待ってから撮る

Slide 4

Slide 4 text

© DeNA Co., Ltd. 4 1 Roborazziの概要 スクロールが必要な画面のスクリーンショットを撮る Coilによる画像読み込みを含む画面のスクリーンショットを撮る アニメーション再生途中のタイミングでスクリーンショットを撮る 4 3 2 目次 非同期処理が終わったタイミングでスクリーンショットを撮る 5

Slide 5

Slide 5 text

© DeNA Co., Ltd. 5 5 01 Roborazziの概要

Slide 6

Slide 6 text

© DeNA Co., Ltd. 6 Roborazziの特徴 Local Test (JVM上で動くテスト)で動く スクリーンショットテストツール ● https://github.com/takahirom/roborazzi ● Robolectric 4.10より導入されたRobolectric Native Graphicsを使って実現 ● Local Testで動くので極めて高速

Slide 7

Slide 7 text

© DeNA Co., Ltd. 7 Roborazziの使い方 (トップレベル build.gradle) plugins { id("io.github.takahirom.roborazzi") version "" apply false }

Slide 8

Slide 8 text

© DeNA Co., Ltd. 8 Roborazziの使い方 (モジュールレベル build.gradle) dependencies { testImplementation( "org.robolectric:robolectric:$robolectric_version") testImplementation( "org.robolectric:shadow-framework:$robolectric_version") testImplementation( "io.github.takahirom.roborazzi:roborazzi:$version") testImplementation( "io.github.takahirom.roborazzi:roborazzi-compose:$version") } plugins { id("io.github.takahirom.roborazzi") }

Slide 9

Slide 9 text

© DeNA Co., Ltd. 9 参考: その他Composeのテストに必要な依存関係 dependencies { testImplementation( "androidx.test.ext:junit:$androidx_test_ext_junit_version") testImplementation( "androidx.test:core:$androidx_test_core_version") testImplementation( "androidx.compose.ui:ui-test-junit4:$compose_version") debugImplementation( "androidx.compose.ui:ui-test-manifest:$compose_version") }

Slide 10

Slide 10 text

© DeNA Co., Ltd. 10 Roborazziの使い方 (テストコード) @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MyFirstRoborazziTest { @get:Rule val composeTestRule = createComposeRule() @Test fun test() { composeTestRule.setContent { // スクリーンショットを撮りたいComposable関数をここに書く } composeTestRule.onRoot().captureRoboImage() } } Robolectricを使うのに必要 Robolectric Native Graphics の有効化 スクリーンショットを撮る Jetpack Compose のテストに必要

Slide 11

Slide 11 text

© DeNA Co., Ltd. 11 テストの実行と結果レポート確認 スクリーンショットを撮る ● 方法1: 専用のGradleタスクを実行する ./gradlew recordRoborazziDebug ● 方法2: 普通のテストとして実行する 1. gradle.propertiesに書く roborazzi.test.record=true 2. テストを実行する ./gradlew testDebugUnitTest 結果レポートを確認する ● 画像ファイルの所在 build/outputs/roborazzi/*.png ● 結果レポートの所在 build/reports/roborazzi/index.html

Slide 12

Slide 12 text

© DeNA Co., Ltd. 12 12 02 スクロールが必要な画面の スクリーンショットを撮る

Slide 13

Slide 13 text

© DeNA Co., Ltd. 13 スクロールが必要なほど長いリスト @Composable fun LongList() { LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp), ) { items(100) { Text(text = "[$it]") Divider() } } }

Slide 14

Slide 14 text

© DeNA Co., Ltd. 14 方法1: 少しずつスクロールして撮影を繰り返す @Test fun testScrollLongTest() { composeTestRule.setContent { LongList() } composeTestRule.apply { var index = 0 while (index < 100) { onNode(hasScrollToNodeAction()) .performScrollToIndex(index) onRoot().captureRoboImage() index += 20 } }} 20アイテムずつ スクロールしては captureRoboImage() を呼ぶ

Slide 15

Slide 15 text

© DeNA Co., Ltd. 15 1枚目 2枚目 3枚目 4枚目 5枚目 方法1: 少しずつスクロールして撮影を繰り返す (結果)

Slide 16

Slide 16 text

© DeNA Co., Ltd. 16 方法2: 画面サイズを縦に引きのばして1枚で撮影する① @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @LooperMode(LooperMode.Mode.PAUSED) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel7) class LongListTest { @get:Rule val composeTestRule = createAndroidComposeRule() lateinit var context: Context @Before fun setUp() { context = ApplicationProvider.getApplicationContext() } Display情報をPixel7に指定 後でActivityを使うので createAndroidComposeRuleにする

Slide 17

Slide 17 text

© DeNA Co., Ltd. 17 方法2: 画面サイズを縦に引きのばして1枚で撮影する② ... @Test fun testLongList() { setDisplayHeight(3000.dp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { LongList() } composeTestRule.onRoot().captureRoboImage() } 画面の高さを 十分大きな値に変更して Activityを再生成する

Slide 18

Slide 18 text

© DeNA Co., Ltd. 18 方法2: 画面サイズを縦に引きのばして1枚で撮影する③ ... fun setDisplayHeight(widthDp: Dp) { val density = context.resources.displayMetrics.density val px = (widthDp.value * density).roundToInt() val display = ShadowDisplay.getDefaultDisplay() Shadows.shadowOf(display).setHeight(px) } } RobolectricのShadowを 使うと画面高さを 強制的に変更できる

Slide 19

Slide 19 text

© DeNA Co., Ltd. 19 方法2: 画面サイズを縦に引きのばして1枚で撮影する(結果)

Slide 20

Slide 20 text

© DeNA Co., Ltd. 20 ここまでのまとめ ● 長いリストのスクリーンショットを撮る方法は2通りある ● 少しずつスクロールしながら、複数枚撮る方法 ● 画面の高さを大きな値に変更してから1枚で撮る方法 ● 画面の高さを変更するには、RobolectricのShadowDisplayを使う ○ 高さ変更後にActivityの再生成が必要

Slide 21

Slide 21 text

© DeNA Co., Ltd. 21 21 03 Coilによる画像読み込みを含む 画面のスクリーンショットを撮る

Slide 22

Slide 22 text

© DeNA Co., Ltd. 22 Coilで読み込んだ画像がある画面 @Composable fun ImageScreen() { Column(verticalArrangement = spacedBy(8.dp)) { AsyncImage( "https://placehold.jp/cc0000/400x400.png", null) Divider() AsyncImage( "https://placehold.jp/00cc00/400x400.png", null) Divider() AsyncImage( "https://placehold.jp/0000cc/400x400.png", null) } }

Slide 23

Slide 23 text

© DeNA Co., Ltd. 23 このままスクリーンショットを撮ると・・・ class NaiveImageScreenTest { @get:Rule val composeTestRule = createComposeRule() @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot().captureRoboImage() } }

Slide 24

Slide 24 text

© DeNA Co., Ltd. 24 Coilが提供しているFakeImageLoaderを使う① https://coil-kt.github.io/coil/testing/ ● 追加が必要な依存関係 ● 画像のURLごとに、テスト用に返す画像(Drawable)を指定できる testImplementation("io.coil-kt:coil-test:2.5.0") val engine = FakeImageLoaderEngine.Builder() .intercept(, Drawable(Color.RED)) .intercept(, Drawable(Color.GREEN)) .default(ColorDrawable(Color.BLUE)) .build() val imageLoader = ImageLoader.Builder(context) .components { add(engine) } .build()

Slide 25

Slide 25 text

© DeNA Co., Ltd. 25 Coilが提供しているFakeImageLoaderを使う② @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() val engine = FakeImageLoaderEngine.Builder() .intercept("https://placehold.jp/cc0000/400x400.png", context.getDrawable(R.drawable.ic_android_cc0000)!!) .intercept({ (it as? String)?.contains("00cc00") == true }, context.getDrawable(R.drawable.ic_android_00cc00)!!) .default(...).build() fakeImageLoader = ImageLoader.Builder(context) .components { add(engine) } .build() Coil.setImageLoader(fakeImageLoader) } URLごとに 描画してほしい画像を指定 (λ式で複雑な条件も書ける) Coilが使うImageLoaderを上書き

Slide 26

Slide 26 text

© DeNA Co., Ltd. 26 Coilが提供しているFakeImageLoaderを使う③ @After fun tearDown() { Coil.reset() } @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot() .captureRoboImage() } Coilが使うImageLoaderを 元に戻す

Slide 27

Slide 27 text

© DeNA Co., Ltd. 27 ここまでのまとめ ● Coilによる画像読み込みを含む画面は、 そのままではスクリーンショットが撮れない ● 同期的に(ローカルにある)画像を読み込むFakeImageLoaderを使う ● 「このURLのときはこのDrawableを描画する」というルールを 複数指定できる ● Coil.setImageLoader()でCoilが利用するImageLoaderを FakeImageLoaderに差し替えられる

Slide 28

Slide 28 text

© DeNA Co., Ltd. 28 28 04 再生途中のアニメーションの スクリーンショットを撮る

Slide 29

Slide 29 text

© DeNA Co., Ltd. 29 ボタンを押すとアニメーションする画面 @Composable fun AnimatedRectangle() { Column(...) { var widen by remember { mutableStateOf(false) } Button(onClick = { widen = true }) { Text(text = "広げる") } val width = if (widen) 320.dp else 32.dp Box( modifier = Modifier .animateContentSize( tween(2000, easing = LinearEasing)) .size(width = width, height = 32.dp) .background(color = Color.Green), ) }} 2秒かけてBoxの幅を320dpにする

Slide 30

Slide 30 text

© DeNA Co., Ltd. 30 このままスクリーンショットを撮ると・・・ @Test fun testAfterAnimation() { composeTestRule.apply { setContent { AnimatedRectangle() } onNode(hasText("広げる")).performClick() onRoot().captureRoboImage() } } ボタンを押してすぐに captureRoboImage()を呼ぶ アニメーション完了後 の状態が撮影される

Slide 31

Slide 31 text

© DeNA Co., Ltd. 31 composeTestRuleの仕様 ( https://d.android.com/jetpack/compose/testing?hl=en#sync-auto より) ● composeTestRuleはUIがアイドル状態になるのを待って から次の操作をしている(自動同期) ● アニメーション再生中はビジー状態なので、 途中の状態をキャプチャできなかった ● composeTestRule.mainClock.autoAdvanceを falseにすると、自動同期をOFFにできる ● advanceTimeBy(milliseconds)を使って 自分で時間を進められるようになる

Slide 32

Slide 32 text

© DeNA Co., Ltd. 32 再生途中のアニメーションを撮る @Test fun testIntermediateAnimation() { composeTestRule.apply { mainClock.autoAdvance = false setContent { AnimatedRectangle() } onNode(hasText("広げる")).performClick() mainClock.advanceTimeBy(1_000) onRoot().captureRoboImage() mainClock.autoAdvance = true } } 自動同期をOFFにする アニメーション開始 1秒後の状態が撮影される ボタンを押してから1秒進める

Slide 33

Slide 33 text

© DeNA Co., Ltd. 33 ここまでのまとめ ● composeTestRuleはデフォルトで自動同期がONになっている ● 自動同期がONだと、アニメーション再生中のような ビジー状態のスクリーンショットを撮ることができない ● composeTestRule.mainClock.autoAdvance = false で 自動同期をOFFにできる ● advanceTimeBy(milliseconds)などを使って自分で時間を進めれば、 特定時刻のアニメーションの状態を撮影できる

Slide 34

Slide 34 text

© DeNA Co., Ltd. 34 34 05 その他の非同期処理が終わった タイミングで スクリーンショットを撮る

Slide 35

Slide 35 text

© DeNA Co., Ltd. 35 ボタンを押すと2秒後に表示文字列が変化する画面 @Composable fun DelayedButton() { Column(...) { var text by remember { mutableStateOf("開始") } val coroutineScope = rememberCoroutineScope() Button(onClick = { coroutineScope.launch { delay(2.seconds) text = "完了" } } ) { Text(text = text) } } } rememberCoroutineScopeを使って、 2秒後にボタンのテキストを 「完了」にする 2秒後

Slide 36

Slide 36 text

© DeNA Co., Ltd. 36 ボタンを押した直後にスクリーンショットを撮ると・・ @Test fun testAfterClick() { composeTestRule.setContent { DelayedButton() } composeTestRule .onNode(hasText("開始")) .performClick() composeTestRule.onRoot().captureRoboImage() } 「開始」ボタンを押して すぐにキャプチャ 「完了」に変わる前に キャプチャされてしまう

Slide 37

Slide 37 text

© DeNA Co., Ltd. 37 Jetpack Compose用のIdlingResource ① package androidx.compose.ui.test interface IdlingResource { val isIdleNow: Boolean fun getDiagnosticMessageIfBusy(): String? = null } フレームワークから問い合わせが来たら 「今アイドル状態かどうか」を返すモノ (EspressoのIdlineResourceと同じ考え方) EspressoのIdlingResourceとは 所属パッケージが異なる

Slide 38

Slide 38 text

© DeNA Co., Ltd. 38 Jetpack Compose用のIdlingResource ② class MyIdlingResource : IdlingResource { private var _isIdleNow: Boolean = false override val isIdleNow: Boolean get() = _isIdleNow fun changeIdleState(idle: Boolean) { _isIdleNow = idle } } シンプルな実装例 状態(アイドル or ビジー)が 変わったときに呼んでもらう (プロダクトコード側から)

Slide 39

Slide 39 text

© DeNA Co., Ltd. 39 @Composable fun DelayedButton( idleNotifier: (Boolean) -> Unit = {} ) { Column(...) { ... Button(onClick = { coroutineScope.launch { idleNotifier(false) delay(2.seconds) text = "完了" idleNotifier(true) } }) { ... }}} Jetpack Compose用のIdlingResource ③ プロダクトコードを少し修正 ボタンが押されてから 「完了」に変わるまでは ビジー状態(idle=false) であることを通知

Slide 40

Slide 40 text

© DeNA Co., Ltd. 40 @get:Rule val composeTestRule = ... lateinit var idlingResource: MyIdlingResource @Before fun setUp() { idlingResource = MyIdlingResource() composeTestRule.registerIdlingResource(idlingResource) } @After fun tearDown() { composeTestRule.unregisterIdlingResource(idlingResource) } Jetpack Compose用のIdlingResource ④ テストコード側の準備 (IdlingResourceの登録と解除) setUpで登録 tearDownで解除

Slide 41

Slide 41 text

© DeNA Co., Ltd. 41 @Test fun testUntilIdle() { composeTestRule.setContent { DelayedButton(idlingResource::changeIdleState) } composeTestRule.onNode(hasText("開始")).performClick() composeTestRule.waitForIdle() composeTestRule.onRoot().captureRoboImage() } Jetpack Compose用のIdlingResource ④ テストコード本体 ビジー状態を通知してもらえるように IdlingResourceをインジェクト アイドル状態になるまで待つ

Slide 42

Slide 42 text

© DeNA Co., Ltd. 42 Jetpack Compose用のIdlingResource ⑤ ところが結果は・・・ 「開始」のまま! 実装を追ったところ、 RobolectricではIdlingResourceの状態は無視されていた

Slide 43

Slide 43 text

© DeNA Co., Ltd. 43 @Test fun testUntilIdle() { composeTestRule.setContent { DelayedButton(idlingResource::changeIdleState) } composeTestRule.onNode(hasText("開始")).performClick() composeTestRule.waitUntil(timeoutMillis = 10_000L) { idlingResource.isIdleNow } composeTestRule.onRoot().captureRoboImage() } Jetpack Compose用のIdlingResource ⑥ テストコードをさらに修正 λ式がtrueになるまで待つ

Slide 44

Slide 44 text

© DeNA Co., Ltd. 44 Jetpack Compose用のIdlingResource ⑦ 結果は・・・ 「完了」になった!

Slide 45

Slide 45 text

© DeNA Co., Ltd. 45 waitUntil() { ... } の注意事項 ● Jetpack Composeが提供する非同期機構(rememberCoroutineScopeなど)のみで有効 ● composeTestRule.mainClock を少しずつ進めながらpollingしている (mainClock以外の時間は止まったまま) ● その他の非同期機構を使う場合は自分で時間を進めましょう ● たとえばHandler.postDelayedを使ってる場合 composeTestRule.waitUntil(10_000L) { ShadowLooper.idleMainLooper(20, MILLISECONDS) idlingResource.isIdleNow } pollingの度に MainLooperの時刻も20msec ずつ進める

Slide 46

Slide 46 text

© DeNA Co., Ltd. 46 補足: Jetpack Composeのコルーチンだけ時間操作する方法 ● Jetpack Composeで使われるTestDispatcherを差し替えることもできる ● その場合は、TestScope.advanceUntilIdle()などを使って時間操作する val coroutineDispatcher = UnconfinedTestDispatcher() @get:Rule val composeTestRule = createAndroidComposeRule( effectContext = coroutineDispatcher ) @Test fun testUntilIdle() = runTest(coroutineDispatcher.scheduler) { ... composeTestRule.onNode(hasText("開始")).performClick() advanceUntilIdle() composeTestRule.onRoot().captureRoboImage() }

Slide 47

Slide 47 text

© DeNA Co., Ltd. 47 ここまでのまとめ ● rememberCoroutineScopeを使ったコルーチンの完了待ちには IdlingResourceを使う ● EspressoのIdlingResourceとはパッケージが違う点に注意 ● RobolectricではIdlingResourceを登録しても無視されるので waitForIdle()の代わりに waitUntil { idlingResource.isIdleNow } を使う ● 上記以外の非同期処理待ちをするときは、 そのスケジューラが使っている時計も進める必要がある (Handler.postDelayedなら ShadowLooper.idleMainLooper(...))

Slide 48

Slide 48 text

© DeNA Co., Ltd. 48 全体のまとめ Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsを紹介しました ● スクロールが必要な画面 ○ RobolectricのShadowDisplayを使って画面高さを変更する ● Coilによる画像読み込みを含む画面を撮る ○ Coilに用意されているFakeImageLoaderを使う ● 再生途中のアニメーションを撮る ○ composeTestRuleの自動同期を無効にして、手動で時間を進める ● 非同期処理の終了を待ってから撮る ○ IdlingResourceを使う

Slide 49

Slide 49 text

© DeNA Co., Ltd. 49 ご清聴ありがとうございました