こんにちは、Androidエンジニアの@morux2です。本記事ではスクリーンショットの撮影にRoborazziを導入した経緯をご紹介できればと思います。
はじめに
スタディサプリ小学・中学講座では、UnitTestに加えてVisual Regression Test (以下 VRT)を行っています。VRTは画像比較によるUIの回帰テストです。変更前後のコードそれぞれに対する画面のスクリーンショットを比較し、意図しない差分を検知することができます。*1
VRTは、画面の用意・撮影・比較の3ステップに分けることができます。これまでは以下の実装方法になっていました。*2*3
- 画面の用意
- @PreviewのついたComposable
- スクロールが必要な画面
- ComposeTestRule(performScrollToNode)
- 撮影
- ComposeTestRule(captureToImage) + Firebase Test Lab
- 比較
今回は3つのステップのうち、撮影の部分をRoborazziに移行しました。RoborazziはAndroid端末を使わずに、JVM上でスクリーンショットを撮影することができるライブラリです。Robolectricのグラフィック機能がベースとなっており、AndroidTestではなくUnitTestとして実行することができます。
きっかけ
2023年9月、スタディサプリ中学講座に小学コンテンツが追加され、小学・中学講座に生まれ変わりました。*4 小学講座はタブレット専用のUIになっているので、これまでのスマートフォンでのVRTに加えてタブレットでの実行が必要不可欠になりました。
そこで、近年主流となっているJVM上でスクリーンショットを撮影できるライブラリの導入を検討しました。コストや実行時間の削減、複数端末での撮影を期待したためです。
RoborazziとPaparazziの比較
今回JVM上でスクリーンショットを撮影できるライブラリとして、PaparazziとRoborazziを比較検討しました。観点は以下になります。
- 書きやすさ
- 複数端末での撮影
- スクロールした画面の撮影
- Showkaseの流用
スマートフォン・タブレット両者での撮影はもちろん、複数の画面サイズでの品質担保も必要でした。特に小学講座は8インチ以上のタブレットを動作対象としているため、10~11インチの推奨サイズに加えて小さい端末での見た目も確認する必要があります。また、中学講座ではほとんどの画面で縦スクロールするため、スクロールした画面の撮影が必要でした。
書きやすさ
Paparazzi, Roborazziともにメソッドを呼び出すだけで手軽にスクリーンショットが撮影出来ます。どちらも現状はJUnit4で実行することになるので、JUnit5に移行をしている場合はVintageライブラリを使って実行することになります。*5
PaparazziとRoborazziのサンプルコード
Paparazzi
import app.cash.paparazzi.DeviceConfig.Companion.PIXEL_5 import app.cash.paparazzi.Paparazzi import org.junit.Rule import org.junit.Test class MySnapshotTest { @get:Rule val paparazzi = Paparazzi(deviceConfig = PIXEL_5) @Test fun captureMyComposableScreen() { paparazzi.snapshot { MyComposableScreen() } } }
Roborazzi
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.captureRoboImage import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel5) class MySnapshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun captureMyComposableScreen() { composeTestRule.setContent { MyComposableScreen() } composeTestRule .onRoot() .captureRoboImage() } }
複数端末での撮影
両者ともに複数端末での撮影は可能です。
PaparazziとRoborazziのサンプルコード
Paparazzi
import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(TestParameterInjector::class) class MySnapshotTest { @get:Rule val paparazzi = Paparazzi() enum class Device(public val deviceConfig: DeviceConfig) { PIXEL_5(DeviceConfig.PIXEL_5), PIXEL_C(DeviceConfig.PIXEL_C) } @Test fun captureMyComposableScreen( @TestParameter device: Device, ) { paparazzi.unsafeUpdateConfig(deviceConfig = device.deviceConfig) paparazzi.snapshot(name = device.name) { MyComposableScreen() } } }
Roborazzi
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.captureRoboImage import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MySnapshotTest { @get:Rule val composeTestRule = createComposeRule() @Test @Config(qualifiers = RobolectricDeviceQualifiers.Pixel5) fun captureMyComposableScreenPixel5()= captureMyComposableScreen() @Test @Config(qualifiers = RobolectricDeviceQualifiers.PixelC) fun captureMyComposableScreenPixelC()= captureMyComposableScreen() private fun captureMyComposableScreen() { composeTestRule.setContent { MyComposableScreen() } composeTestRule .onRoot() .captureRoboImage() } }
Parametarizedテストを用いる方法
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.captureRoboImage import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.GraphicsMode @RunWith(ParameterizedRobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) class MySnapshotTest( private val device: Device, ) { @get:Rule val composeTestRule = createComposeRule() enum class Device(public val robolectricDeviceQualifiers: String) { PIXEL_5(RobolectricDeviceQualifiers.Pixel5), PIXEL_C(RobolectricDeviceQualifiers.PixelC) } @Test fun captureMyComposableScreen() { RuntimeEnvironment.setQualifiers(device.robolectricDeviceQualifiers) composeTestRule.setContent { MyComposableScreen() } composeTestRule .onRoot() .captureRoboImage() } companion object { @ParameterizedRobolectricTestRunner.Parameters @JvmStatic fun data(): Array<Device> { return Device.values() } } }
スクロールした画面の撮影
スマホで閲覧する中学講座アプリはほとんどの画面で縦スクロールするため、スクロール後UIの品質担保を重視しました。RoborazziはRobolectricベースなので、composeTestRuleをUnitTestで呼び出してスクロールを実行することが可能です。一方Paparazziではスクロールを実行できません。代わりに縦長画像を撮影できる機能が検討されていますが、1年近く進展がありません。
Roborazziのサンプルコード
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.captureRoboImage import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode @RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(qualifiers = RobolectricDeviceQualifiers.Pixel5) public class MySnapshotTest { @get:Rule val composeTestRule = createComposeRule() @Test fun captureMyComposableScreen() { composeTestRule.setContent { MyComposableScreen() } composeTestRule .onNodeWithTag("lazyColumn") .performScrollToNode(hasText("Hello")) .captureRoboImage() } }
Showkaseの流用
Preview流用によるテストコードの削減も欠かせませんでした。Paparazzi・RoborazziともにShowkaseを流用し、@PreviewのついたComposableのキャプチャを一括で撮影することが可能です。詳細な説明は割愛しますので、DroidKaigi conference-app-2022および DroidKaigi conference-app-2023をご確認ください。
Roborazziの採用理由
書きやすさ・複数端末での撮影・Showkaseの流用は両者差がありませんでしたが、スクロールの実行はRoborazziのみ可能でした。RoborazziのREADMEでも同様の言及があり、RobolectricベースであるためにHiltでFakeをInjectできること、UIに対してスクロールやタップアクションを実施できることが優位性として述べられています。
Google I/O 2023 では、公式からHost-side Screenshot Testingの発表もありましたが、AndroidStudioのPreview機能がベースとなっているため、こちらもスクロールの実行は厳しいのではないかと推測されています。*7
以上を踏まえて、我々はRoborazziの採用を決定しました。検討の段階でnowinandroidにRoborazziが導入されたことも大きな決め手となりました。
現状の運用
中学講座は今まで通り実機でスクリーンショットを撮影し、小学講座のみRoborazziを利用しています。今後は中学もRoborazziに移行し、小学については複数端末での実行を検討していきます。
- 画面の用意
- @PreviewのついたComposable
- Showkase + ComposeTestRule
- スクロールが必要な画面
- ComposeTestRule(performScrollToNode)
- @PreviewのついたComposable
- 撮影
- 小学講座 (new!)
- Roborazzi
- 中学講座
- ComposeTestRule(captureToImage) + Firebase Test Lab
- 小学講座 (new!)
- 比較
- reg-suit
さいごに
今回はスタディサプリ小学・中学講座でRoborazziを導入した経緯を紹介しました。Roborazziの移行が進みましたら、Tips等も共有できればと思います。まだまだ試行錯誤の段階なので、常により良い方法やアイデアを歓迎しています。
スタディサプリでは、一緒に最高のプロダクトを作っていってくれる仲間を募集しています! 少しでもご興味がある方はこちらのページからご連絡ください! brand.studysapuri.jp
*1:https://blog.studysapuri.jp/entry/2021-08-23/android-vrt-tips-1
*2:https://speakerdeck.com/recruitengineers/abceed-tech-night?slide=52
*3:https://cats-234205.web.app/2020/visual-regression-testing-with-android/
*4:https://studysapuri.jp/course/elementary/sho1/
*5:https://github.com/takahirom/roborazzi/issues/152
*6:https://github.com/takahirom/roborazzi/issues/47#issuecomment-1519013180