Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Roborazziでスクリーンショットを撮るときに役立つTips集 / A collecti...

TOYAMA Sumio
January 31, 2024

Roborazziでスクリーンショットを撮るときに役立つTips集 / A collection of useful tips for taking screenshots in Roborazzi

2024年1月31日に開催されたAndroid Test Night #9の発表資料です。

TOYAMA Sumio

January 31, 2024
Tweet

More Decks by TOYAMA Sumio

Other Decks in Programming

Transcript

  1. © 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
  2. © DeNA Co., Ltd. 3 お話しすること Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsをいくつかご紹介します •

    スクロールが必要な画面を撮る • Coilによる画像読み込みを含む画面を撮る • 再生途中のアニメーションを撮る • 非同期処理の終了を待ってから撮る
  3. © DeNA Co., Ltd. 6 Roborazziの特徴 Local Test (JVM上で動くテスト)で動く スクリーンショットテストツール

    • https://github.com/takahirom/roborazzi • Robolectric 4.10より導入されたRobolectric Native Graphicsを使って実現 • Local Testで動くので極めて高速
  4. © DeNA Co., Ltd. 7 Roborazziの使い方 (トップレベル build.gradle) plugins {

    id("io.github.takahirom.roborazzi") version "<version>" apply false }
  5. © 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") }
  6. © 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") }
  7. © 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 のテストに必要
  8. © 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
  9. © DeNA Co., Ltd. 13 スクロールが必要なほど長いリスト @Composable fun LongList() {

    LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp), ) { items(100) { Text(text = "[$it]") Divider() } } }
  10. © 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() を呼ぶ
  11. © DeNA Co., Ltd. 15 1枚目 2枚目 3枚目 4枚目 5枚目

    方法1: 少しずつスクロールして撮影を繰り返す (結果)
  12. © 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<ComponentActivity>() lateinit var context: Context @Before fun setUp() { context = ApplicationProvider.getApplicationContext() } Display情報をPixel7に指定 後でActivityを使うので createAndroidComposeRuleにする
  13. © DeNA Co., Ltd. 17 方法2: 画面サイズを縦に引きのばして1枚で撮影する② ... @Test fun

    testLongList() { setDisplayHeight(3000.dp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { LongList() } composeTestRule.onRoot().captureRoboImage() } 画面の高さを 十分大きな値に変更して Activityを再生成する
  14. © 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を 使うと画面高さを 強制的に変更できる
  15. © DeNA Co., Ltd. 20 ここまでのまとめ • 長いリストのスクリーンショットを撮る方法は2通りある • 少しずつスクロールしながら、複数枚撮る方法

    • 画面の高さを大きな値に変更してから1枚で撮る方法 • 画面の高さを変更するには、RobolectricのShadowDisplayを使う ◦ 高さ変更後にActivityの再生成が必要
  16. © 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) } }
  17. © DeNA Co., Ltd. 23 このままスクリーンショットを撮ると・・・ class NaiveImageScreenTest { @get:Rule

    val composeTestRule = createComposeRule() @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot().captureRoboImage() } }
  18. © 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(<URL1>, Drawable(Color.RED)) .intercept(<URL2>, Drawable(Color.GREEN)) .default(ColorDrawable(Color.BLUE)) .build() val imageLoader = ImageLoader.Builder(context) .components { add(engine) } .build()
  19. © DeNA Co., Ltd. 25 Coilが提供しているFakeImageLoaderを使う② @Before fun setUp() {

    val context = ApplicationProvider.getApplicationContext<Context>() 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を上書き
  20. © DeNA Co., Ltd. 26 Coilが提供しているFakeImageLoaderを使う③ @After fun tearDown() {

    Coil.reset() } @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot() .captureRoboImage() } Coilが使うImageLoaderを 元に戻す
  21. © DeNA Co., Ltd. 27 ここまでのまとめ • Coilによる画像読み込みを含む画面は、 そのままではスクリーンショットが撮れない •

    同期的に(ローカルにある)画像を読み込むFakeImageLoaderを使う • 「このURLのときはこのDrawableを描画する」というルールを 複数指定できる • Coil.setImageLoader()でCoilが利用するImageLoaderを FakeImageLoaderに差し替えられる
  22. © 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にする
  23. © DeNA Co., Ltd. 30 このままスクリーンショットを撮ると・・・ @Test fun testAfterAnimation() {

    composeTestRule.apply { setContent { AnimatedRectangle() } onNode(hasText("広げる")).performClick() onRoot().captureRoboImage() } } ボタンを押してすぐに captureRoboImage()を呼ぶ アニメーション完了後 の状態が撮影される
  24. © 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)を使って 自分で時間を進められるようになる
  25. © 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秒進める
  26. © DeNA Co., Ltd. 33 ここまでのまとめ • composeTestRuleはデフォルトで自動同期がONになっている • 自動同期がONだと、アニメーション再生中のような

    ビジー状態のスクリーンショットを撮ることができない • composeTestRule.mainClock.autoAdvance = false で 自動同期をOFFにできる • advanceTimeBy(milliseconds)などを使って自分で時間を進めれば、 特定時刻のアニメーションの状態を撮影できる
  27. © 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秒後
  28. © DeNA Co., Ltd. 36 ボタンを押した直後にスクリーンショットを撮ると・・ @Test fun testAfterClick() {

    composeTestRule.setContent { DelayedButton() } composeTestRule .onNode(hasText("開始")) .performClick() composeTestRule.onRoot().captureRoboImage() } 「開始」ボタンを押して すぐにキャプチャ 「完了」に変わる前に キャプチャされてしまう
  29. © 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とは 所属パッケージが異なる
  30. © 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 ビジー)が 変わったときに呼んでもらう (プロダクトコード側から)
  31. © 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) であることを通知
  32. © 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で解除
  33. © 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をインジェクト アイドル状態になるまで待つ
  34. © DeNA Co., Ltd. 42 Jetpack Compose用のIdlingResource ⑤ ところが結果は・・・ 「開始」のまま!

    実装を追ったところ、 RobolectricではIdlingResourceの状態は無視されていた
  35. © 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になるまで待つ
  36. © 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 ずつ進める
  37. © DeNA Co., Ltd. 46 補足: Jetpack Composeのコルーチンだけ時間操作する方法 • Jetpack

    Composeで使われるTestDispatcherを差し替えることもできる • その場合は、TestScope.advanceUntilIdle()などを使って時間操作する val coroutineDispatcher = UnconfinedTestDispatcher() @get:Rule val composeTestRule = createAndroidComposeRule<ComponentActivity>( effectContext = coroutineDispatcher ) @Test fun testUntilIdle() = runTest(coroutineDispatcher.scheduler) { ... composeTestRule.onNode(hasText("開始")).performClick() advanceUntilIdle() composeTestRule.onRoot().captureRoboImage() }
  38. © DeNA Co., Ltd. 47 ここまでのまとめ • rememberCoroutineScopeを使ったコルーチンの完了待ちには IdlingResourceを使う •

    EspressoのIdlingResourceとはパッケージが違う点に注意 • RobolectricではIdlingResourceを登録しても無視されるので waitForIdle()の代わりに waitUntil { idlingResource.isIdleNow } を使う • 上記以外の非同期処理待ちをするときは、 そのスケジューラが使っている時計も進める必要がある (Handler.postDelayedなら ShadowLooper.idleMainLooper(...))
  39. © DeNA Co., Ltd. 48 全体のまとめ Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsを紹介しました •

    スクロールが必要な画面 ◦ RobolectricのShadowDisplayを使って画面高さを変更する • Coilによる画像読み込みを含む画面を撮る ◦ Coilに用意されているFakeImageLoaderを使う • 再生途中のアニメーションを撮る ◦ composeTestRuleの自動同期を無効にして、手動で時間を進める • 非同期処理の終了を待ってから撮る ◦ IdlingResourceを使う