スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

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

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

今回は、 Visual Regression Test (以下 VRT) の tips 集の続編についてお話しします。 VRT の簡単な説明と前回の tips (tips 1~4) については こちら をご覧ください。

Tips 集

5. 「スワイプ」 ではなく 「スクロール」 させる

ScrollView や RecyclerView を使用している場合、特定箇所まで画面をスクロールさせた上でスクリーンショットを撮影したいことがあります。その際、ViewActions.swipeUp() を使用すると edge effect が発生してしまい、撮影のタイミングによって edge effect の形が変わるため意図しない差分検知が発生してしまいます。

Espresso.onView(ViewMatchers.withId(R.id.hogeList))
    .perform(ViewActions.swipeUp())

一方、ViewActions.scrollTo()RecyclerViewActions.scrollToPosition() を用いると、edge effect を発生させることなく画面をそのまま撮影することが可能です。

// スクロール先を id で指定
Espresso.onView(ViewMatchers.withId(R.id.target))
    .perform(ViewActions.scrollTo())

// スクロール先を index で指定
Espresso.onView(ViewMatchers.withId(R.id.hogeList))
    .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(14))

またこれらの方法ではどこまでスクロールするかの行き先を指定することができ便利です。  

6. 画面が所望の状態になるまで 「待って」 からスクリーンショットを撮る

「テスト対象画面を起動し、スクリーンショットを撮影する」という処理のみでは、撮影したい状態に達する前の状態の画面が撮影されてしまう可能性があります。例えば、ある TextView に特定の文字列が表示されている画面を撮影したい場合、その文字列が表示される前の状態(空文字状態)でスクリーンショットが撮影される、といった具合です。

そこで Espresso の IdlingResource という仕組みを用いると、画面が事前に指定した状態(アイドル状態)になるまでテストスレッドを待機させることができます。

class EspressoViewIdlingResource(
    private val viewMatcher: Matcher<View>,
    private val idleMatchers: List<Matcher<View>>
) : IdlingResource {

    private var resourceCallback: IdlingResource.ResourceCallback? = null

    // idleMatchers で指定した状態になった場合に true を返すよう実装する
    override fun isIdleNow(): Boolean {
        val view: View? = getView(viewMatcher)
        val isIdle = idleMatchers.all { it.matches(view) }

        if (isIdle) {
            resourceCallback?.onTransitionToIdle()
        }
        return isIdle
    }

    override fun registerIdleTransitionCallback(resourceCallback: IdlingResource.ResourceCallback?) {
        this.resourceCallback = resourceCallback
    }

    override fun getName(): String {
        return "$this $viewMatcher"
    }

    private fun getView(viewMatcher: Matcher<View>?): View? {
        return try {
            val viewInteraction = onView(viewMatcher)
            val finder = viewInteraction.javaClass
                .getDeclaredField("viewFinder")
                .apply { isAccessible = true }
                .get(viewInteraction) as ViewFinder
            finder.view
        } catch (e: Exception) {
            // Appropriate error handling
            null
        }
    }
}

先ほどの TextView の例で考えると

// 作成した EspressoViewIdlingResource を使用し、特定の View に指定文字列が表示されるまで待機させるメソッドを実装する
fun waitUntilTextChangedByText(matcher: Matcher<View>, text: String) {
    val idlingResource: IdlingResource = EspressoViewIdlingResource(
        matcher,
        listOf(ViewMatchers.withText(resId))
    )
    try {
        IdlingRegistry.getInstance().register(idlingResource)
        Espresso.onView(ViewMatchers.withId(0)).check(ViewAssertions.doesNotExist())
    } finally {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }
}

// テストコード
// 作成した待機メソッドを呼ぶ
waitUntilTextChangedByText(ViewMatchers.withId(R.id.errorTitle), "エラーが発生しました")

// スクリーンショットを撮影
...

という形で、TextView に「エラーが発生しました」という文字列が表示されるまで待機してからスクリーンショットを撮ることが可能になります。

7. Koin と MockK を使って VRT をシンプルに実装する

弊プロジェクトでは DI ライブラリとして Koin 、テスト用モックライブラリとして MockK を使用しています。これらのライブラリと、ここまでの tips を組み合わせた VRT の実装例を紹介します。

例として、SampleActivitySampleViewModel に, SampleViewModelHogeRepository に依存しているとします。

class SampleViewModel(
    private val hogeRepository: HogeRepository
) : ViewModel() {
    // HogeRepository.getHoge() を呼んで結果を UI に流す
}

class HogeRepository(
    ...
) {
    suspend fun getHoge(): Hoge {
        ...
    }
    ...
}

この場合、例えば以下のように記述できます。

@RunWith(AndroidJUnit4::class)
class SampleActivityTest {

    // tips 2: 意図しないネットワークリクエストを抑制する
    @get:Rule
    val suppressRequestApolloClientRule = SuppressRequestApolloClientRule()

    // tips 4. テストケース名(スクリーンショットのファイル名に使用)を取得する Rule を用いる
    @get:Rule
    val testName = TestName()

    @Test
    fun takeSampleScreenTitleShownCorrectly() {
        // Production code 上の HogeRepository を上書きする
        loadKoinModules(
            module {
                factory {
                    mockk<HogeRepository> {
                        // 撮影したい画面状態を作る上で必要な値を返却させる
                        coEvery { getHoge() } returns Hoge("hoge")
                    }
                 }
            }
        )

        // 画面を起動する
        ActivityScenario.launch(SampleActivity::class.java).use {
            // tips 6. 所望の状態になるまでテストを待機させる
            waitUntilTextChangedByText(withId(R.id.title), "hoge")
            // スクリーンショット撮影
            it.takeScreenshot(testName = testName)
        }
    }
}

以上のように Koin と MockK を用いて Repository が所望のデータを返すよう変更することで、簡単に撮影したい画面状態を作ることができました。

8. Jetpack Compose における VRT

Jetpack Compose を導入している画面に対しては、以下のような Composable 専用のスクリーンショット撮影メソッドを作成し利用しています。

fun <T : Any> SemanticsNodeInteraction.takeScreenshot(
    context: Context,
    screenClass: KClass<T>,
    testName: TestName,
) {
    takeScreenshot(
        context = context,
        fileName = "${screenClass.simpleName}_${testCase.name}",
        bitmap = captureToImage().asAndroidBitmap()
    )
}

private fun takeScreenshot(context: Context, fileName: String, bitmap: Bitmap) {
    val imageFile = File(
        context.applicationContext.getExternalFilesDir(Environment.DIRECTORY_SCREENSHOTS),
        "$fileName.png"
    )

    FileOutputStream(imageFile).use {
        bitmap.compress(Bitmap.CompressFormat.PNG, 0, it)
        it.flush()
    }
}

テストの実装例は以下のようになります。SampleActivity が SampleScreen Composable を root として起動しているとします。

この場合、例えば以下のように記述できます。

@RunWith(AndroidJUnit4::class)
class SampleActivityTest {

    // tips 2: 意図しないネットワークリクエストを抑制する
    @get:Rule
    val suppressRequestApolloClientRule = SuppressRequestApolloClientRule()

    // tips 4. テストケース名(スクリーンショットのファイル名に使用)を取得する Rule を用いる
    @get:Rule
    val testName = TestName()

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun takeSampleScreenTitleShownCorrectly() {
        var context: Context? = null

        // 画面を起動する
        composeTestRule.setContent {
            context = LocalContext.current
            SampleScreen(
                title = "hoge"
                ...
            )
        }

        // スクリーンショットを撮影
        composeTestRule.onRoot()
            .takeScreenshot(
                context = checkNotNull(context),
                screenClass = SampleActivity::class,
                testName = testName
            )
    }
}

Android View の場合に比べて以下が楽になります。

  • 画面の Root Composable に必要な引数を渡すのみで所望の状態で画面を起動できるため、特定の DI やモックライブラリに依存しないテストが書ける
    • Root Composable が ViewModel など DI ライブラリに依存しうる値を参照している場合も、より primitive な Composable に切り出した上で同様なテストが書ける
  • Test 中の UI 更新に非同期処理を行なっていない限り Compose が自動待機してくれるため、 IdlingResource が不要になるケースが多い

コード量もかなり減り、メンテナンスコストの低下が期待できます。

おわりに

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

また弊チームでは Jetpack Compose への移行を継続的に行なっており、現在は Composable Preview をそのまま利用して VRT を行えないか検討中です。


We're hiring!

スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!