Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
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
3.5k
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
DroidKaigi 2022: Gradle Managed Virtual Devicesで変化するエミュレータ活用術
sumio
2
8.5k
DeNA TechCon 2021 - スマホ向けゲームの辛い部分をコード自動生成技術で克服する / Overcoming the Painful Part of Smartphone Games Development with Automatic Code Generation
sumio
0
520
Robolectricの限界を理解してUIテストを高速に実行しよう / Let's run UI Test faster with understanding limit of Robolectric
sumio
3
8.7k
EspressoではじめるAndroid UIテスト / Android UI Testing Starting with Espresso
sumio
2
2.3k
Espressoの知識ゼロでも書ける!Android UIテストはじめの一歩 / The First Step of Android UI Testing
sumio
1
7.7k
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.5k
RxJavaの非同期処理に負けないEspressoテストコードを書く / Let's Write Espresso Tests Synchronizing with RxJava's Asynchronous Processing
sumio
0
1.1k
Other Decks in Programming
See All in Programming
Rustではじめる負荷試験
skanehira
5
1.1k
複雑さに立ち向かうための ソフトウェア開発入門
shiz
2
580
connect-go で面倒くささと戦う / 2024-08-27 #newmo_layerx_go
izumin5210
2
590
Mastering AsyncSequence - 使う・作る・他のデザインパターン(クロージャ、Delegate など)から移行する
treastrain
3
1.3k
Regular Expressions, REXML, Automata Learning
makenowjust
0
180
iOSの隠されたAPIを解明し、開発効率を向上させる方法/iOSDC24
noppefoxwolf
2
120
BasicBasic認証
sadnessojisan
5
2.5k
初めてのiOS関連GitHub ActionsをMarketplaceに公開するまでの実録
konifar
3
200
月間4.5億回再生を超える大規模サービス TVer iOSアプリのリアーキテクチャ戦略 - iOSDC2024
techtver
PRO
1
570
プログラマのための音楽入門
cheebow
4
540
フロントエンドカンファレンス北海道2024 『小規模サイトでも使えるVite 〜HTMLコーディングをよりスマートに〜』長谷川広武(ハム)
h2ham
1
2.5k
Scala におけるコンパイラエラーとの付き合い方
chencmd
1
150
Featured
See All Featured
Embracing the Ebb and Flow
colly
83
4.4k
The Pragmatic Product Professional
lauravandoore
30
6.2k
The Cult of Friendly URLs
andyhume
76
5.9k
Navigating Team Friction
lara
183
13k
Rebuilding a faster, lazier Slack
samanthasiow
78
8.5k
Fashionably flexible responsive web design (full day workshop)
malarkey
400
65k
Building Applications with DynamoDB
mza
89
5.9k
A Modern Web Designer's Workflow
chriscoyier
690
190k
A Tale of Four Properties
chriscoyier
155
22k
Dealing with People You Can't Stand - Big Design 2015
cassininazir
363
22k
StorybookのUI Testing Handbookを読んだ
zakiyama
25
5k
Bash Introduction
62gerente
608
210k
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 ご清聴ありがとうございました