Skip to content

Latest commit

 

History

History
566 lines (437 loc) · 30.3 KB

VisualRegressionTest_Advanced.md

File metadata and controls

566 lines (437 loc) · 30.3 KB

さまざまなケースでComposeの画面スクリーンショットを撮る

ここまでで、Composeのプレビュー画面について、スクリーンショットを保存してVisual Regression Testを運用する基本的な方法を学んできた。

本節では、より応用的なトピックとして、次のようなケースでスクリーンショットを保存するテクニックを紹介する。

1つのプレビュー画面のスクリーンショットを、さまざまな環境の下で保存する

実現の仕組み

実現方法はユースケースによって2種類ある。

  • 1つの@Previewアノテーションにつき、複数パターンのスクリーンショットを保存したい
    • 例:すべてのプレビュー画面について、ダークモードのスクリーンショットとライトモードのスクリーンショットの両方を保存したい
  • 特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存したい
    • 例:@Preview(widthDp = 800) と指定されているときは、画面幅800dpのスクリーンショットを保存したい

ユースケース1:1つの@Previewアノテーションにつき、複数パターンのスクリーンショットを保存する

このユースケースは、「Composeのプレビュー画面でVisual Regression Testを行う」で紹介したParameterizedRobolectricTestRunnerの仕組みを使うと自然に実現できる。

@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
class AllPreviewScreenshotTest(
    private val testCase: TestCase
) {

    // あとで composeTestRule.activityRule が必要になるため createAndroidComposeRule を使う
    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    ...

    @Test
    fun testLightMode() {
        val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png"
        // ライトモードでスクリーンショットを保存するように環境を設定する
        // updateScreenshotEnvironmentの実装は後述
        updateScreenshotEnvironment("notnight")

        // 変更した環境を適用するためにActivityを再生成する
        composeTestRule.activityRule.scenario.recreate()

        // スクリーンショットをとる
        capturePreview(filePath)
    }

    @Test
    fun testDarkMode() {
        // テストメソッドごとにスクリーンショットのファイル名が重複しないように末尾に`_night`を入れたファイル名にする
        val filePath = "${testCase.showkaseBrowserComponent.componentKey}_night.png"
        // ダークモードでスクリーンショットを保存するように環境を設定する
        // updateScreenshotEnvironmentの実装は後述
        updateScreenshotEnvironment("night")

        // 変更した環境を適用するためにActivityを再生成する
        composeTestRule.activityRule.scenario.recreate()

        // スクリーンショットをとる
        capturePreview(filePath)
    }

    private fun capturePreview(filePath: String) {
        // 「Composeのプレビュー画面でVisual Regression Testを行う」で紹介したスクリーンショットを保存するコード
        ...
    }

    companion object {
        class TestCase(
            val showkaseBrowserComponent: ShowkaseBrowserComponent
        ) {
            override fun toString() = showkaseBrowserComponent.componentKey
        }


        @ParameterizedRobolectricTestRunner.Parameters(name = "[{index}] {0}")
        @JvmStatic
        fun components(): Iterable<Array<*>> = Showkase.getMetadata().componentList.map {
            arrayOf(TestCase(it))
        }
    }
}

Showkaseでは、次のコードで@Previewまたは@ShowkaseComposableが付いたComposable関数に関する情報(ShowkaseBrowserComponent)のリストが得られる。 ひとつのアノテーションにつき、ひとつの要素(ShowkaseBrowserComponentインスタンス)が対応している。

val showkaseBrowserComponentList: List<ShowkaseBrowserComponent> = Showkase.getMetadata().componentList

ParameterizedRobolectricTestRunnerでは、1つのShowkaseBrowserComponentにつき1回テストクラスが呼び出されるため、テストクラス内に複数のテストメソッドを定義すれば、それぞれのテストが呼び出されることになる。 そのため、1つのプレビュー画面で撮りたい各バリエーションについて、それに対応するテストメソッドを定義していけばよい。

ユースケース2:特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存する

@Previewまたは@ShowkaseComposableアノテーションのオプション引数に指定された情報は、前述のShowkaseBrowserComponentクラスのプロパティから取得できる。 ShowkaseBrowserComponentクラスには次のプロパティが定義されている。

data class ShowkaseBrowserComponent(
    val componentKey: String, 
    val group: String,
    val componentName: String,
    val componentKDoc: String,
    val component: @Composable () -> Unit,
    val styleName: String? = null,
    val isDefaultStyle: Boolean = false,
    val widthDp: Int? = null,
    val heightDp: Int? = null,
    val tags: List<String> = emptyList(),
    val extraMetadata: List<String> = emptyList()
)

これらのプロパティに格納されている情報を元に、テストコード側で条件分岐すれば、たとえば次のようなことが実現できる。

  • widthDpheightDpによって指定された画面サイズでスクリーンショットを撮る
  • group"night"という文字列が含まれるものについては、ナイトモード(ダークモード)でスクリーンショットを撮る

ところが、@Previewアノテーションのパラメーターのうち、ShowkaseBrowserComponentのプロパティに反映されるのは次の4つしかない。 そのため、この4つのパラメーターを使って「どのような環境(例:ダークモード)でスクリーンショットをとりたいのか」を表現しなければならない。

@Previewアノテーションのパラメーター名 対応するShowkaseBrowserComponentのプロパティ名
group group
name componentName
widthDp widthDp
heightDp heightDp

たとえば、次のような2つの設定で、(Android Studio上に)プレビュー画面を表示しているケースを考える。

// 360x640の画面サイズ、ドイツ語、ナイトモードでプレビュー画面を表示する
@Preview(widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES)
// 1200x800の画面サイズ、日本語、ナイトモードでプレビュー画面を表示する
@Preview(widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyComposable() { ... }

同じ設定下で表示した画面をスクリーンショットテストでも保存したいものの、localeuiModeに指定されている値はShowkaseBrowserComponentに反映されない。 そこで、localeuiModeに指定されている値の情報を、(ShowkaseBrowserComponentに反映される)groupパラメーターにも指定することを考える。 それらの情報を-区切りでエンコードするのであれば、次のように@Previewアノテーションの宣言を書き換えるとよい。

@Preview(group = "de-night", widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(group = "ja-night", widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MyComposable() { ... }

このようにすることで、Android Studioのプレビュー画面では widthDpheightDplocaleuiModeの情報を使って画面を表示し、 スクリーンショットテストではwidthDpheightDpgroupの情報を使ってスクリーンショットを保存できるようになる。

何度も同じような宣言を書くのが大変な場合は、次のようにカスタムアノテーションを定義してもよい。

// MyCustomPreviewという名前のカスタムアノテーションを定義
@Preview(group = "de-night", widthDp = 360, heightDp = 640, locale = "de", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(group = "ja-night", widthDp = 1200, heightDp = 800, locale = "ja", uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class MyCustomPreviews


// カスタムアノテーションを、上記組み合わせでプレビューしたい関数に付与する
@MyCustomPreview
@Composable
fun MyComposable() { ... }

このユースケースのテストコードは次のようなイメージになる。

class AllPreviewScreenshotTest(
    private val testCase: TestCase
) {

  // あとで composeTestRule.activityRule が必要になるため createAndroidComposeRule を使う
  @get:Rule
  val composeTestRule = createAndroidComposeRule<ComponentActivity>()
  lateinit var context: Context

  @Before
  fun setUp() {
      context = ApplicationProvider.getApplicationContext()
  }

  ...

  @Test
  fun test() {
    ...
    val widthDp = testCase.showkaseBrowserComponent.widthDp
    val heightDp = testCase.showkaseBrowserComponent.heightDp
    // 画面サイズの変更
    if (widthDp != null || heightDp != null) {
        // 画面サイズを変更する。setDisplaySizeの実装は後述。
        setDisplaySize(widthDpp, heightDp)
    }

    // `group`が指定されていないときは"Default Group"という文字列になっている
    // そのときは`group`のパースをスキップする
    if (testCase.showkaseBrowser.group != "Default Group") {
      // group に指定されたハイフン区切りの文字列をパースする
      // たとえば
      // group = "de-night"
      // であれば
      // tagsは ["de", "night"] となる
      val tags = testCase.showkaseBrowser.group.split("-")

      // その他の情報の反映
      tags.forEach { tag ->
          // tagの指定内容にあわせて環境を変更する。
          // updateScreenshotEnvironmentの実装は後述
          updateScreenshotEnvironment(tag)
      }
    }
    // 変更した画面サイズや環境を適用するためにActivityを再生成する
    composeTestRule.activityRule.scenario.recreate()

    // スクリーンショットを撮る
    composeTestRule.setContent { 
        testCase.showkaseBrowserComponent.component()
    }
    composeTestRule.onRoot().captureRoboImage()
  }
}

特に次のポイントに注意すること。

  • 画面サイズや環境を変更した後にはcomposeTestRule.activityRule.scenario.recreate()を呼び出してActivityを再生成する必要がある
  • composeTestRule.activityRuleにアクセスするためにはcreateComposeRule()ではなくcreateAndroidComposeRule()を使う必要がある

なお、groupパラメーターにスクリーンショット取得環境の指定をエンコードする方法は、厳密にいえばgroupパラメーターの用途外利用となる。 とくに、ShowkaseブラウザでUIカタログを閲覧するときのグルーピングが意味をなさなくなってしまう点はデメリットかも知れない。 もし、その点が許容できない場合は、@Previewアノテーションとあわせて(Showkaseからしか認識されない)@ShowkaseComposableアノテーションを併記する方法もある。 詳細は割愛するが、その場合は画面サイズ以外の情報をtagsパラメーターに指定するとよい。

スクリーンショット取得のための環境セットアップ

これまでに学んできた実現の仕組みに加えて、スクリーンショットを撮りたい環境別のセットアップ方法を学べば、特定の環境下でのスクリーンショット取得が実現できる。 ここでは、次の3つのケースについてセットアップ方法を説明する。

特定の画面サイズでスクリーンショットを撮る

スクリーンショットを撮りたい画面サイズwidthDpheightDpが与えられたときに、画面サイズを変更するsetDisplaySize()メソッドの実装を紹介する。 「ユースケース2」で紹介したテストコードとあわせて使えば、@PreviewアノテーションのwidthDpheightDpの指示どおりの画面サイズでスクリーンショットを取得できる。

Robolectricが提供している動的に画面サイズを変更するShadowDisplay APIを使って実現できる。

private fun setDisplaySize(widthDp: Int?, heightDp: Int?) {
  val display = ShadowDisplay.getDefaultDisplay()
  // 「ユースケース2」のテストクラスで宣言されていたcontextプロパティを使っている
  val density = context.resources.displayMetrics.density
  widthDp?.let {
      val widthPx = (widthDp * density).roundToInt()
      Shadows.shadowOf(display).setWidth(widthPx)
  }
  heightDp?.let {
      val heightPx = (heightDp * density).roundToInt()
      Shadows.shadowOf(display).setHeight(heightPx)
  }
}

なお「ユースケース2」で紹介したテストコードでは、テストクラスの@Configアノテーションで、デバイスとしてRobolectricDeviceQualifiers.Pixel7が指定されている。 そのため、widthDpheightDpが指定されていないときはPixel7の画面サイズでスクリーンショットが撮られることになる。

特定のフォントスケールでスクリーンショットを撮る

フォントスケールを変更した状態でスクリーンショットを撮るには、CompositionLocalProviderを使ってLocalDensityを変更することで実現する。 CompositionLocalProviderLocalDensity.currentはComposable関数であるため、composeTestRule.setContent{ ... }の中で宣言する必要がある。

次に、フォントスケールを2倍にする(本来のフォントの2倍の大きさでレンダリングする)例を示す。

composeTestRule.setContent {
    val density = LocalDensity.current
    val customDensity = Density(fontScale = density.fontScale * 2, density = density.density)
    CompositionLocalProvider(
        LocalDensity provides customDensity
    ) {
        testCase.showkaseBrowserComponent.component()
    }
}

すべてのプレビュー画面について、通常のフォントサイズと、2倍のフォントサイズの2種類のスクリーンショットを撮りたいという「ユースケース1」のパターンでは、 片方のテストメソッドについてだけ上記の設定を入れるとよい。

@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7)
class AllPreviewScreenshotTest(
    private val testCase: TestCase
) {


    @Test
    fun defaultFontScaleTest() {
        val filePath = "${testCase.showkaseBrowserComponent.componentKey}.png"
        // デフォルトのフォントスケールでスクリーンショットをとる
        takeScreenshot(filePath = filePath)
    }
    
    @Test
    fun doubledFontScaleTest() {
        val filePath = "${testCase.showkaseBrowserComponent.componentKey}_font2x.png"
        // 2倍のフォントスケールでスクリーンショットをとる
        takeScreenshot(filePath = filePath, fontMagnification = 2.0f)
    }

    private fun takeScreenshot(filePath: String, fontMagnification: Float = 1.0f) {
        composeTestRule.setContent {
            val density = LocalDensity.current
            val customDensity = Density(fontScale = density.fontScale * fontMagnification, density = density.density)
            CompositionLocalProvider(
                // 以前に紹介したようにLocalInspectionModeをtrueにする
                LocalInspectionMode provides true,
                // フォントを拡大したcustomDensityでLocalDensityを上書きする
                LocalDensity provides customDensity
            ) {
                testCase.showkaseBrowserComponent.component()
            }
        }
        // captureRoboImage(filePath)でスクリーンショットを撮る
        ...
    }

    companion object {
        ...
    }
}

ナイトモード(ダークモード)でスクリーンショットを撮る

Robolectricでは、「Specifying Device Configuration」に列挙されている項目(qualifierと呼ぶ)であれば、 RuntimeEnvironment.setQualifiers()を使うことで環境を一時的に変更できる。 ナイトモードに関しては、デフォルトではライトモード(notnight)で、nightを指定するとナイトモードとなる。

次のように、先頭に+を付けてqualifier(ここではnotnightnight)を指定すると、デフォルトで指定されているqualifiersに追加で環境を指定できる。

「デフォルトで指定されているqualifiers」というのは、テストクラスの@Configで指定されているqualifiers引数のことである。 いままでのテストクラスではRobolectricDeviceQualifiers.Pixel7を指定していたが、この具体的な値は次のように定義されている。

const val Pixel7 = "w411dp-h914dp-normal-long-notround-any-420dpi-keyshidden-nonav"

つまり

RuntimeEnvironment.setQualifiers("+night")

と指定することで、このPixel7の設定に加えてナイトモードを指定したことになる。

前述の「ユースケース2」を想定して@Previewgroupパラメーターに、ハイフン区切りのqualifierを指定することにすれば、 「ユースケース2」で紹介したテストコード中のupdateScreenshotEnvironmentに関連する部分は次のようになる。

@Test
fun test() {
    ...
    // `group`が指定されていないときは"Default Group"という文字列になっている
    // そのときは`group`のパースをスキップする
    if (testCase.showkaseBrowser.group != "Default Group") {
        // group に指定されたハイフン区切りの文字列をパースする
        // たとえば
        // group = "de-night"
        // であれば
        // tagsは ["de", "night"] となる
        val tags = testCase.showkaseBrowser.group.split("-")
        tags.forEach { tag ->
            // tagの指定内容にあわせて環境を変更する。
            updateScreenshotEnvironment(tag)
        }
    }
    ...
}

fun updateScreenshotEnvironment(tag: String) {
  RuntimeEnvironment.setQualifiers("+$tag")
}

ここまでのまとめ

スクリーンショットを撮る2つのユースケース別に、テストの組み立て方を説明した。

  • 1つのアノテーション(1つのShowkaseBrowserComponent)につき、複数パターンのスクリーンショットを保存する
  • 特定のプレビュー画面のみ、通常とは異なる環境でスクリーンショットを保存する

続けて、スクリーンショットを撮りたい環境のセットアップ手段として使える3つの方法を具体例と共に説明した。

  • RobolectricのShadow APIを使って画面サイズを変更する
  • CompositionLocalProviderを使ってフォントスケールを変更する
  • RobolectricのRuntimeEnvironment.setQualifiers()を使ってナイトモード(ダークモード)にする

練習問題1〜3

前回作成したAllPreviewScreenshotTestを、VariousPreviewScreenshotTestというクラス名でapp/src/testExercise配下にコピーしている。 次の内容を実現できるように、そのテストコードを改造しよう。

改造した結果は、プレビュー関数InterestsScreenPopulatedの5枚のスクリーンショット(screenshots/com.google.samples.apps.nowinandroid.feature.interests_InterestsScreenPopulated_*.png)で判断できる。

  • 練習問題1:「ユースケース2」のテストコードを参考に、@PreviewwidthDpheightDpが指定されたときに画面サイズを変更してスクリーンショットを撮るようにしてみよう
    • プレビュー関数InterestsScreenPopulatedに付与されている@DevicePreviewsアノテーション定義にはwidthDpheightDpが指定されているにもかかわらず、改造前の状態では4枚とも同じ画面サイズのスクリーンショットになっている。 テストコードを修正して、widthDpheightDpの指示どおりの画面サイズでスクリーンショットを撮るようにしよう
  • 練習問題2:いままで取得していた各スクリーンショットについて、フォントスケールを2倍にしたときのスクリーンショットも合わせて保存するようにしてみよう(「ユースケース1」のパターン)
    • フォントスケール1倍となっている5枚のスクリーンショットに加えて、フォントスケール2倍のスクリーンショット5枚(合計10枚)を撮るようにしよう
  • 練習問題3:練習問題1をさらに改良し、@Previewgroupパラメーター(ハイフン区切り)にnightが含まれていたらナイトモードでスクリーンショットを撮るようにしてみよう
    • @DevicePreviewsの定義内でnightが含まれているものは1つだけである。 その1つに対応するスクリーンショットをナイトモードのスクリーンショットにしよう (ナイトモードのスクリーンショットは、練習問題2によってフォントスケール別に2枚保存されるはずである)

UIテストの中で画面のスクリーンショットを保存する

Roborazziは、プレビュー画面以外であってもスクリーンショットを取得できる。 たとえば、Jetpack Composeによって構築された画面のUIをComposeTestRuleで操作した結果をスクリーンショットとして保存できる(UI操作の方法は「ViewModelを結合してComposeをテストする」参照)。

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UserInteractionTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun test() {
        composeTestRule.setContent {
            // テストしたいComposable関数
            MyComposable()
        }
        // OKボタンクリック
        composeTestRule.onNodeWithText("OK").performClick()
        // スクリーンショット取得
        composeTestRule.onRoot().captureRoboImage()

    }
}

このコードでは、MyComposable()の画面を表示し、「OK」と書かれたコンポーネントをクリックした後の画面のスクリーンショットを保存している。

練習問題4

現在のテストコードのままでテストを実行し、スクリーンショットが次のような結果になることを確認しよう。

次に、Content DescriptionにHeadlinesと書かれているコンポーネントをクリックしてから captureRoboImage() を呼ぶようにテストコードを書き換えよう。
※ヒント:onNodeWithContentDescriptionメソッドとperformClickメソッドを利用する

最後に、再度テストを実行し、スクリーンショットが次のように変化することを確認しよう。

Coilを使って画像を非同期のロードする画面のスクリーンショットを保存する

練習問題4で撮影したForYouScreenは、本来であればHeadlinesなどの見出しの左側にアイコンが表示されるはずだが、スクリーンショットだとアイコンは表示されていない。

練習問題4で撮影した画像 アプリとして動作させたときの画像

このアイコンはCoilを使ってネットワークから非同期にダウンロードしたものが表示されているが、 RobolectricではCoilの非同期ダウンロードを正しく扱えないため、このようなスクリーンショットになってしまう。

この問題を解決するのがFakeImageLoaderEngineである。 FakeImageLoaderEngineはCoilから提供されており、次の依存関係を追加することで利用できるようになる。

testImplementation("io.coil-kt:coil-test:2.6.0")

FakeImageLoaderEngineを使うと、画像をURLからダウンロードする代わりに、指定した画像(AndroidのDrawableオブジェクト)を表示させることができる。 代替画像は、URLごとに異なるものを指定したり、すべて同じ画像にしたりできる。 Coilの公式ドキュメントに記載されている使用例は次のとおり(コメントは筆者追記)。

@Before
fun before() {
    val engine = FakeImageLoaderEngine.Builder()
        // 特定のURLと完全一致したときの代替画像を指定する
        .intercept("https://www.example.com/image.jpg", ColorDrawable(Color.RED))

        // URLが特定の条件を満たしたときの代替画像を指定する
        .intercept({ it is String && it.endsWith("test.png") }, ColorDrawable(Color.GREEN))

        // デフォルトの代替画像を指定する
        .default(ColorDrawable(Color.BLUE))
        .build()
    val imageLoader = ImageLoader.Builder(context)
        .components { add(engine) }
        .build()
    // ここで作ったFake ImageLoaderに差し替える
    Coil.setImageLoader(imageLoader)
}

なお、この方法でCoilのImageLoaderを差し替えたときは、tearDownメソッドでCoil.reset()を呼んで差し替えたImageLoaderを元に戻さなければならない点に注意すること。

@After
fun tearDown() {
    Coil.reset()
}

練習問題5

練習問題4で作成したForYouScreenVisualRegressionTestについて、次のようなルールで代替画像を設定し、 アイコンが表示されるようにしてみよう。

※依存ライブラリ io.coil-kt:coil-test:2.6.0 の追加は設定済み

URL 代替画像
URLにAndroid-Studioが含まれているとき R.drawable.ic_topic_android_studio
URLにComposeが含まれているとき R.drawable.ic_topic_compose
URLにHeadlinesが含まれているとき R.drawable.ic_topic_headlines

リソースID(R.drawable.xxxx)からDrawableオブジェクトを生成するにはcontext.getDrawable()を使う。

val context = ApplicationProvider.getApplicationContext<Context>()
val drawable = context.getDrawable(R.drawable.xxxx)

うまくいけば、次のようなスクリーンショットが撮れるはずである。