DeNA TechCon 2021での発表資料です https://techcon.dena.com/2021/session/16/
品質管理部SWETグループ 田熊 希羽1
View Slide
● Androidのプロダクトにスクリーンショットを活用したテストを導入する中で、直面した課題とそれらをどのように解決したか● 導入でつまずかないために検討するべきことと、気をつけるべきこと2
1. スクリーンショットテストについて2. 3つのAndroidプロダクトへの導入3. 導入する中で直面した課題4. 導入でつまずかないために3
● 田熊 希羽(タクマ ノゾミ)● 品質管理部 SWETグループ所属○ Pocochaシステム部兼務○ 株式会社Mobility Technologies兼務出向● Androidとテストが好き4
2. 3つのAndroidプロダクトへの導入3. 導入する中で直面した課題4. 導入でつまずかないために5
● 手元で画面の表示確認をする● Visual Regression Test(画像回帰テスト)● カタログ化してデザインのレビューに利用6
● 手元で画面の表示確認をする● Visual Regression Test(画像回帰テスト)● カタログ化してデザインのレビューに利用共通の目的はUIに関する不具合を早期発見すること7
● 手元で画面の表示確認をする● Visual Regression Test(画像回帰テスト)● カタログ化してデザインのレビューに利用この発表ではこれらをひっくるめてスクリーンショットテストと表現します8
● 画面実装時のデバッグ・動作確認の手段としてスクリーンショットを使用する● 画面によっては、アプリを操作して遷移したり、条件によって変わる表示を再現するのが大変○ テストで任意の画面や任意の条件を再現したスクリーンショットを取得できれば、動作確認の時間を削減できる9
● コードの変更前と変更後のスクリーンショット画像を比較して差分を検知する● UIに意図しない変更が含まれていないかを確認できる● ピクセル単位で比較することで、人の目で見て気が付きづらいような差分も検知可能10
reg-suitを使った差分レポート差分がある箇所が赤くなる今回は文言を修正したことによる差分が検出されている11
● スクリーンショットを一覧化して確認できるようにする● 開発者だけでなく、PdM・デザイナー・QAメンバーのレビューに利用できる● アプリを起動せずにUIを確認できる12
クレジットカードが無効クレジットカードが期限切れクレジットカードが有効13
● folio-sec/Fastfilescreenshots-preview-generator.rb を参考にしつつカスタマイズ○ https://github.com/folio-sec/Fastfile/blob/master/Scripts/screenshots-preview-generator.rb14
1. スクリーンショットテストについて3. 導入する中で直面した課題4. 導入でつまずかないために15
● API通信といった外部依存はテスト用データを返せるように(スタブ化)して、任意の状態再現が簡単にできるようにする● 1テストケースのスコープを広げすぎない○ 例: 任意の状態で画面起動 + スクリーンショットを撮ってテスト終了16
● API通信といった外部依存はテスト用データを返せるように(スタブ化)して、任意の状態再現が簡単にできるようにする● 1テストケースのスコープを広げすぎない○ 例: 任意の状態で画面起動 + スクリーンショットを撮ってテスト終了● テスト安定化のため● テスト実装のハードルを下げるため17
● 実アプリへの忠実度● 実行時間● 保守コスト● デバッグコストテストピラミッド自動テストのバランスについての指針18
● 実アプリへの忠実度● 実行時間● 保守コスト● デバッグコストこの部分に該当19
● Fundamentals of Testing○ https://developer.android.com/training/testing/fundamentals20
21
22モックライブラリなどで実装を差し替える
@Testfun capture() {// 画面起動前にテストデータのセットアップを行う// テストしたい画面の起動val intent = Intent(context, RankingActivity::class.java)val scenario = launchActivity(intent )scenario.onActivity activity// スクリーンショットを取得・保存}}23
@Testfun capture() {// 画面起動前にテストデータのセットアップを行う// テストしたい画面の起動val intent = Intent(context, RankingActivity::class.java)val scenario = launchActivity(intent )scenario.onActivity activity// スクリーンショットを取得・保存}}ActivityScenarioといったテスト用の画面起動APIが用意されている24
@Testfun capture() {// 画面起動前にテストデータのセットアップを行う// テストしたい画面の起動val intent = Intent(context, RankingActivity::class.java)val scenario = launchActivity(intent )scenario.onActivity activity// スクリーンショットを取得・保存}}実機やEmulatorを使ったInstrumentation Testとして実行する25
● GO○ タクシー配車サービス● 乗務員アプリ○ GOのタクシー乗務員が利用するアプリ● Pococha○ ライブコミュニケーションサービス26
● GO○ タクシー配車サービス● 乗務員アプリ○ GOのタクシー乗務員が利用するアプリ● Pococha○ ライブコミュニケーションサービス実際に運用するのが難しくなり断念27
● GO○ タクシー配車サービス● 乗務員アプリ○ GOのタクシー乗務員が利用するアプリ● Pococha○ ライブコミュニケーションサービスうまくいかなかったプロダクトもあわせて、直面した様々な課題を紹介していきます28
1. スクリーンショットテストについて2. 3つのAndroidプロダクトへの導入4. 導入でつまずかないために29
テストしたい画面を任意の状態で起動できるようにする意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる導入までのハードルを大きく4つに分割30
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させるテストしたい画面を任意の状態で起動できるようにする31
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる● 依存の差し替えをどうするか● テストの結合範囲をどうするか● モックライブラリMockkの罠● Applicationクラスの障壁● 画面起動のパターンに対応するテストしたい画面を任意の状態で起動できるようにする32
● DIライブラリを導入しているプロダクト○ ライブラリの仕組みを利用して差し替える● DIライブラリを導入していないプロダクト○ 依存先を解決する仕組みを自分で用意○ デフォルト引数を駆使しつつ、コンストラクタでインスタンスを差し替え33
● DIライブラリを導入しているプロダクト○ ライブラリの仕組みを利用して差し替える● DIライブラリを導入していないプロダクト○ 依存先を解決する仕組みを自分で用意○ デフォルト引数を駆使しつつ、コンストラクタでインスタンスを差し替えライブラリによって都度調査して対応今回導入したプロダクトではDagger2・koin34
● DIライブラリを導入しているプロダクト○ ライブラリの仕組みを利用して差し替える● DIライブラリを導入していないプロダクト○ 依存先を解決する仕組みを自分で用意○ デフォルト引数を駆使しつつ、コンストラクタでインスタンスを差し替えプロダクトの1つはこれで対応35
● FragmentFactoryとInterceptingActivityFactoryを使えば、テスト時に起動するFragmentとActivityのインスタンスを差し替えることができる● 詳細(DIライブラリ未導入の方は是非)○ Android UIテストでActivityとFragmentにコンストラクタインジェクションする (Qiita)36
37
38
結合範囲39
GOPococha40
変更監視乗務員アプリのある画面の例(一部省略)変更監視41
変更監視全体を結合しないとUIの変更が流れない変更監視42
● アプリのアーキテクチャによって結合範囲が変わってくる● 結合範囲を広げると、UIが変化する条件を把握するのが難しくなる● ユニットテストで担保する範囲を明確にし、スクリーンショットテストのスコープを調整する43
● すでにユニットテストで使用していたmockkのInstrumentation Testサポートを使用○ mockk.io/ANDROID● Android P以上ではinline mock機能により、finalクラスやobjectのスタブも可能○44
● バイトコードに処理を差し込むことで機能を実現○ Mock final and static methods on Android devices● Androidの実機で動作させるとランダムにクラッシュする上、クラッシュのログがでない● Andorid11ではinline mock機能を使わない場合でも、inline mockのセットアップでクラッシュ45
● inline mockを利用しない○ サブクラス化してモック生成する機能を利用○ 依存がIF化されていない場合、DexOpenerやAll-open compiler pluginを導入する必要有● Android11でクラッシュする問題は1.10.6で修正される予定(1.10.0を利用すれば一時的に回避化)46
前提として、Instrumentation Testでは通常のアプリと同様にApplicationクラスが起動される47
● テストで困るApplicationクラスの実装例○ 起動などをトリガーにAPI通信を行い、401エラーだったらログイン画面に遷移させる○ テストしたい画面の起動をブロックされるテストではテスト用Applicationクラスを使うことで実行されないようにすることは可能だが...48
● プロダクトコード内で固有のApplcationを直接参照していると切り離すのが難しい○ テスト用Applicationクラスに継承してもらうことで回避は可能○ その上で不要な処理を実行しないように改修■ Applicationクラスが巨大だと骨が折れる49
● スクリーンショットを取得したい範囲と起動方法によって変わる○ Fragment単体 or 親Activity(Fragment)を含むか● 起動方法のパターン例○ PagerAdapterを利用している○ Navigation componentを利用している50
FragmentActivityPager51
● 親画面も含んだスクリーンショットを撮る場合は、親画面から起動する○ 子画面が参照している外部依存もあわせて差し替えできるようにする○ 特にコンストラクタで差し替えをしている場合は、親画面から渡せるようにする必要あり52
● ActivityScenario・FragamentScenarioを利用すると起動しているActivity・Fragmentのインスタンスにアクセスできる● そこからNavigationControllerを取得し、navigationを実行することで画面操作をせずに遷移することが可能53
val activityScenario = launchActivity(intent)activityScenario.onActivity { act : MyActivity ->val navController = Navigation.findNavController(act, R.id.nav)navController.navigate(..)}起動したActivityのインスタンスからpublicアクセスできるものはテストコードからもアクセス可能54
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる● 依存の差し替えをどうするか● テストの結合範囲をどうするか● モックライブラリMockkの罠● Applicationクラスの障壁● 画面起動のパターンに対応するテストしたい画面を任意の状態で起動できるようにする55
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するテストしたい画面を任意の状態で起動できるようにするスクリーンショットをとってみたら、実アプリと違う表示になってしまうことがある56
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● 非同期処理の待ち合わせ● スクリーンショットAPIの罠● FragmentScenarioの罠● スクロールする画面に対応するテストしたい画面を任意の状態で起動できるようにする57
非同期処理は?テストで結合する範囲にバックグラウンドスレッドの起動がある?Dispatcherを差し替えできるようにする準備なしでOK58
非同期処理は?テストで結合する範囲にバックグラウンドスレッドの起動がある?準備なしでOKRepositoryでスレッド起動かつ、Repositoryがスタブ化されているケース59
● Dispatcherの差し替え実装例○ DroidKaigi/conference-app-2019■ CoroutinePlugin.kt● DataBinding利用時○ android/architecture-samples■ DataBindingIdlingResource.kt60
● FragmentScencarioで内部的使われるActivityは、AppCompatActivityを● FragmentScenaioで起動した画面は、AppCompatでのみ認識されるView属性が正常に表示されない61
いない...app:srcCompatで指定したVectorDrawable62
● FragmentScenarioの使用を避ける○ 親のActivityから起動する○ 空のAppCompatActivityにattachしてFragmentを起動できる仕組みを用意する■ 内部的にActivityScenarioを使った代替のFragmentScenarioなど63
● Androidのテストで利用できるスクリーンショットのAPIは複数ある● APIによって、実際のアプリと見た目が異なるスクリーンショットがとれる場合がある● 詳細○ Androidのテストで利用できるスクリーンショット取得APIのまとめ (Qiita)64
○ ✕ リフレクションを使用すれば可✕○ ○ リフレクションを使用すれば可SurfaceView単体のみ可✕(画面全体のみ)○ ○ ○65
● スクロールをしながらスクリーンショットを取得○ スクリーンショット → スクロール → スクリーンショット...● 全体をスクリーンショットできるように画面をリサイズしたあとスクリーンショットを撮る66
自動でスクロール +スクリーンショット取得を行うEspressoのActionを用意67
コンテンツのサイズにあわせてViewをリサイズするオプションを追加ただしUiDevice#takeSceenshotは端末で見える領域しかキャプチャしないため併用できない端末上でみえる範囲68
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● 非同期処理の待ち合わせ● スクリーンショットAPIの罠● FragmentScenarioの罠● スクロールする画面に対応するテストしたい画面を任意の状態で起動できるようにする69
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するテストしたい画面を任意の状態で起動できるようにするVisual Regression Testで差分の誤検知をしないように70
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● ステータスバーを固定化する● 地図の表示を固定化する● 時刻の表示を固定化するテストしたい画面を任意の状態で起動できるようにする71
● 時刻や電池残量など表示が動的に変わる● 全画面のスクリーンショットを撮る際には含まれてしまう72
● 開発者オプションのシステムUIデモモード○ Demo Mode for the Android System UI● テストコードから有効化するには(値は↑を参照)○ UiAutomation#executeShellCommandでadbを実行し、デモモードを有効化○ 必要なコマンドをBroadcastで発行73
時刻を10:00・電池残量を100に固定74
● 読み込み状態によって差分がでる● 中身が常に更新されうる75
● Google Mapsの場合は、道路や地名の表示等をオプションで非表示にできる○ https://mapstyle.withgoogle.com/● 一番安定するのは何も表示しない● カスタムの設定を作成し、テストコードから画面を起動した際にデフォルトの設定を上書きする76
地図の中身はすべて非表示カスタムで実装しているマーカーのみになった77
● AndroidのUI widgetであるTextClockは、内部的にCalendarのインスタンスを保持しており、外から時刻を差し替えるのが難しい● 時刻フォーマットを(HH:mm等)指定するメソッドがあるので、そこに時刻のフォーマットとして解釈できない文字列を入れることで固定化可能78
activityScenario.onActivity { act ->act.findViewById(R.id.clock).format24Hour = "10:00"}findViewByIdで内部のViewにアクセスして状態を変更する直接INVISIBLEにするといった力技も可能79
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● ステータスバーを固定化する● 地図の表示を固定化する● 時刻の表示を固定化するテストしたい画面を任意の状態で起動できるようにする80
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させるテストしたい画面を任意の状態で起動できるようにする81
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる● スクリーンショットテストが流行らないテストしたい画面を任意の状態で起動できるようにする82
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させるテストしたい画面を任意の状態で起動できるようにする● スクリーンショットテストが流行らない現在力を入れて取組中83
● 自動テストにありがちな、テストを書く時間がなくて後回しになるという問題はスクリーンショットテストでも共通● 効果が高いのは実装と一緒にテストを書くこと○ 少しでもテスト実装のハードルを下げられるような取り組みを実施中84
● 画面起動やUI操作のヘルパーを実装してボイラープレートを削減● ペアプロやモブプロでスクリーンショットテストの実装をサポート● スクリーンショット画像を端末から取得するGradleタスクを用意85etc...
1. スクリーンショットテストについて2. 3つのAndroidプロダクトへの導入3. 導入する中で直面した課題86
テストしたい画面を任意の状態で起動できるようにする意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる4つのポイントでつまづきそうな箇所がないかを確認87
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる● 依存の差し替えができるか● アーキテクチャにあわせて結合範囲を検討する● モックライブラリ利用の可否● Applicationクラスにテストの邪魔になりそうな処理はないか● アプリの画面構成とどのような起動経路があるかを確認するテストしたい画面を任意の状態で起動できるようにする88
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● 利用している非同期処理の機構にあわせて待ち合わせの仕組みを実装する● 画面の特性にあわせて、スクリーンショットAPIを選択する● FragmentScenarioの代替手段を用意する● スクロールする画面の対応方針を決めて仕組みを実装するテストしたい画面を任意の状態で起動できるようにする89
スクリーンショットテストを定着させる意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得する● 画面の中で動的に変わる箇所と固定化する方法を突き止めるテストしたい画面を任意の状態で起動できるようにする90
意図した通りのスクリーンショットを取得する安定したスクリーンショットを取得するスクリーンショットテストを定着させる● スクリーンショットテストのメリットについてチームで合意● 実装のハードルは都度改善テストしたい画面を任意の状態で起動できるようにする91
● UIテスタビリティを意識しつつ開発する○ 画面実装時にも、テストで邪魔な処理が差し替えできるようになっているかを意識● スクリーンショットテストでカバーしすぎようとしない○ ユニットテストで見るべきでは?を考える92
1. スクリーンショットテストについて2. 3つのAndroidプロダクトへの導入3. 導入する中で直面した課題4. 導入でつまずかないために93
● スクリーンショットはUIに関する不具合の早期発見に活用できる● Androidプロダクトに導入するには4つのポイントでハードルがある● プロダクトの特性にあわせて、上記のポイントでハマりどころがないか確認する94
95