こんにちは、Androidエンジニアの@morux2です。
スタディサプリ小学・中学講座では、Visual Regression Test (以下 VRT)を実施しています。VRTは画像比較によるUIの回帰テストです。変更前後のコードそれぞれに対する画面のスクリーンショットを比較し、意図しない差分を検知することができます。*1
今回はスクリーンショットの撮影にRoborazziを導入して半年が経過したので、現状の運用やTipsを共有できればと思います。なお、執筆当時のRoborazziのバージョンは1.9.0です。
VRTの運用
Roborazzi導入時のブログや登壇資料も併せてご覧ください。
構成
VRTには大きく3つのステップがあります。半年前にスクリーンショットの撮影をFirebase Test LabからRoborazziに移行しました。これによって撮影環境が実デバイスからJVMに変わりました。
- 画面の用意 (Showkase)
- @PreviewのついたComposableをリスト形式で一括で取得
- 撮影 (Roborazzi)
- ComposableのリストをParameterizedテストに渡してcaptureRoboImage()メソッドで撮影
- 比較 (reg-suit)
- 変更前後の画像をクラウドストレージで保存し、比較した結果(差分)をPRにコメント・Slackに通知
実行タイミング
masterブランチへのコミットとPRに対して実行しています。
- masterブランチ
全てのmergeコミットに対してVRTを実行することで、 UIの差分の原因となるコミットを確実に特定できるようにしています。比較対象は直前のmergeコミットです。
- PR
Run VRT ラベルを付与した場合に実行します。PR作成者の判断で、UIに関係のないモジュールやCI等の変更の際は実行しなくても良いことにしています。比較対象はmasterの最新のコミットです。
撮影内容
- UIコンポーネントは単一のデバイス、スクリーンは複数デバイスで撮影をしています。
- 小学講座はタブレット専用アプリなので、8インチ(小さめ)と10インチ(推奨サイズ)で撮影をしています。一方中学講座は、スマートフォンとタブレットで撮影をしています。
- 撮影枚数は小学・中学講座合わせて650枚ほどになります。そのうち約600枚がRoborazziを用いてJVM上で撮影をしています。
実行時間
VRTの実行時間は30分程度になります。CI環境はGitHub Actionsです。内訳は以下の通りです。
- assemble-for-android-test : 7分
Roborazziに移行できていない撮影をFirebase Test Labで実行するために、テストアプリをビルドするステップです。./gradlew assembleDebug
と./gradlew
assembleDebugAndroidTest
を実行し、upload-artifactアクションでapkファイルをアップロードします。
- run-roborazzi : 8分
./gradlew recordRoborazziDebug
で約600枚のスクリーンショットを撮影し、upload-artifactアクションで撮影した画像をアップロードします。
- run-fastlane : 5分
Roborazziに移行できていない約50枚のスクリーンショットを実デバイスで撮影します。Firebase Test Lab plugin for fastlaneを用いています。
- run-reg-suit : 15秒
reg-suitを実行し、RoborazziとFirebase Test Labで撮影した画像に対してVRTのレポートを作成します。
Roborazziを導入するまでは全ての撮影をFirebase Test Labで行っており、半数の約300枚の撮影に4,50分かかっていました。撮影枚数やCI環境が変わっているため厳密な比較はできませんが、実デバイスを用いないことによる実行時間の短縮が実感できています。
RoborazziのTips集
ここからはTipsを紹介します。
RoborazziとUnitTestを別々に実行する
参考 github.com
Roborazziのプラグインを適用しているモジュールに、VRT以外のUnitTestが存在する場合、./gradlew recordRoborazziDebug
を実行すると両者が走ってしまいます。そこで、gradleプロパティを独自に定義しました。./gradlew recordRoborazziDebug -PexcludeNonRoborazziTests
と呼び出すと、VRTのみを実行することができます。
実装イメージ
testOptions { unitTests { all { filter { if (project.hasProperty("excludeNonRoborazziTests")) includeTestsMatching "jp.studysapuri.tara.launch.vrt.*" } } } }
複数デバイスで撮影を行う
参考 github.com
複数デバイスで撮影を行う場合は独自の拡張関数を生やすと便利です。呼び出し元では、Roborazziに用意されているデバイスのConfigを用いることができます。
実装イメージ
fun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureMultiDevice( screenshotName: String, devices: List<Pair<String, String>>, body: @Composable () -> Unit, roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, ) { devices.forEach { device -> captureComponent( screenshotName = screenshotName, device = device, body = body, roborazziOptions = roborazziOptions, ) } } private fun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureComponent( screenshotName: String, device: Pair<String, String>, body: @Composable () -> Unit, roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, ) { RuntimeEnvironment.setQualifiers(device.second) this.activity.setContent { CompositionLocalProvider( LocalInspectionMode provides true, ) { body() } } val filePath = "$DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH/$screenshotName-${device.first}.png" this.onRoot().captureRoboImage( filePath = filePath, roborazziOptions = roborazziOptions, ) }
@RunWith(AndroidJUnit4::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @Config(application = TestApplication::class) class HomeFragmentTest { @get:Rule val composeTestRule = createAndroidComposeRule<DummyActivityForRoborazzi>() @get:Rule val testName = TestName() @Test fun takeHomeScreen() { composeTestRule.captureMultiDevice( screenshotName = testName.methodName, body = { HomeContent() }, devices = listOf( "phone" to RobolectricDeviceQualifiers.Pixel5, "tablet" to RobolectricDeviceQualifiers.MediumTablet, ) ) } }
Lottieが含まれるテストを安定させる
参考 github.com
Lottieでアニメーションしているコンポーネントの撮影がflakyになってしまう問題がありました。Lottieがバックグラウンドスレッドで実行されるのを防ぐことで、VRTを安定させています。
実装イメージ
@Before fun setup() { LottieTask.EXECUTOR = Executor(Runnable::run) } @After fun finished() { // 実行後はデフォルトの設定に戻す LottieTask.EXECUTOR = Executors.newCachedThreadPool() }
ダイアログを撮影する
参考 github.com
Roborazzi 1.9.0でスクリーンに描画されたダイアログを撮影できるcaptureScreenRoboImage()
が追加されました。Experimentalなのでダイアログの撮影にのみ用いています。
実装イメージ
private fun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureComponent( screenshotName: String, device: Pair<String, String>, body: @Composable () -> Unit, roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, ) { RuntimeEnvironment.setQualifiers(device.second) this.activity.setContent { CompositionLocalProvider( LocalInspectionMode provides true, ) { body() } } val filePath = "$DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH/$screenshotName-${device.first}.png" if (screenshotName.contains("Dialog")) { @OptIn(ExperimentalRoborazziApi::class) captureScreenRoboImage( filePath = filePath, roborazziOptions = roborazziOptions, ) } else { this.onRoot().captureRoboImage( filePath = filePath, roborazziOptions = roborazziOptions, ) } }
フォントスケールを変更する
参考 github.com
Robolectricでは簡単にフォントスケールを変更できます。これによって、フォントサイズを大きくした際のアプリの描画崩れを確認し、改善箇所に優先度をつけることができます。
実装イメージ
private fun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureComponent( screenshotName: String, device: Pair<String, String>, body: @Composable () -> Unit, roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, ) { RuntimeEnvironment.setFontScale(2.0f) RuntimeEnvironment.setQualifiers(device.second) this.activity.setContent { CompositionLocalProvider( LocalInspectionMode provides true, ) { body() } } val filePath = "$DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH/$screenshotName-${device.first}.png" this.onRoot().captureRoboImage( filePath = filePath, roborazziOptions = roborazziOptions, ) }
さいごに
Roborazziを採用したことで、以前より短いテスト時間で意図しないUIの差分を検知し、端末のサイズやフォントスケールによる描画崩れも確認することができています。最近だとfeatureモジュールの細分化や、Compose 1.6.0対応 (includeFontPaddingが無効になる *2 )に、VRTが大いに役立ちました。この記事がVRT導入や知見共有のきっかけとなれば嬉しいです😊