ããã«ã¡ã¯ã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 ã®å®è£ ä¾ãç´¹ä»ãã¾ãã
ä¾ã¨ãã¦ãSampleActivity ã SampleViewModel ã«, SampleViewModel ã HogeRepository ã«ä¾åãã¦ããã¨ãã¾ãã
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!
ã¹ã¿ãã£ãµããªã§ã¯ãä¸çã®æã¦ã¾ã§æé«ã®å¦ã³ãå ±ã«å±ãã仲éãåéãã¦ãã¾ãã å°ãã§ãæ°ã«ãªã£ãæ¹ã¯ã«ã¸ã¥ã¢ã«é¢è«ããã£ã¦ãã¾ãã®ã§ãæ°è»½ã«ãåãåãããã ããï¼