スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

スタディサプリ小学・中学講座にRoborazziを導入して半年が経過しました

こんにちは、Androidエンジニアの@morux2です。

スタディサプリ小学・中学講座では、Visual Regression Test (以下 VRT)を実施しています。VRTは画像比較によるUIの回帰テストです。変更前後のコードそれぞれに対する画面のスクリーンショットを比較し、意図しない差分を検知することができます。*1

今回はスクリーンショットの撮影にRoborazziを導入して半年が経過したので、現状の運用やTipsを共有できればと思います。なお、執筆当時のRoborazziのバージョンは1.9.0です。

VRTの運用

Roborazzi導入時のブログ登壇資料も併せてご覧ください。

speakerdeck.com

構成

VRTには大きく3つのステップがあります。半年前にスクリーンショットの撮影をFirebase Test LabからRoborazziに移行しました。これによって撮影環境が実デバイスからJVMに変わりました。

  • 画面の用意 (Showkase)
    • @PreviewのついたComposableをリスト形式で一括で取得
  • 撮影 (Roborazzi)
    • ComposableのリストをParameterizedテストに渡してcaptureRoboImage()メソッドで撮影
  • 比較 (reg-suit)
    • 変更前後の画像をクラウドストレージで保存し、比較した結果(差分)をPRにコメント・Slackに通知

生成された差分レポート
Slack・PR上での通知

実行タイミング

masterブランチへのコミットとPRに対して実行しています。

  • masterブランチ

全てのmergeコミットに対してVRTを実行することで、 UIの差分の原因となるコミットを確実に特定できるようにしています。比較対象は直前のmergeコミットです。

  • PR

Run VRT ラベルを付与した場合に実行します。PR作成者の判断で、UIに関係のないモジュールやCI等の変更の際は実行しなくても良いことにしています。比較対象はmasterの最新のコミットです。

Run VRTラベルをPRに付与

撮影内容
  • UIコンポーネントは単一のデバイス、スクリーンは複数デバイスで撮影をしています。
  • 小学講座はタブレット専用アプリなので、8インチ(小さめ)と10インチ(推奨サイズ)で撮影をしています。一方中学講座は、スマートフォンタブレットで撮影をしています。
  • 撮影枚数は小学・中学講座合わせて650枚ほどになります。そのうち約600枚がRoborazziを用いてJVM上で撮影をしています。

小学講座のVRT
中学講座のVRT

実行時間

VRTの実行時間は30分程度になります。CI環境はGitHub Actionsです。内訳は以下の通りです。

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のレポートを作成します。

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では簡単にフォントスケールを変更できます。これによって、フォントサイズを大きくした際のアプリの描画崩れを確認し、改善箇所に優先度をつけることができます。

スケールを2fに設定して撮影

実装イメージ

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導入や知見共有のきっかけとなれば嬉しいです😊