Activity/Fragment単位でVisual Regression Testingを実行する

Activity/Fragment単位でVisual Regression Testingを実行する

817e89f88197980860f1854b74fbee25?s=128

MAO YUFENG

July 14, 2020
Tweet

Transcript

  1. lcdsmao Activity/Fragment単位で Visual Regression Testing を実行する

  2. About Me lcdsmao lcdsmao Mao Yufeng (毛昱楓) CL, CyberAgent

  3. Visual Regression Testing (VRT) とは

  4. VRTとは? コード変更前と変更後のUIをピクセル比較するテスト https://github.com/reg-viz/reg-suit

  5. 実行結果例 UIに影響する変更があった際、レビュー時などで簡単に視覚的に気づく PRに貼ったレポート 詳細確認

  6. CL AndroidでやっているVRTの流れ Screenshot Firebase Test Lab Instrumented Test REG SUIT

    Cloud Storage VRT Github Report
  7. CL AndroidでやっているVRTの流れ Screenshot Firebase Test Lab Instrumented Test REG SUIT

    Cloud Storage VRT Github Report どうスクリーンショットを 撮っているかを話す
  8. VRT構成の参考記事 Android のアプリ開発でも Visual Regression Testing を始めましょう https://medium.com/@wasabeef/android-%E3%81%AE%E3%82%A2%E3%83%97%E3%83%AA%E9%96%8B%E7%99%BA%E3%8 1%A7%E3%82%82-visual-regression-testing-%E3%82%92%E5%A7%8B%E3%82%81%E3%81%BE%E3%81%97%E3%82%87%E3 %81%86-739504391cea

  9. VRTに使える スクリーンショットを撮るため に

  10. 課題 • テスト対象を決める • UI状態の作成 • UI操作の自動化 • スクリーンショットの安定化

  11. テスト対象を決める

  12. View層はテスト対象 Activity/ Fragment Layout(XML) ViewModel Repository LiveData View層 どれを選べばいいでしょう •

    Layout(XML) • Activity/Fragment
  13. UIの状態を含めてスクリーンショットを撮りたい テストの際、UI状態の組み合わせが簡単に作れるのは重要 パターン1 パターン2 パターン3 パターン4 同じ画面だけど状態 を網羅して撮りたい

  14. Layoutをテスト対象に UIの状態をいじるコードのメンテーが大変そう • テスト分 • Activity/Fragment分 DataBindingうまく使えば、⬆統一できそう が、結構チャレンジ Activity/ Fragment

    Layout(XML) ViewModel Repository LiveData
  15. Activity/Fragmentをテスト対象に Activity/Fragmentが持つViewModelは UIの状態を反映している 適切な状態を持つViewModelを作れば、望 ましいスクリーンショットが容易に撮れる Activity/ Fragment Layout(XML) ViewModel Repository

    LiveData
  16. 例:Activity class MyActivity : AppCompatActivity() { // Extension fun from

    activity-ktx private val viewModel by viewModels<MyViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.my_layout) viewModel.itemsLiveData.observe(this) { // Bind data to UI } } } MyActivity.kt
  17. ActivityScenarioでスクリーンショットを撮る @Test fun screenshot() { // TODO: 撮りたいUI状態を作る ActivityScenario.launch(MyActivity::class.java) //

    Screenshotのライブラリを使う // Dialogなど撮れるFalconおすすめ https://github.com/jraska/Falcon takeScreenshot("MyActivity") } MyActivityScreenshot.kt
  18. UI状態の作成

  19. 例:ViewModel class MyViewModel( private val repository: Repository ) : ViewModel()

    { // UIの状態を持つ val itemsLiveData = MutableLiveData<List<Item>>() fun loadItems() { itemsLiveData.value = repository.getItems() } } MyViewModel.kt
  20. Activity内ViewModelをインスタンス化 MyActivity.kt class MyActivity : AppCompatActivity() { private val viewModel

    by viewModels<MyViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... } }
  21. ViewModelFactoryをDIする MyActivity.kt class MyActivity : AppCompatActivity() { private val viewModel

    by viewModels<MyViewModel> { myViewModelFactory } @Inject lateinit var myViewModelFactory: ViewModelProvider.Factory override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // DaggerでMemberInjectionする DaggerMyComponent.builder() .build() .inject(this) } } ActivityにInjectする ViewModelFactoryをモックしたもの に差し替えできれば、適切なUI状態が 作れる
  22. MemberInjectionの問題 MemberInjectionを使っていると、普通に Daggerで構成されたGraphで依存解決され て、モックされたではない依存がinjectされ てテストが実行されてしまう (DaggerHilt, Koinなどを使うとこの問題無いかも) override fun onCreate(savedInstanceState:

    Bundle?) { super.onCreate(savedInstanceState) DaggerMyComponent.builder() .build() .inject(this) }
  23. MemberInjectionの問題 テストの時自由に ViewModelFactoryを Injectしたい! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)

    if (screenshotTest) { // モックをInjectする } else { DaggerMyComponent.builder() .build() .inject(this) } }
  24. MemberInjectionの解決案:InjectorWrapper class InjectorWrapper<T : Any>( private val defaultInjector: (T) ->

    Unit // DaggerのInjector ) { fun inject(target: T) { defaultInjector.invoke(target) } }
  25. InjectorWrapper class InjectorWrapper<T : Any>( private val targetClass: Class<T>, private

    val defaultInjector: (T) -> Unit // DaggerのInjector ) { fun inject(target: T) { val injector = injectorDelegateMap[targetClass] ?: defaultInjector injector.invoke(target) } companion object { private val injectorDelegateMap = mutableMapOf<Class<*>, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun <T> setDelegate(targetClass: Class<T>, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } }
  26. InjectorWrapper class InjectorWrapper<T : Any>( private val targetClass: Class<T>, private

    val defaultInjector: (T) -> Unit // DaggerのInjector ) { fun inject(target: T) { val injector = injectorDelegateMap[targetClass] ?: defaultInjector injector.invoke(target) } companion object { private val injectorDelegateMap = mutableMapOf<Class<*>, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun <T> setDelegate(targetClass: Class<T>, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } } テスト用InjectorをMapに保存する
  27. InjectorWrapper class InjectorWrapper<T : Any>( private val targetClass: Class<T>, private

    val defaultInjector: (T) -> Unit // DaggerのInjector ) { fun inject(target: T) { val injector = injectorDelegateMap[targetClass] ?: defaultInjector injector.invoke(target) } companion object { private val injectorDelegateMap = mutableMapOf<Class<*>, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun <T> setDelegate(targetClass: Class<T>, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } } テストInjectorあれば使う、無いならデフォルトの使う
  28. ActivityでInjectorWrapperを使う override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) DaggerMyComponent.builder() .build() .inject(this)

    } Before
  29. ActivityでInjectorWrapperを使う override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val component =

    DaggerMyComponent.builder() .build() // MyActivityをkeyに、DaggerのInjectorをデフォルトのInjectorに InjectorWrapper(MyActivity::class.java, component::inject) .inject(this) } After
  30. テスト依存をInjectorWrapperに設定する @Test fun screenshot() { // TODO: 撮りたいUI状態を作る ActivityScenario.launch(MyActivity::class.java) takeScreenshot("MyActivity")

    } Before
  31. テスト依存をInjectorWrapperに設定する After @Test fun screenshot() { // 撮りたいUI状態持つViewModelFactoryをInjectorWrapperに設定する InjectorWrapper.setDelegate(MyActivity::class.java) {

    activity -> activity.myViewModelFactory = mockMyViewModelFactory() } ActivityScenario.launch(MyActivity::class.java) takeScreenshot("MyActivity") }
  32. 適切な状態持つViewModelを作る // MockKを例に https://mockk.io/ fun mockMyViewModelFactory( items: List<Item> = emptyList()

    ): ViewModelProvider.Factory { val viewModel = MyViewModel(mockk()) // spyが必要で、後説明する val spyViewModel = spyk(viewModel) { every { itemsLiveData } returns MutableLiveData(items) every { loadItems() } just runs } return object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { @Suppress("UNCHECKED_CAST") return spyViewModel as T } } }
  33. 適切な状態持つViewModelを作る // MockKを例に https://mockk.io/ fun mockMyViewModelFactory( items: List<Item> = emptyList()

    ): ViewModelProvider.Factory { val viewModel = MyViewModel(mockk()) // spyが必要で、後説明する val spyViewModel = spyk(viewModel) { every { itemsLiveData } returns MutableLiveData(items) every { loadItems() } just runs } return object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T { @Suppress("UNCHECKED_CAST") return spyViewModel as T } } }
  34. モック作る時の注意点 • モックライブラリのmockはジェネリックスと相性悪い、spyを使う • API 27以下のInstrumented Testで、モックライブラリはfinal class が対応できない、allopenプラグインを使う //

    androidx.lifecycle.ViewModel <T> T setTagIfAbsent(String key, T newValue) https://github.com/mockk/mockk/blob/master/ANDROID.md https://kotlinlang.org/docs/reference/compiler-plugins.html plugins { id "org.jetbrains.kotlin.plugin.allopen" version "1.3.72" }
  35. UI操作の自動化

  36. UIを操作して撮りたいケース 初期状態 AppBarLayout をスクロール RecyclerView をスクロール ViewPager をスクロール

  37. EspressoによるUI操作 takeScreenshot("MyActivity-1") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform( RecyclerViewActions .scrollToPosition<RecyclerView.ViewHolder>(3) ) takeScreenshot("MyActivity-2") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform(

    RecyclerViewActions .scrollToPosition<RecyclerView.ViewHolder>(6) ) takeScreenshot("MyActivity-3")
  38. EspressoによるUI操作 takeScreenshot("MyActivity-1") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform( RecyclerViewActions .scrollToPosition<RecyclerView.ViewHolder>(3) ) takeScreenshot("MyActivity-2") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform(

    RecyclerViewActions .scrollToPosition<RecyclerView.ViewHolder>(6) ) takeScreenshot("MyActivity-3") 面倒だ!楽にしたい!
  39. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 木構造

  40. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 CoordinatorLayout •

    スクリーンショット • AppBarLayoutへ
  41. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 AppBarLayout •

    スクロール • スクリーンショット
  42. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 CoordinatorLayout •

    スクリーンショット • AppBarLayoutへ • ViewPagerへ
  43. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 ViewPager •

    RecyclerView 1へ
  44. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 RecyclerView 1

    • スクロール • スクリーンショット
  45. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 ViewPager •

    RecyclerView 1へ • RecyclerView 2へ
  46. ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 RecyclerView 2

    • スクロール • スクリーンショット
  47. 自動操作を実装する fun startAutomaticTakeScreenshot(scenario: ActivityScenario) { var rootView: View? = null

    // ActivityScenarioからルートViewを取得して走査を始める scenario.onActivity { activity -> rootView = activity.findViewById(android.R.id.content) } dfs(rootView!!) }
  48. 探索関数 fun dfs(view: View) { // Viewのタイプに応じてハンドリングする when (view) {

    is AppBarLayout -> handleAppBarLayout(view) is ViewPager -> handleViewPager(view) is RecyclerView -> handleRecyclerView(view) ... } }
  49. 特殊なViewをハンドリングする fun handleViewPager(view: ViewPager) { val startPosition = view.currentItem //

    Espressoで各Pageにスクロールする view.forEachIndexed { index, child -> if (index != startPosition) { performAction(view, ViewPagerActions.scrollToPage(index)) } // 現在のページを更に探索する dfs(child) } } ViewPagerの例
  50. 対象Viewを操作してスクリーンショットを撮る private fun performAction(view: View, viewAction: ViewAction) { // 同じIdもつViewがあり得るので、ユーニークのTagを作る

    val tagValue = "view-tree-operator-value-${tagCnt++}" view.setTag(R.id.view_tree_operator_target_tag_key, tagValue) val viewMatcher = ViewMatchers.withTagKey( R.id.view_tree_operator_target_tag_key, CoreMatchers.equalTo(tagValue) ) Espresso.onView(viewMatcher).perform(viewAction) takeScreenshot() }
  51. テストで自動操作 @Test fun screenshot() { InjectorWrapper.setDelegate(MyActivity::class.java) { activity -> activity.myViewModelFactory

    = mockMyViewModelFactory() } ActivityScenario.launch(MyActivity::class.java) takeScreenshot("MyActivity") } Before
  52. テストで自動操作 @Test fun screenshot() { InjectorWrapper.setDelegate(MyActivity::class.java) { activity -> activity.myViewModelFactory

    = mockMyViewModelFactory() } val scenario = ActivityScenario.launch(MyActivity::class.java) startAutomaticTakeScreenshot(scenario) } After
  53. スクリーンショットの安 定化

  54. アニメーションを禁止 android { testOptions { animationsDisabled = true } }

    build.gradle
  55. 非同期処理の完成を待つ • CoroutineDispatcher (Executor) をテスト用のものに変える https://qiita.com/takahirom/items/ebf3ae5e38b733904801 • DataBindingのバイディング処理を待つ https://github.com/android/architecture-components-samples/blob/1d7a759f742e8bdaf1eb4531e38ea9270301c577/GithubBrow serSample/app/src/androidTest/java/com/android/example/github/util/DataBindingIdlingResource.kt

    • Glideの画像ローディングを待つ https://lcdsmao.dev/synchronize-glide-loading-in-test-with-idlingresource/
  56. まとめ

  57. まとめ VRTに使えるスクリーンショットを撮るために • UI状態作りやすいActivity/Fragmentにした • 適切なUI状態持つViewModelをInjectできるようにInjectorWrapperを 作った • 煩雑なUI操作を避けるためにUI操作とスクリーンショットの自動化を実現し た

    • スクリーンショットを安定化するためアニメーション禁止と非同期処理の待 つをした