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
5.3k
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.5k
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
9.1k
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.4k
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
Refactor your code - refactor yourself
xosofox
1
260
Semantic Kernelのネイティブプラグインで知識拡張をしてみる
tomokusaba
0
180
nekko cloudにおけるProxmox VE利用事例
irumaru
3
430
ドメインイベント増えすぎ問題
h0r15h0
2
300
From Translations to Multi Dimension Entities
alexanderschranz
2
130
Stackless и stackful? Корутины и асинхронность в Go
lamodatech
0
750
CSC305 Lecture 26
javiergs
PRO
0
140
KMP와 kotlinx.rpc로 서버와 클라이언트 동기화
kwakeuijin
0
140
Итераторы в Go 1.23: зачем они нужны, как использовать, и насколько они быстрые?
lamodatech
0
770
フロントエンドのディレクトリ構成どうしてる? Feature-Sliced Design 導入体験談
osakatechlab
8
4.1k
KubeCon + CloudNativeCon NA 2024 Overviewat Kubernetes Meetup Tokyo #68 / amsy810_k8sjp68
masayaaoyama
0
250
SymfonyCon Vienna 2025: Twig, still relevant in 2025?
fabpot
3
1.2k
Featured
See All Featured
Facilitating Awesome Meetings
lara
50
6.1k
Scaling GitHub
holman
458
140k
The Cult of Friendly URLs
andyhume
78
6.1k
Chrome DevTools: State of the Union 2024 - Debugging React & Beyond
addyosmani
2
170
Optimising Largest Contentful Paint
csswizardry
33
3k
[RailsConf 2023] Rails as a piece of cake
palkan
53
5k
The Straight Up "How To Draw Better" Workshop
denniskardys
232
140k
Cheating the UX When There Is Nothing More to Optimize - PixelPioneers
stephaniewalter
280
13k
Visualizing Your Data: Incorporating Mongo into Loggly Infrastructure
mongodb
44
9.3k
XXLCSS - How to scale CSS and keep your sanity
sugarenia
247
1.3M
Agile that works and the tools we love
rasmusluckow
328
21k
How to train your dragon (web standard)
notwaldorf
88
5.7k
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 ご清聴ありがとうございました