Android における Visual Regression Test tips 集 #1

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

今回は、弊 Android アプリ開発チームで取り組んでいる Visual Regression Test (以下 VRT)の tips 集についてお話しします。

はじめに

弊チームでは、実装初期の段階から unit test に加えて VRT を行っています。一方で Android 開発における VRT の歴史は長くないことに加え、弊チームでは Espresso 等を利用した instrumented tests の本格導入が初めてだったため、各所で問題に遭遇しました。
本記事では、 VRT を正しく実行するために弊チームが検討・導入してきた workaround を tips 集として紹介したいと思います (紹介する内容全てが最適解とは考えておらず、より良い方法を常に歓迎しています) 。

Visual Regression Test とは?

画像比較による UI の回帰テストです。変更前後のコードそれぞれに対する画面のスクリーンショットを比較し、意図しない差分を検知します。

弊チームにおける実装方法ですが、

という形で行っています。そのため、これらの技術セットに依存した tips となることをご了承ください。

Tips 集

1. テスト対象 Fragment の起動には FragmentScenario ではなく ActivityScenario を使用する

テストにおいて対象の画面 (Activity/Fragment) を UI 操作無しに直接起動する方法として、 ActivityScenario/FragmentScenario があります。

対象画面が Fragment の場合の起動方法として FragmentScenario があるはずなのですが、これを使用するとアプリで指定している一部 Theme が上手く適用されなくなることがあります。

理由としては FragmentScenario が 起動する Fragment に対して内部的に起動される親 Activity が AppCompatActivity を継承していないため(FragmentActivity を継承している)、AppCompat に関連する部分の Theme が適用されなくなる、ということが挙げられます。

これは MaterialComponents をベースの Theme にするような、AppCompat な Theme を明示的に利用していないケースでも起こる話のため、注意が必要です。

Workaround ですが、一度 ActivityScenario で 対象 Fragment の実の親となる Activity を起動し、そこから Fragment を起動するという形で対応しています。

private fun launchHogeFragment(): ActivityScenario<HogeParentActivity> {
    return ActivityScenario.launch(HogeParentActivity::class.java).onActivity {
        it.supportFragmentManager.commit {
            replace(R.id.fragmentContainer, HogeFragment.newInstance())
        }
    }
}

厳密には起動する Activity は AppCompatActivity を継承さえしていれば良く、実の親 Activity を指定する必要はありません。しかし、 Fragment 内で Activity のダウンキャストをするような実装をしている場合は実の親 Activity が上記のように必要となります (そもそもこうした結合度の高い実装をなるべく避けるべきという話は別途あるかと思います)。

2. API 通信を抑制する Rule を導入する

テストにおいて冪等性を保証するために、実際の API 通信は避け mock データを返すような Repository を ViewModel に注入するようにしています。

Koin を使用した例:

// テスト対象画面で起こるリクエストを mock する
private fun setupMockData() {
    val repository = mockk<HogeRepository> {
        every { getHoge() } returns flowOf(Hoge())
    }

    loadKoinModules(module {
         // HogeRepositoryを必要としている全箇所でこのインスタンスが利用される
        factory { repository }
    })
}

一方、1. であったように ActivityScenario 経由で Fragment を表示する(かつ実の親 Activity を起動する必要がある)場合、指定した親 Activity で起こりうるリクエストを含めて mock する必要があります。網羅的に mock する方法もありますが、本来テスト対象ではない画面で起こるリクエストの mock を考慮するのは煩雑ですし、思わぬ漏れが生じて flaky なテストとなるリスクがあります。

これの対策として、テストにおいてはいかなる実際の API 通信も認めないという前提の下、通信が生じた場合は intercept してリクエストを捨てる Rule を作成・使用しています。

Koin, apollo-android の例:

class SuppressRequestApolloClientRule : TestWatcher() {

    override fun starting(description: Description?) {
        loadKoinModules(
            module {
                single {
                    ApolloClient.builder()
                        .addApplicationInterceptor(object : ApolloInterceptor {
                            override fun interceptAsync(
                                request: ApolloInterceptor.InterceptorRequest,
                                chain: ApolloInterceptorChain,
                                dispatcher: Executor,
                                callBack: ApolloInterceptor.CallBack
                            ) = Unit

                            override fun dispose() = Unit
                        })
                        .serverUrl("https://example.com")
                        .build()
                }
            }
        )
    }
}

この Rule を使用することで、テスト専用の Interceptor を追加した ApolloClient が Repository に対して注入されるようになります。これを併用しつつ、テスト対象画面に対しては期待するスクリーンショットを撮影するために必要なデータを返却するような Repository を明示的に mock しています。

こちらは ApolloClient の例ですが、Retrofit などを使用している場合は OkHttpInterceptor を利用することで同様の挙動の実現が可能かと思います。

3. ネットワークから読み込んでいる画像をダミーのものに置き換える

画像をネットワークからロードして表示するような場合も、読み込み失敗によって期待する画像が表示されず意図しない差分となってしまうリスクがあります。これを避けるため 2. と同様な対応ができると良いのですが、現状未導入です。
弊プロジェクトでは画像表示ライブラリとして Coil を使用していますが、OkHttp と同様な interface を持った Interceptor が存在する ため、実現は可能と推察しています。

現状は、画像がネットワークからロード・表示されてしまうことは許容しつつ、その後ローカルにあるダミー画像に強制的に変更した上でスクリーンショットを取る、という形で対応しています。

ネットワークから読み込む = Android アプリ側はどのような画像が表示されるか関知しないことを意味するため、VRT で画像そのものの正しさを保証する必要はなく、差分とならないよう毎回同じ画像が表示さえされれば良いためです(一方でクロップやスケールといった Android アプリ側で制御している部分に関してはテスト対象です)。

現在は画像をロードする画面が少なく問題にはなっていませんが、画像差し替え部分のコードが煩雑になりがちなため、今後そのような画面が増えてきたタイミングで改善検討を行う予定です。

4. スクリーンショット名をテストケースから自動取得する

弊チームではスクリーンショットの撮影に ScreenShotter を使用していますが、出力されるスクリーンショットのファイル名を指定する必要があります。

reg-suit では、このファイル名を使用して差分比較すべき画像の組み合わせを特定するため、名前の重複が起こったり、スクリーンショットの名前を誤って変えてしまうといったことが起こるとうまく差分検知が働かなくなってしまいます。

テストケース名が自動的にスクリーンショット名として使われるような仕組みがあると、思わず変更してしまうリスクが減ったり、名前の重複をコンパイラに教えてもらえるなどのメリットがあり良さそうです。またテストケース名を「どの画面のどのような状態を撮影したものか」を表す名前にすることで、reg-suit 出力後の閲覧性も向上します。

当初はテストケース名を以下のように関数参照により取得していました。*3

// スクリーンショットを撮影するメソッド
fun <T : Activity> takeScreenShotByActivityScenario(
    scenario: ActivityScenario<T>,
    testCaseName: String
) {
    scenario.onActivity { activity ->
        val screenName = activity::class.java.simpleName
         // スクリーンショット名を指定
        ScreenShotter.takeScreenshot("${screenName}_$testCaseName", activity)
    }
}

@Test
fun loginScreenTest() {
    val scenario = ActivityScenario.launch(LoginActivity::class.java)
     
    takeScreenShotByActivityScenario(scenario, this::loginScreenTest.name)
}

この方法では文字列をハードコードしているのと変わらず、コピペなどで他ケースの名前を代入してしまうなどのリスクが生じます。理想的には何も指定せずともテストケースメソッド名がスクリーンショット名として自動で使用されるような仕組みがあるとミスを防ぐことができます。

結論として、現状は以下のような方法に落ち着いています。

fun <T : Activity> ActivityScenario<T>.takeScreenShot(testCase: Method) {
    this.onActivity { activity ->
        val screenName = activity::class.java.simpleName
        ScreenShotter.takeScreenshot("${screenName}_${testCase.name}", activity)
    }
}

@Test
fun loginScreenTest() {
    val activityScenario = ActivityScenario.launch(LoginActivity::class.java)

    activityScenario.takeScreenShot(checkNotNull(object {}.javaClass.enclosingMethod))
}

スクリーンショットを取るメソッドの引数を、Method 型を受け取る形に変更します。
メソッドの利用側で匿名オブジェクトをインラインで作り、enclosingMethod を使用することでこのオブジェクトを直接ラップしているメソッドの型を取得しています。
これにより、テストケース毎に名前をハードコードする事によるミスは防げるようになります。

「直接ラップしているメソッドしか取れない」というところが落とし穴でもあり、匿名オブジェクトを作る部分をリファクタリングなどの拍子で別メソッドに切り出してしまうとテストケースではなく切り出されたメソッド名が使われてしまうというリスクは残っています。また呼び出し元から取得する必要があるため完全な自動取得とは呼べない点も挙げられます。

呼び出し元からの指定が不要になる方法として、以下のようにテストスレッドのスタックトレースからメソッド名を索引してくるような方法も検討しましたが、

  • 一意に索引できることを保証するのが困難であること
  • 使用者側で内部に遮蔽された索引ロジックを意識しつつ一意性のためのルールを守りながら使うことが必要となり、enclosingMethod の方法に比べて長期的に正しく利用していけるか懸念がある

といった理由から enclosingMethod の方法で現状は十分だろうと判断し採用を見送っています。

// テストケースメソッドの prefix ルールを予め決めておく 例:
const val VRT_TEST_CASE_PREFIX = "testCaseNamePrefix_"

fun <T : Activity> takeScreenShotByActivityScenario(scenario: ActivityScenario<T>) {
    val matchedTestCaseNames = Thread.currentThread()
        .stackTrace
        .map { it.methodName }
        .filter { it.startsWith(VRT_TEST_CASE_PREFIX) }

    if (matchedTestCaseNames.isEmpty()) {
        throw NoSuchElementException("No test cases found start with $VRT_TEST_CASE_PREFIX")
    }
    if (matchedTestCaseNames.size > 1) {
        throw IllegalStateException("Multiple test cases matched for prefix $VRT_TEST_CASE_PREFIX: ${matchedTestCaseNames.joinToString()}")
    }

    val testCaseName = matchedTestCaseNames.single()

    scenario.onActivity { activity ->
        val screenName = activity::class.java.simpleName
        ScreenShotter.takeScreenshot("${screenName}_${testCaseName}", activity)
    }
}

// ルールとして決めた prefix を使ったテストケース名を定義
@Test
fun testCaseNamePrefix_loginScreenTest() {
    val scenario = ActivityScenario.launch(LoginActivity::class.java)

    takeScreenShotByActivityScenario(scenario)
}

おわりに

unit test では検出しきれない UI の変更ミスを検出してくれる Visual Regression Test は大変便利ですが、思わぬところで躓くことがあります。今回はそうした際に導入してきた tips を紹介しました。これらの tips をはじめとする対応により現状 VRT を正しく動作させることができています。

次回は、UI が期待する状態になるまでテストケースを待機させてからスクリーンショットを撮るといった、Espresso を中心とした tips を紹介する予定です。


こちらの記事を読まれて Quipper に興味をお持ちいただいた皆様、ぜひ Quipper に遊びに来ませんか?
以下 Wantedly ページよりお気軽にご連絡ください!
www.wantedly.com

また、2021-08-26 に Android エンジニアを対象とした「スタディサプリ/Quipper オンラインミートアップ #4」を開催します。
Quipper やスタディサプリにご興味がある方はぜひお気軽にご応募いただけると嬉しいです! quipper.connpass.com

*1:日本語版 document 参照

*2:実際は、デフォルトでは Dialog のスクリーンショットが撮影できないため Dialog 専用のメソッドを用意する、View の elevation がうまく撮影できないため PixelCopy を使用した処理に書き換える、といったカスタムを行った上で利用しています。

*3:ここでは簡単のために Activity のクラス名を screen 名として使用していますが、Fragment の場合は別途指定する必要があります。