Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

Visual Regression Testing (VRT) とは

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

CL AndroidでやっているVRTの流れ Screenshot Firebase Test Lab Instrumented Test REG SUIT Cloud Storage VRT Github Report どうスクリーンショットを 撮っているかを話す

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

テスト対象を決める

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

例:Activity class MyActivity : AppCompatActivity() { // Extension fun from activity-ktx private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.my_layout) viewModel.itemsLiveData.observe(this) { // Bind data to UI } } } MyActivity.kt

Slide 17

Slide 17 text

ActivityScenarioでスクリーンショットを撮る @Test fun screenshot() { // TODO: 撮りたいUI状態を作る ActivityScenario.launch(MyActivity::class.java) // Screenshotのライブラリを使う // Dialogなど撮れるFalconおすすめ https://github.com/jraska/Falcon takeScreenshot("MyActivity") } MyActivityScreenshot.kt

Slide 18

Slide 18 text

UI状態の作成

Slide 19

Slide 19 text

例:ViewModel class MyViewModel( private val repository: Repository ) : ViewModel() { // UIの状態を持つ val itemsLiveData = MutableLiveData>() fun loadItems() { itemsLiveData.value = repository.getItems() } } MyViewModel.kt

Slide 20

Slide 20 text

Activity内ViewModelをインスタンス化 MyActivity.kt class MyActivity : AppCompatActivity() { private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... } }

Slide 21

Slide 21 text

ViewModelFactoryをDIする MyActivity.kt class MyActivity : AppCompatActivity() { private val viewModel by viewModels { 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状態が 作れる

Slide 22

Slide 22 text

MemberInjectionの問題 MemberInjectionを使っていると、普通に Daggerで構成されたGraphで依存解決され て、モックされたではない依存がinjectされ てテストが実行されてしまう (DaggerHilt, Koinなどを使うとこの問題無いかも) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) DaggerMyComponent.builder() .build() .inject(this) }

Slide 23

Slide 23 text

MemberInjectionの問題 テストの時自由に ViewModelFactoryを Injectしたい! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (screenshotTest) { // モックをInjectする } else { DaggerMyComponent.builder() .build() .inject(this) } }

Slide 24

Slide 24 text

MemberInjectionの解決案:InjectorWrapper class InjectorWrapper( private val defaultInjector: (T) -> Unit // DaggerのInjector ) { fun inject(target: T) { defaultInjector.invoke(target) } }

Slide 25

Slide 25 text

InjectorWrapper class InjectorWrapper( private val targetClass: Class, 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, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun setDelegate(targetClass: Class, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } }

Slide 26

Slide 26 text

InjectorWrapper class InjectorWrapper( private val targetClass: Class, 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, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun setDelegate(targetClass: Class, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } } テスト用InjectorをMapに保存する

Slide 27

Slide 27 text

InjectorWrapper class InjectorWrapper( private val targetClass: Class, 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, (Any) -> Unit>() // Test用のInjector @VisibleForTesting fun setDelegate(targetClass: Class, injector: (T) -> Unit) { @Suppress("UNCHECKED_CAST") injectorDelegateMap[targetClass] = { injector.invoke(it as T) } } } } テストInjectorあれば使う、無いならデフォルトの使う

Slide 28

Slide 28 text

ActivityでInjectorWrapperを使う override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) DaggerMyComponent.builder() .build() .inject(this) } Before

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

テスト依存をInjectorWrapperに設定する @Test fun screenshot() { // TODO: 撮りたいUI状態を作る ActivityScenario.launch(MyActivity::class.java) takeScreenshot("MyActivity") } Before

Slide 31

Slide 31 text

テスト依存をInjectorWrapperに設定する After @Test fun screenshot() { // 撮りたいUI状態持つViewModelFactoryをInjectorWrapperに設定する InjectorWrapper.setDelegate(MyActivity::class.java) { activity -> activity.myViewModelFactory = mockMyViewModelFactory() } ActivityScenario.launch(MyActivity::class.java) takeScreenshot("MyActivity") }

Slide 32

Slide 32 text

適切な状態持つViewModelを作る // MockKを例に https://mockk.io/ fun mockMyViewModelFactory( items: List = 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 create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") return spyViewModel as T } } }

Slide 33

Slide 33 text

適切な状態持つViewModelを作る // MockKを例に https://mockk.io/ fun mockMyViewModelFactory( items: List = 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 create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") return spyViewModel as T } } }

Slide 34

Slide 34 text

モック作る時の注意点 ● モックライブラリのmockはジェネリックスと相性悪い、spyを使う ● API 27以下のInstrumented Testで、モックライブラリはfinal class が対応できない、allopenプラグインを使う // androidx.lifecycle.ViewModel 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" }

Slide 35

Slide 35 text

UI操作の自動化

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

EspressoによるUI操作 takeScreenshot("MyActivity-1") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform( RecyclerViewActions .scrollToPosition(3) ) takeScreenshot("MyActivity-2") Espresso.onView(ViewMatchers.withId(R.id.recycler_view)) .perform( RecyclerViewActions .scrollToPosition(6) ) takeScreenshot("MyActivity-3") 面倒だ!楽にしたい!

Slide 39

Slide 39 text

ViewTreeを探索して自動操作する AppBarLayout CoordinatorLayout ViewPager RecyclerView 1 RecyclerView 2 木構造

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

自動操作を実装する fun startAutomaticTakeScreenshot(scenario: ActivityScenario) { var rootView: View? = null // ActivityScenarioからルートViewを取得して走査を始める scenario.onActivity { activity -> rootView = activity.findViewById(android.R.id.content) } dfs(rootView!!) }

Slide 48

Slide 48 text

探索関数 fun dfs(view: View) { // Viewのタイプに応じてハンドリングする when (view) { is AppBarLayout -> handleAppBarLayout(view) is ViewPager -> handleViewPager(view) is RecyclerView -> handleRecyclerView(view) ... } }

Slide 49

Slide 49 text

特殊な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の例

Slide 50

Slide 50 text

対象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() }

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

テストで自動操作 @Test fun screenshot() { InjectorWrapper.setDelegate(MyActivity::class.java) { activity -> activity.myViewModelFactory = mockMyViewModelFactory() } val scenario = ActivityScenario.launch(MyActivity::class.java) startAutomaticTakeScreenshot(scenario) } After

Slide 53

Slide 53 text

スクリーンショットの安 定化

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

非同期処理の完成を待つ ● 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/

Slide 56

Slide 56 text

まとめ

Slide 57

Slide 57 text

まとめ VRTに使えるスクリーンショットを撮るために ● UI状態作りやすいActivity/Fragmentにした ● 適切なUI状態持つViewModelをInjectできるようにInjectorWrapperを 作った ● 煩雑なUI操作を避けるためにUI操作とスクリーンショットの自動化を実現し た ● スクリーンショットを安定化するためアニメーション禁止と非同期処理の待 つをした