MobilityTechnologies / gogo-screenshot-android

A screenshot test library for Android
Apache License 2.0
16 stars 1 forks source link
public

GOGO Screenshot Test for Android

概要

GOGO Screenshot Test for Android (以降GOGO Screenshot Testと表記します)は、 Androidアプリに対するスクリーンショットテストを書くときに良く使う機能をひとまとめにしたライブラリです。

GOGO Screenshot Testの特徴は以下の通りです。

セットアップ

JitPackを使っています。

  1. トップレベルのbuild.gradleにJitPackリポジトリを登録します

    allprojects {
      repositories {
        ...
        maven { url 'https://jitpack.io' }
      }
    }
  2. app/build.gradleに依存関係を追加します。
    ※前述のAppCompatFragmentScenarioにて利用するActivityのデフォルト実装FragmentTestingActivityを内部に含んでいます。そのため、androidTestImplementationではなくdebugImplementationとして追加してください

    dependencies {
        debugImplementation 'com.github.MobilityTechnologies:gogo-screenshot-android:0.0.1'
        ...
    }
  3. AndroidのInstrumented TestでJUnit5を利用可能とするためにandroid-junit5 Gradle Pluginをセットアップします。以下のリンク先にある手順を全て実施してください

  4. (任意) スクリーンショットを保存したい場合、AndroidJUnitRunnerのサブクラスを定義します。 定義したクラスはapp/src/androidTest/配下に保存してください。

    class MyAndroidJUnitRunner : AndroidJUnitRunner() {
      override fun onCreate(arguments: Bundle?) {
          // ★1
          val newArguments = UiTestRunListener.appendListenerArgument(arguments)
          super.onCreate(newArguments)
      }
    
      override fun onStart() {
          // ★2
          SnapShotOptions.currentSettings = SnapShotOptions.DEFAULT_SETTINGS.copy(
                  buildFlavorPathComponent = BuildConfig.FLAVOR
          )
          super.onStart()
      }
    
      override fun finish(resultCode: Int, results: Bundle?) {
          // ★3
          SnapShot.zipAll()
          super.finish(resultCode, results)
      }
    }
    • ★1 (任意) onCreate()メソッド内に処理を書きます。ステータスバーの内容を固定化したい場合に、UiTestRunListener.appendListenerArgument(arguments)の戻り値をsuper.onCreate()の引数に渡してください
    • ★2: (任意) onStart()メソッド内に処理を書きます。SnapShotOptionsを使って起動オプションを変更したい場合に、super.onStart()の直前に書いてください。 指定できる内容は後述します。
    • ★3: (任意) finish()メソッド内に処理を書きます。保存したスクリーンショット画像をzipファイルにまとめたい場合に、super.finish()の直前に書いてください
    • ★1では、システムUIデモモードをテストの間だけ有効化します。システムUIデモモードを有効化すると、ステータスバーが含まれるスクリーンショットを取得する場合でも、差分の少なくすることができます。 ★1を設定する場合は、あわせてテスト時のビルドで使用されるAndroidManifest.xml(例: debug/AndroidManifest.xml)に、android.permission.DUMPのパーミッションを設定してください。
      <manifest>
       ..
          <uses-permission
              android:name="android.permission.DUMP"
              tools:ignore="ProtectedPermissions" />
      </manifest>
    • 上記すべての設定が不要な場合は、サブクラスの定義をスキップしても問題ありません。
  5. (任意) 4でAndroidJUnitRunnerのサブクラスを定義した場合、サブクラス名をbuild.gradleのandroid.testInstrumentationRunnerに指定します。

    android {
      ...
      testInstrumentationRunner "com.example.MyAndroidJUnitRunner"
    }

テストを書く場所

AndroidのInstrumented Testとして書きます。app/src/androidTest/配下にテストコードを置いてください。

テストの起動オプション

AndroidJUnitRunnerの起動オプションを使ってスクリーンショットの撮り方や保存するファイル名をカスタマイズすることができます。 指定できるオプションは次の通りです。

オプション名 取り得る値 デフォルト値 意味
encodeScreenshotFileName true または false false スクリーンショットファイル名に非ASCII文字が含まれないようにBase64エンコードするかどうかを指定します。
screenshotType original または visual_regression original visual_regressionを指定した場合、同じ画面であれば、できるだけ画像差分が出ないようにスクリーンショットを撮ります。
現在の実装ではGoogle Mapで表示される地図の差分が出るのを抑えるため、地図部分を非表示にしてスクリーンショットを撮ります。

AndroidJUnitRunnerの起動オプションは、build.gradleandroid.testInstrumentationRunnerArgumentを使って指定できます。

android {
    ...
    testInstrumentationRunnerArgument "encodeScreenshotFileName", "false"
    testInstrumentationRunnerArgument "screenshotType", "visual_regression"
    ...
}

また、「セットアップ」で定義したAndroidJUnitRunnerのサブクラス内(★2の箇所)で、SnapShotOptionsを使って起動オプションを指定することもできます。 その場合に指定できるオプションは次の通りです。指定方法は前述の★1のコード例を参照してください。

プロパティ名 デフォルト値 意味
encodeFileName Boolean - AndroidJUnitRunnerの起動オプションencodeScreenshotFileNameと同じです
screenshotType ScreenshotType - AndroidJUnitRunnerの起動オプションscreenshotTypeと同じです
rootDirectory File /sdcard/Android/data/${applicationId}/files/Pictures スクリーンショットを保存するディレクトリを指定します
buildFlavorPathComponent String? null プロダクトフレーバーが定義されていて、スクリーンショット保存ディレクトリをフレーバーごとに分けたい場合は、フレーバー名を指定してください
fileNameCreator SnapShotNameCreator SnapShotName.toFileName() 独自にカスタマイズしたスクリーンショットのファイル名規則を指定します。
詳細はSnapShotNameCreatorインターフェイス説明を参照してください

スクリーンショットを撮るテストを書く

スクリーンショットを撮るテストを書くためのおおまかなステップは次の通りです。

以降で順を追って説明します。

スクリーンショット対象画面を起動する

起動したい画面の種類によって、SimpleActivityPageSimpleFragmentPageSimpleDialogFragmentPageのいずれかを使います。それぞれの用途は次の通りです。

SimpleActivityPage

MyActivityを起動する場合のコード例は次の通りです。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension { SimpleActivityPage(it, MyActivity::class) }

...

@Test
fun myTest() {
    val intent = (MyActivityを起動するためのIntent)
    // MyActivityを起動する
    uiTestExtension.page.launchActivitySimply(intent)
} 

MyActivityNavHostFragmentを持っており、そのNavHostFragmentが管理するFragmentを起動したい場合は、 SimpleActivityPageコンストラクタの第3引数にNavHostFragmentがセットされているview IDを指定してください。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension { SimpleActivityPage(it, MyActivity::class, R.id.my_nav_host) }

@Test
fun myTest() {
    val intent = (MyActivityを起動するためのIntent)

    // MyActivityを起動してからλ式で指定されたnavigateを実行し、
    // デスティネーション R.id.myFragment が表示されるまで待つ
    uiTestExtension.page.launchFragmentByNavController(R.id.myFragment, intent) { 
        // itはNavController
        it.navigate(...) // R.id.myFragmentに遷移するアクションを指定
    }
} 

SimpleFragmentPage

MyFragmentを起動する場合のコード例は次の通りです。 SimpleFragmentPageコンストラクタの第3引数には、ホストするActivityに適用したいテーマを指定してください。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension { SimpleFragmentPage(it, MyFragment::class, R.style.AppTheme) }

...

@Test
fun myTest() {
    // MyFragmentを起動する
    uiTestExtension.page.launchFragmentSimply()
}

MyFragmentのインスタンス化方法を指定したい場合(MyFragment.newInstance(...)などが提供されている場合)は、 launchFragmentSimply()の代わりにlaunchFragmentByCreatorを使って起動してください。

@Test
fun myTest() {
    // MyFragmentを起動する
    uiTestExtension.page.launchFragmentByCreator {
        MyFragment.newInstance(...)
    }
}

SimpleActivityPageと同様にナビゲーションにも対応しています。 MyFragmentNavHostFragmentを持っており、そのNavHostFragmentが管理するFragmentを起動したい場合は、 SampleFragmentPageコンストラクタの第4引数にNavHostFragmentがセットされているview IDを指定してください。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension {
    SimpleFragmentPage(it, MyFragment::class, R.style.AppTheme, R.id.my_nav_host)
}

...

@Test
fun myTest() {
    // MyFragmentを起動してからλ式で指定されたnavigateを実行し、
    // デスティネーション R.id.myFragment2 が表示されるまで待つ
    uiTestExtension.page.launchChildFragmentByNavController(R.id.myFragment2) {
        // itはNavCotroller
        it.navigate(...) // R.id.myFragment2に遷移するアクションを指定
    }
}

SimpleDialogFragmentPage

MyDialogFragmentを起動する場合のコード例は次の通りです。 SimpleDialogFragmentPageの第3引数には、通常はDialogHostingFragment::classを指定してください。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension {
    SimpleDialogFragmentPage(it, MyDialogFragment::class, DialogHostingFragment::class)
}

...

@Test
fun myTest() {
    // MyDialogFragmentを起動する
    uiTestExtension.page.launchDialogFragmentByCreator {
        MyDialogFragment.newInstance(....)
    }
}

DialogFragmentの中には、そのダイアログをホストするFragmentに特定のリスナインターフェイスの実装を要求するものがあります。 そのようなDialogFragmentを起動するには、前準備としてそのリスナインターフェイスを実装したDialogHostingFragmentのサブクラスを定義してください。

MyDialogFragmentが、ホスト側にMyListenerMyListener2インターフェイスの実装を要求している場合の例は次の通りです。

class MyDialogHostingFragment(
        myListener: MyListener,
        myListener2: MyListener2
    ) : DialogHostingFragment(), MyListener by myListener, MyListener2 by myListener2

ポイントは次の3つです。

その上で、次のようにしてください。 SimpleDialogFragmentPageコンストラクタの第3引数がMyDialogHostingFragment::classになっている点が最初の例との違いです。

@JvmField
@RegisterExtension
val uiTestExtension = UiTestExtension {
    SimpleDialogFragmentPage(it, MyDialogFragment::class, MyDialogHostingFragment::class)
}

...

@Test
fun myTest() {
    // MyDialogFragmentを起動する
    uiTestExtension.page.launchDialogFragmentByCreator {
        MyDialogFragment.newInstance(....)
    }
}

起動した画面に表示される内容を調整する

画面に表示される内容を調整する方法はアプリの設計により様々ですが、たとえば次のような方法が考えられます。

Repositoryのメソッドをスタブ化する

本ライブラリは、スタブ化について支援する仕組みは用意していません。 DIライブラリやモックライブラリを使って実現してください。

それらのライブラリを使うにあたって毎回同じ前処理が必要な場合は、前述のSimple{Activity,Fragment,DialogFragment}Pageのカスタマイズ版を定義することができます。

その場合はSimple{Activity,Fragment,DialogFragment}Pageを別名でコピーし、starting()メソッドやfinished()メソッドをオーバーライドしてください。 starting()はJUnit5のBeforeEachCallbackのタイミングで、finished()AfterEachCallbackのタイミングで、それぞれ実行されます。

以下にKoinを使った初期化の例を示します。 オーバーライドする場合は、必ずsuper.starting()super.finished()を呼び出してください。

class MyActivityPage<...>(...) : ActivityScenarioPage<...>(...) {

    override fun starting() {
        super.starting()
        startKoin {
            androidLogger(Level.ERROR)
            androidContext(ApplicationProvider.getApplicationContext())
            modules(...) // Repositoryのスタブ版に差し替える
        }
    }

    override fun finished() {
        stopKoin()
        super.finished()
    }
}

起動したActivityやFragmentにアクセスする

起動しているActivityやFragmentにアクセスできるonActivityのようなメソッドを提供していますので活用してください。 それぞれのメソッドの詳細はKDocコメントを参照してください。

スクリーンショットを撮る

スクリーンショットは次のように書くことで取得できます。

uiTestExtension.page.captureDisplay("画面の状態")

// 状態の組み合わせなど、補足の説明が必要な場合は第二引数に追加する(オプション)
uiTestExtension.page.captureDisplay("画面の状態", "補足の説明")

撮影したスクリーンショットはデフォルトで次のパスに保存されます。

補足の説明がない場合: /sdcard/Android/data/${applicationId}/files/Pictures/screenshots/画面のクラス名/画面の状態-スクリーンショット取得順を表す番号.PNG

補足の説明がある場合: /sdcard/Android/data/${applicationId}/files/Pictures/screenshots/画面のクラス名/画面の状態-スクリーンショット取得順を表す番号-補足の説明.PNG

パスを構成する要素の詳細は次のとおりです。

ディレクトリを構成する要素

要素 デフォルト値
ルートディレクトリ /sdcard/Android/data/${applicationId}/files/Pictures スクリーンショット保存先のルートディレクトリ
SnapShotOptions#rootDirectoryで変更可
画像保存先ディレクトリ screenshots SnapShotOptions#buildFlavorPathComponentでscreenshots/BuildFlavorに変更可
画面のクラス名 Pageで指定された画面のクラス名 UiTestExtension#page.snapShotPageNameで変更可

ファイル名を構成する要素

要素 用途
画面の状態 スクリーンショットを取得した時点での画面の状態や条件を表します
スクリーンショット取得順を表す番号 同一の状態・条件に対して複数枚スクリーンショットを取得するときにスクリーンショット取得順がわかるように自動で連番されます
補足の説明 画面の状態だけでは表現が難しい場合に説明を追加します

スクリーンショット取得範囲の指定

スクリーンショットの取得範囲を指定することができます。

// 画面全体のスクリーンショットを取得する
// ダイアログやSurfaceViewが含まれる場合はこのメソッドを使用する
uiTestExtension.page.captureDisplay("画面の状態", "補足の説明(オプション)")

// Pageに指定したActivityもしくはFragmentのスクリーンショットを取得します
// ActivityScenarioPageを利用している場合はActivity・FragmentScenarioPageを利用している場合はFragmentのスクリーンショットを取得します
uiTestExtension.page.captureActivityOrFragment("画面の状態", "補足の説明(オプション)")

// 特定のViewのスクリーンショットを取得します
// ActivityScenarioPageを利用している場合はActivity・FragmentScenarioPageを利用している場合はFragmentのインスタンスがラムダ式の引数に渡されるので、スクリーンショットを取得したいViewのインスタンスを返します
uiTestExtension.page.captureViewFromActivityOrFragment("画面の状態", "補足の説明(オプション)") { activityOrFragment -> 
    // スクリーンショットを取得したいViewのインスタンスを返す
}

連続するスクリーンショットの取得

同一の状態・条件に対して複数枚のスクリーンショットを取得する場合、次のように書くことができます。

uiTestExtension.page.captureSequentially("画面の状態・条件") {

            // なにかしらのUIの変更

            captureDisplay("補足の説明-1") // captureActivityOrFragmentも利用可

            // なにかしらのUIの変更

            captureDisplay("補足の説明-2")

            // なにかしらのUIの変更

            captureDisplay() // 補足の説明は省略可能
}

このとき、画面の状態・条件-01-補足の説明-1.PNG画面の状態・条件-02-補足の説明-2.PNG画面の状態・条件-03.PNGの3枚のスクリーンショットが取得できます。

スクロールをしながらスクリーンショットを取得する

上記の仕組みを利用して、スクロール可能な画面のスクリーンショットを取得できます。

スクロール可能な画面は、一枚のスクリーンショットだとコンテンツ全体が取得できない可能性があります。 スクロールしながらスクリーンショットを取ることで、コンテンツ全体のスクリーンショットを複数枚に分けて取得することができます。

// R.id.scroll_view = スクロールをしたいViewのID
// R.id.bottom_view = スクロールをしたいViewの中で一番下に位置するViewのID
// スクロールの下端がbottom_viewと揃うまでスクロールとスクリーンショットの取得を繰り返す
uiTestExtension.page.captureSequentially("画面の状態") {
    captureEachScrolling(R.id.scroll_view, R.id.bottom_view)
}

Tips

画面表示が完了するまで待ち合わせる

コルーチンの待ち合わせ

UiTestExtensionidlingCoroutineDispatcherというフィールドをもっており、これはUIテストで待ち合わせ可能なCoroutineのDispatherです。

UIテスト中に実行されるCoroutineのDispatcherをidlingCoroutineDispatcherに変更することで、Coroutineの待ち合わせをすることができます。

また、引数にFunction Typeを受け取るwithIdlingCoroutineContextメソッドがあります。 これは、引数のFunction Typeを非同期で実行した上でテストコードで待ち合わせを行います。

このメソッドの具体的な活用例は次のとおりです。(mockk利用時の例)

// スタブ設定時にあえて値の返却を遅延させた上で待ち合わせを行いたいときにwithIdlingCoroutineContextを利用可能
// 例: 同期的に実行してしまうとUIに不整合が発生する場合等 
coEvery { repository.stubSuspendFunc()} coAnswers {
    uiTestExtension.withIdlingCoroutineContext {
        // 値を返す
    }
}

CountingIdlingResourceを使った待ち合わせ

UiTestExtensionCountingIdlingResourceをフィールドに持っています。

CountingIdlingResourceはカウンタが0のときをアイドル状態、0より大きい時をビジー状態とみなし、アイドル状態になるまで待ち合わせを行います。

非同期処理の開始と終了がフックできる場合に利用できます。

uiTestExtension.countingIdlingResource.increment()

asyncSomethingMethod() {
    uiTestExtension.countingIdlingResource.decrement()
}

UI Automator (UiDevice) を使った待ち合わせ

IdlingResourceでの待ち合わせが難しい場合、特定のViewが特定の状態になるまで待つといった待ち合わせ処理をUI Automator(UiDevice)で書くことができます。

例: 特定のIDボタンがenabledの状態になるまで待つ

// waitUntilおよびtoResourceNameはutilsにヘルパー関数として定義している
uiTestExtension.uiDevice.waitUntil(By.res(toResourceName(R.id.button_next)).enabled(true))

License

Copyright 2021 Mobility Technologies Co., Ltd.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

     http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Third Party Content