MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)

こんにちは、株式会社フィノバレーでAndroidエンジニア兼UXリサーチャーをやっております、島本(id: yoi_ko)です。

MoneyEasyのAndroidアプリ(ver1.13.0時点)におけるテスト環境については前回記事でご紹介しましたが、この後デザインの一部リニューアルを行い、ver2系としてリリースしました🎉

これに伴い、MoneyEasyのAndroidアプリでは、ver1系で実装したUI TestからVisual Regression Testへの移行を行いました。
現状、AndroidアプリにおけるVisual Regression Test=VRTの導入事例はあまり多くは見かけません。ですが、CI/CDと組み合わせることでテストのリソースを大幅に削減しつつ品質を担保することができるため、特に小規模な開発組織においては大変有効なテスト方法であると思います。 

そんなVRTについて、2回に分けて紹介します。今回の記事は、前編:実装編となります。

  • 前編:実装編 VRT導入の経緯、想定ゴール、実装について
  • 後編:CI/CD編 GitLab CIへの取り込み方法について

VRT導入の経緯

手動テストの効率を上げたい(切実)

MoneyEasyはQRコード決済を提供するプラットフォームです。ユーザーのお金を扱うアプリですので、当然バグやデグレは許されず、リリース時には必ず綿密にテストを行う必要があります。この過程で、手動テストは避けて通れません。

一方弊社の方針は「少数先鋭」、つまりそれぞれが高いパフォーマンスを出すことを前提に、人員を絞って業務にあたっています。開発ステップにおいては特に問題はありませんが、ことテストに関してはチームで分担して実施するため、開発メンバーのリソースを割く必要があります。

現在運用しているのはさるぼぼコイン、アクアコインの2環境なので、上記の方法で間に合っていました。ところが、既にプレスリリースが出ている通り、ありがたいことにMoneyEasyは今後さらに拡大していくことが決定しています(長崎県南島原MINAコイン、東京都世田谷区のせたがやPay、子どもの「食」応援クーポン事業「Table for Kids」)。品質を担保しつつ迅速なリリースを実現するには、テスト効率を改善することが必須です。

そうだ、VRTやろう

もともとver1系でUI Testを実装していましたが、デザインリニューアルによりテストコードの大幅な改修が必要でした。また、UI Testはメンテコストがバカにならない(その割に効果が出ているのか疑問)という課題もあり、ver2系では既存のUI Testコードをすべて捨てることにしました。

代わりに検討したのが、スクリーンショット(SS)を使ったテストです。2020年1月に「Payアプリを支える技術」をテーマに開催された Bonfire Android #6 - connpass に参加した折にスクリーンショットテストが紹介されており、そのときから「これはうちに入れると良さそうだぞ」と興味を持っていました。テスト効率を改善するにあたっては、「画面表示がデグレっていないかチェックする」という作業を、手動から自動にしてやるのがもっとも効果的であると考えていました。その目的に対し、非常に有効である印象を受けたのです。

チーム内で検討し、全画面のSSを取得&期待とのdiffの確認までを自動で行うVisual Regression Testという形で導入してみようとの結論を出しました。2020年9月なかばあたりのことです。この時点で、これから導入先が増えることはほぼ見えていたので、それまで(2020年12月頃)に構築し終えることを目標としました。

また、この検討にあたっては以下の記事を大変参考にさせていただきました。この場を借りてお礼申し上げます🙇‍♀️

想定ゴール

f:id:yoi_ko:20201117161720p:plain
MoneyEasyのAndroidアプリは、ver1.13.0時点で既にCI/CD環境がありました。ver2系でもこれを引き継ぎ、構成自体には手を入れずにUI Test→VRTの置き換えのみを行うこととしました。 なぜこの構成になっているかについては、以下の記事をご覧ください。 finnovalley.hatenablog.com

このゴールを設定した際、SSを取得する実装はすんなり出来るだろうけれど、diffを検出するところを自動化するところまでいけるかは未知数でした。仮にdiff検出の自動化が上手く行かなくても、全画面のSSを取得するところまで出来ているだけでも効率化には繋がるため、全画面分のSSの取得処理実装作業を最優先して進める方針としました。

Firebase ScreenShotterを使ったSS取得処理実装

SSの取得にあたっては、FirebaseのScreenShotterライブラリを使うこととしました。ver1系のUI Testで使ったFirebase TestLabとの連携がしやすいことが決め手となっています。

環境構築

以下の公式ドキュメント通りにやれば大丈夫です。aarファイルを直接参照する形式なのがちょっと「うーん…😞」となるポイントではありますが、まあそこは目を瞑りましょう。いまのところ特に問題はないです。

firebase.google.com

SS取得処理の実装

テストでSSを取得する手順は至ってシンプルです。

  1. 該当画面(Activity/Fragment)を立ち上げる
  2. SS取得処理を実行する

実装自体もそう難しいものではありません。実際のコードはこんな感じです。
このコードでは、スプラッシュ画面を立ち上げてSSを取得しています。

@RunWith(AndroidJUnit4::class)
@LargeTest
class SplashTest {

    // ①
    @get:Rule
    var scenarioRule = ActivityScenarioRule(SplashActivity::class.java)

    // ②
    @get:Rule
    var grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
        Manifest.permission.WRITE_EXTERNAL_STORAGE
    )

    @Test
    fun test() {
        // ③
        scenarioRule.scenario.onActivity { activity ->
            ScreenShotter.takeScreenshot("splash", activity)
        }
    }
}

① Activityの起動には ActivityScenarioRule が便利です。
Activity起動時にIntentで値を渡したい場合は以下のようにします。

    @get:Rule
    val scenarioRule = ActivityScenarioRule<HogeActivity>(
        Intent(InstrumentationRegistry.getInstrumentation().targetContext, HogeActivity::class.java).apply {
            putExtra("fuga", "piyo")
        })

②API28以下で実行する場合は WRITE_EXTERNAL_STORAGE が必要です。(公式ドキュメント参照)
GrantPermissionRule を使って予め権限を許可しておきましょう。

③Activityを渡してやる必要があるので、 ActivityScenario.onActivity 内でScreenShotter.takeScreenshot(filename: String, activity: Activity)を実行します。

撮ったSSを確認する

SS取得処理を実装したら、今度は実際に撮れるか&撮ったSSを確認します。

ローカル

エミュレーターや実機を繋ぎ、以下のコマンドを実行します。

$ ./gradrew connectedAndroidTest

取得したSSは、端末の sdcard/screenshots 配下に格納されています。AndroidStudioのDevice File Explorerなどから見てみましょう。

Firebase TestLab

  1. Firebase TestLabでの実行には、デバッグ用のアプリapkとテストapkが必要となります。以下のコマンドを実行してapkをビルドしておきます。
$ ./gradrew assembleDebug
$ ./gradrew assembleAndroidTest
  1. FirebaseのコンソールからTestLab -> インストゥルメンテーション テストの実行を選択します。↓こんな画面が出るので、1. で生成したapkをそれぞれ指定します。 f:id:yoi_ko:20201118105448p:plain

  2. 実行する端末を指定します。仮想端末のほうが料金は抑えられますが、テストでUiAutomatorを使っていると謎エラーが出たりするので、物理端末をオススメします。 [カスタマイズ]でタイムアウト(デフォルト10分)を設定できるので、テスト数が多い場合は適宜変更しておきましょう。 f:id:yoi_ko:20201118105918p:plain

  3. テストの実行が終わると、こんな感じで結果が表示されます。スクリーンショットタブを選択すると、ズラズラッとSSが表示されます。また、右上の「テスト結果」を押すと、テスト結果の格納されているGCPバケットが表示されます。SSは artifact の下にあります。 f:id:yoi_ko:20201118110321p:plain

注意点

FragmentのSS取得について

developer.android.com

Android Developersでは、FragmentScenario を使った方法が紹介されています。この通りに実装すると、Fragmentを単体で立ち上げることができます。 このとき、Fragmentは空のActivityにアタッチされます。つまり、Fragmentが乗っているActivity側でアプリバーの表示処理を行っている場合は、アプリバーなしの状態の画面が立ち上がることになります。MoneyEasyはまさにこのケースですが、アプリバー込みのSSを取得するほうがVRTの目的に沿うため、FragmentScenarioを使ったこの方法は採りませんでした。

FragmentScenarioを使わないとなると、Activityを立ち上げた上で目的のFragmentに遷移させる必要があります。MoneyEasyでは一部のFragmentの遷移にAACのNavigationを使っており、その箇所では NavController を使って遷移させています。 例)(起動時)入力画面→確認画面と遷移する場合

@Before
fun navigateToConfirm() {
    scenarioRule.scenario.onActivity { activity ->
        activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_input_to_confirm)
    }
}

ダイアログのSSは取得できない

ScreenShotterライブラリでは、ダイアログを表示した状態でのSSは取得できません。この制約があることは知っていましたが、元々ダイアログは対象範囲外としていたので特に問題はありませんでした。

ところが想定外だったのが、 BottomSheetDialogFragment も同じくSS取得不可なことでした(考えてみれば、それはそうなのですが…😇)。ver2系ではホーム画面の各種メニュー表示にBottomSheetDialogFragmentを使っているので、できればここもカバーしたかったのですが、どうにかする方法がイマイチ思いつかなかった&ここの表示は手動のシナリオテスト時に必ず確認する範囲ではあるので、VRT対象外としています。 ※知見がある方いたらご教授ください🙇‍♀️

f:id:yoi_ko:20201117185948p:plain
BottomSheetDialogFragment使用箇所

SSのファイル名の付き方にちょっとクセがある

ScreenShotterライブラリのtakeScreenshot() メソッドを実行すると、例えば以下のようなファイル名でSSが生成されます。 f:id:yoi_ko:20201118111143p:plain
また、テストクラスが取得できない場合、ファイル名は UnknownTestClass-unknownTestMethod-${ScreenShotter.takeScreenshot()で指名した名前}.jpg になります。いずれの場合も、ファイル名が長くなってしまうのです。(takeScreenshot()で渡している fileName にしてくれればいいのですが…)
そこで、MoneyEasyではScreenShotter.takeScreenshot()を実行するメソッドをひとつ定義し、これをすべてのテストで叩くようにしました。

CloudTest.kt

/**
 * このメソッドでSSを撮ると、ファイル名は `UnknownTestClass-unknownTestMethod-${fileName}.jpg` になる。
 * 参考: ScreenShotter.getScreenshotFileName()の処理
 */
fun takeScreenshot(fileName: String, activity: Activity) {
    ScreenShotter.takeScreenshot(fileName, activity)
}

すべてのファイル名のプレフィックスを統一しておくと嬉しいのは、取得したSSのリネーム処理のスクリプトが簡単に書ける点です。これはのちにCIに組み込む際に活きてきます。

なんとかできた

といった感じで、SS取得処理自体の実装をガンガン進めていきました。画面数が多い&ユーザーの状態によって表示の変わる箇所もカバーする方針としたため、対象は合計102画面となりました。さすがに量が多かったので、実装完了まで1ヶ月くらいはかかっています。

ただ、この時点でほぼすべての画面のSS取得が可能になったことで、それだけでも割と気が楽になりました。CI/CDに組み込む対応がもしできなくても、これまでのように手動で1画面ずつ確認するよりはだいぶテストの負担は減らせそうです。

とはいえ、やはりせっかくだからすべて自動でやってしまいたい……🤔ということで、後編:CI/CD編へと続きます。 ※後日アップします