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
4.8k
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.2k
DroidKaigi 2022: Gradle Managed Virtual Devicesで変化するエミュレータ活用術
sumio
2
8.7k
DeNA TechCon 2021 - スマホ向けゲームの辛い部分をコード自動生成技術で克服する / Overcoming the Painful Part of Smartphone Games Development with Automatic Code Generation
sumio
0
530
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.2k
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.6k
Other Decks in Programming
See All in Programming
Streams APIとTCPフロー制御 / Web Streams API and TCP flow control
tasshi
2
350
Arm移行タイムアタック
qnighy
0
330
聞き手から登壇者へ: RubyKaigi2024 LTでの初挑戦が 教えてくれた、可能性の星
mikik0
1
130
とにかくAWS GameDay!AWSは世界の共通言語! / Anyway, AWS GameDay! AWS is the world's lingua franca!
seike460
PRO
1
880
Macとオーディオ再生 2024/11/02
yusukeito
0
370
[Do iOS '24] Ship your app on a Friday...and enjoy your weekend!
polpielladev
0
110
よくできたテンプレート言語として TypeScript + JSX を利用する試み / Using TypeScript + JSX outside of Web Frontend #TSKaigiKansai
izumin5210
6
1.7k
RubyLSPのマルチバイト文字対応
notfounds
0
120
What’s New in Compose Multiplatform - A Live Tour (droidcon London 2024)
zsmb
1
470
TypeScript Graph でコードレビューの心理的障壁を乗り越える
ysk8hori
2
1.1k
「今のプロジェクトいろいろ大変なんですよ、app/services とかもあって……」/After Kaigi on Rails 2024 LT Night
junk0612
5
2.2k
どうして僕の作ったクラスが手続き型と言われなきゃいけないんですか
akikogoto
1
120
Featured
See All Featured
YesSQL, Process and Tooling at Scale
rocio
169
14k
GraphQLの誤解/rethinking-graphql
sonatard
67
10k
The Myth of the Modular Monolith - Day 2 Keynote - Rails World 2024
eileencodes
16
2.1k
"I'm Feeling Lucky" - Building Great Search Experiences for Today's Users (#IAC19)
danielanewman
226
22k
Typedesign – Prime Four
hannesfritz
40
2.4k
Distributed Sagas: A Protocol for Coordinating Microservices
caitiem20
329
21k
Fight the Zombie Pattern Library - RWD Summit 2016
marcelosomers
232
17k
ReactJS: Keep Simple. Everything can be a component!
pedronauck
665
120k
[RailsConf 2023 Opening Keynote] The Magic of Rails
eileencodes
28
9.1k
Testing 201, or: Great Expectations
jmmastey
38
7.1k
Build The Right Thing And Hit Your Dates
maggiecrowley
33
2.4k
Let's Do A Bunch of Simple Stuff to Make Websites Faster
chriscoyier
506
140k
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 ご清聴ありがとうございました