Lock in $30 Savings on PRO—Offer Ends Soon! ⏳
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
Roborazziでスクリーンショットを撮るときに役立つTips集 / A collecti...
Search
TOYAMA Sumio
January 31, 2024
Programming
2
5.1k
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
Share
More Decks by TOYAMA Sumio
See All by TOYAMA Sumio
Understand the mechanism! Let's do screenshots tests of Compose Previews with various variations / 仕組みから理解する!Composeプレビューを様々なバリエーションでスクリーンショットテストしよう
sumio
6
3.4k
DroidKaigi 2022: Gradle Managed Virtual Devicesで変化するエミュレータ活用術
sumio
2
8.8k
DeNA TechCon 2021 - スマホ向けゲームの辛い部分をコード自動生成技術で克服する / Overcoming the Painful Part of Smartphone Games Development with Automatic Code Generation
sumio
0
540
Robolectricの限界を理解してUIテストを高速に実行しよう / Let's run UI Test faster with understanding limit of Robolectric
sumio
3
9k
EspressoではじめるAndroid UIテスト / Android UI Testing Starting with Espresso
sumio
2
2.4k
Espressoの知識ゼロでも書ける!Android UIテストはじめの一歩 / The First Step of Android UI Testing
sumio
1
8.3k
EspressoのテストをAndroidの最新トレンドに対応させよう / Make Espresso testing follow the cutting edge in Android development
sumio
3
18k
KotlinでEspressoテストがもっと書きやすくなるKakaoを試してみた / Trying Kakao which makes Espresso test easier to write
sumio
2
1k
Espressoテストコードの同期処理を究める / Synchronization capabilities of Espresso
sumio
6
6.7k
Other Decks in Programming
See All in Programming
かんたんデザイン編集やってみた~「完全に理解した」までの道のり~
morit4ryo
1
120
Reckoner における Datadog Browser Test の活用事例 / Datadog Browser Test at Reckoner
nomadblacky
0
190
HTTP compression in PHP and Symfony apps
dunglas
2
1.4k
Functional Event Sourcing using Sekiban
tomohisa
0
130
Jakarta EE meets AI
ivargrimstad
0
830
クリエイティブコーディングとRuby学習 / Creative Coding and Learning Ruby
chobishiba
0
3.5k
Modular Monolith Monorepo ~シンプルさを保ちながらmonorepoのメリットを最大化する~
yuisakamoto
11
3.9k
Develop iOS apps with Neovim / vimconf_2024
uhooi
1
310
コンテンツの主権を守るため(?)、高機能画像CDNからAWS自前対応に乗り換えた話
lengthtail
1
120
社内活動の取り組み紹介 ~ スリーシェイクでこんな取り組みしてます ~
bells17
0
390
聞き手から登壇者へ: RubyKaigi2024 LTでの初挑戦が 教えてくれた、可能性の星
mikik0
1
150
たのしいparse.y
ydah
3
110
Featured
See All Featured
Site-Speed That Sticks
csswizardry
1
140
Building Applications with DynamoDB
mza
91
6.1k
Refactoring Trust on Your Teams (GOTO; Chicago 2020)
rmw
31
2.7k
[RailsConf 2023] Rails as a piece of cake
palkan
53
5k
Rebuilding a faster, lazier Slack
samanthasiow
79
8.7k
Performance Is Good for Brains [We Love Speed 2024]
tammyeverts
6
480
The Language of Interfaces
destraynor
154
24k
I Don’t Have Time: Getting Over the Fear to Launch Your Podcast
jcasabona
29
2k
A better future with KSS
kneath
238
17k
The Invisible Side of Design
smashingmag
298
50k
Visualization
eitanlees
145
15k
Designing Experiences People Love
moore
138
23k
Transcript
© DeNA Co., Ltd. 1 Roborazzi + Jetpack Composeで スクリーンショットを撮るときに役立つTips集
2024.01.31 TOYAMA Sumio (sumio_tym) Android Test Night #9
© 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
© DeNA Co., Ltd. 3 お話しすること Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsをいくつかご紹介します •
スクロールが必要な画面を撮る • Coilによる画像読み込みを含む画面を撮る • 再生途中のアニメーションを撮る • 非同期処理の終了を待ってから撮る
© DeNA Co., Ltd. 4 1 Roborazziの概要 スクロールが必要な画面のスクリーンショットを撮る Coilによる画像読み込みを含む画面のスクリーンショットを撮る アニメーション再生途中のタイミングでスクリーンショットを撮る
4 3 2 目次 非同期処理が終わったタイミングでスクリーンショットを撮る 5
© DeNA Co., Ltd. 5 5 01 Roborazziの概要
© DeNA Co., Ltd. 6 Roborazziの特徴 Local Test (JVM上で動くテスト)で動く スクリーンショットテストツール
• https://github.com/takahirom/roborazzi • Robolectric 4.10より導入されたRobolectric Native Graphicsを使って実現 • Local Testで動くので極めて高速
© DeNA Co., Ltd. 7 Roborazziの使い方 (トップレベル build.gradle) plugins {
id("io.github.takahirom.roborazzi") version "<version>" apply false }
© 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") }
© 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") }
© 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 のテストに必要
© 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
© DeNA Co., Ltd. 12 12 02 スクロールが必要な画面の スクリーンショットを撮る
© DeNA Co., Ltd. 13 スクロールが必要なほど長いリスト @Composable fun LongList() {
LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp), ) { items(100) { Text(text = "[$it]") Divider() } } }
© 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() を呼ぶ
© DeNA Co., Ltd. 15 1枚目 2枚目 3枚目 4枚目 5枚目
方法1: 少しずつスクロールして撮影を繰り返す (結果)
© 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にする
© DeNA Co., Ltd. 17 方法2: 画面サイズを縦に引きのばして1枚で撮影する② ... @Test fun
testLongList() { setDisplayHeight(3000.dp) composeTestRule.activityRule.scenario.recreate() composeTestRule.setContent { LongList() } composeTestRule.onRoot().captureRoboImage() } 画面の高さを 十分大きな値に変更して Activityを再生成する
© 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を 使うと画面高さを 強制的に変更できる
© DeNA Co., Ltd. 19 方法2: 画面サイズを縦に引きのばして1枚で撮影する(結果)
© DeNA Co., Ltd. 20 ここまでのまとめ • 長いリストのスクリーンショットを撮る方法は2通りある • 少しずつスクロールしながら、複数枚撮る方法
• 画面の高さを大きな値に変更してから1枚で撮る方法 • 画面の高さを変更するには、RobolectricのShadowDisplayを使う ◦ 高さ変更後にActivityの再生成が必要
© DeNA Co., Ltd. 21 21 03 Coilによる画像読み込みを含む 画面のスクリーンショットを撮る
© 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) } }
© DeNA Co., Ltd. 23 このままスクリーンショットを撮ると・・・ class NaiveImageScreenTest { @get:Rule
val composeTestRule = createComposeRule() @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot().captureRoboImage() } }
© 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()
© 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を上書き
© DeNA Co., Ltd. 26 Coilが提供しているFakeImageLoaderを使う③ @After fun tearDown() {
Coil.reset() } @Test fun testImageScreen() { composeTestRule.setContent { ImageScreen() } composeTestRule.onRoot() .captureRoboImage() } Coilが使うImageLoaderを 元に戻す
© DeNA Co., Ltd. 27 ここまでのまとめ • Coilによる画像読み込みを含む画面は、 そのままではスクリーンショットが撮れない •
同期的に(ローカルにある)画像を読み込むFakeImageLoaderを使う • 「このURLのときはこのDrawableを描画する」というルールを 複数指定できる • Coil.setImageLoader()でCoilが利用するImageLoaderを FakeImageLoaderに差し替えられる
© DeNA Co., Ltd. 28 28 04 再生途中のアニメーションの スクリーンショットを撮る
© 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にする
© DeNA Co., Ltd. 30 このままスクリーンショットを撮ると・・・ @Test fun testAfterAnimation() {
composeTestRule.apply { setContent { AnimatedRectangle() } onNode(hasText("広げる")).performClick() onRoot().captureRoboImage() } } ボタンを押してすぐに captureRoboImage()を呼ぶ アニメーション完了後 の状態が撮影される
© 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)を使って 自分で時間を進められるようになる
© 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秒進める
© DeNA Co., Ltd. 33 ここまでのまとめ • composeTestRuleはデフォルトで自動同期がONになっている • 自動同期がONだと、アニメーション再生中のような
ビジー状態のスクリーンショットを撮ることができない • composeTestRule.mainClock.autoAdvance = false で 自動同期をOFFにできる • advanceTimeBy(milliseconds)などを使って自分で時間を進めれば、 特定時刻のアニメーションの状態を撮影できる
© DeNA Co., Ltd. 34 34 05 その他の非同期処理が終わった タイミングで スクリーンショットを撮る
© 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秒後
© DeNA Co., Ltd. 36 ボタンを押した直後にスクリーンショットを撮ると・・ @Test fun testAfterClick() {
composeTestRule.setContent { DelayedButton() } composeTestRule .onNode(hasText("開始")) .performClick() composeTestRule.onRoot().captureRoboImage() } 「開始」ボタンを押して すぐにキャプチャ 「完了」に変わる前に キャプチャされてしまう
© 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とは 所属パッケージが異なる
© 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 ビジー)が 変わったときに呼んでもらう (プロダクトコード側から)
© 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) であることを通知
© 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で解除
© 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をインジェクト アイドル状態になるまで待つ
© DeNA Co., Ltd. 42 Jetpack Compose用のIdlingResource ⑤ ところが結果は・・・ 「開始」のまま!
実装を追ったところ、 RobolectricではIdlingResourceの状態は無視されていた
© 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になるまで待つ
© DeNA Co., Ltd. 44 Jetpack Compose用のIdlingResource ⑦ 結果は・・・ 「完了」になった!
© 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 ずつ進める
© 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() }
© DeNA Co., Ltd. 47 ここまでのまとめ • rememberCoroutineScopeを使ったコルーチンの完了待ちには IdlingResourceを使う •
EspressoのIdlingResourceとはパッケージが違う点に注意 • RobolectricではIdlingResourceを登録しても無視されるので waitForIdle()の代わりに waitUntil { idlingResource.isIdleNow } を使う • 上記以外の非同期処理待ちをするときは、 そのスケジューラが使っている時計も進める必要がある (Handler.postDelayedなら ShadowLooper.idleMainLooper(...))
© DeNA Co., Ltd. 48 全体のまとめ Jetpack Composeの画面スクリーンショットを Roborazziで撮るときに役立つTipsを紹介しました •
スクロールが必要な画面 ◦ RobolectricのShadowDisplayを使って画面高さを変更する • Coilによる画像読み込みを含む画面を撮る ◦ Coilに用意されているFakeImageLoaderを使う • 再生途中のアニメーションを撮る ◦ composeTestRuleの自動同期を無効にして、手動で時間を進める • 非同期処理の終了を待ってから撮る ◦ IdlingResourceを使う
© DeNA Co., Ltd. 49 ご清聴ありがとうございました