如何使用Espresso测试RecyclerView的SelectionTracker选择功能
我需要通过Espresso选中配置了SelectionTracker的RecyclerView列表项,同时支持在测试中执行取消选中操作。SelectionTracker初始化代码如下:
val selectionTracker = SelectionTracker.Builder( selectionID, recyclerView, stringAdapter.itemKeyProvider, createItemDetailsLookup(recyclerView), StorageStrategy.createStringStorage() )
debug版本手动测试确认SelectionTracker本身功能正常,不想每次代码变更后重复手动回归测试。
方案1:直接对列表项执行longClick
最初尝试通过Espresso调用longClick选中列表项,测试代码如下:
val toolbarId = withId(R.id.toolbar_activity_main) val toolbar = onView(toolbarId) toolbar.check(matches(isCompletelyDisplayed())) toolbar.check( matches( withInvisibleIcon(R.id.action_delete_toolbar_activity_main), ) ) val recyclerViewWithId = withId(R.id.fragment_editor_recycler_view) val recyclerView = onView(recyclerViewWithId) recyclerView.check(matches(isDisplayed())) val itemView = onView( allOf( withParent(recyclerViewWithId), withChild( allOf( withText("AA"), withId(R.id.view_holder_item_text_view) ) ), withId(R.id.view_holder_item), ) ) itemView.check(matches(withEffectiveVisibility(Visibility.VISIBLE))) itemView.perform(longClick()) toolbar.check( matches( withVisibleIcon(R.id.action_delete_toolbar_activity_main), ) )
配套自定义Matcher实现:
private fun withInvisibleIcon(@IdRes int: Int): Matcher<View> { return object : TypeSafeMatcher<View>() { override fun describeTo(description: Description) { description.appendText("with invisible icon id: $int") } public override fun matchesSafely(view: View): Boolean { if (view !is MaterialToolbar) { return false } val item = view.menu.findItem(int) return (item.itemId == int) and (!item.isVisible) } } } private fun withVisibleIcon(@IdRes int: Int): Matcher<View> { return object : TypeSafeMatcher<View>() { override fun describeTo(description: Description) { description.appendText("with visible icon id: $int") } public override fun matchesSafely(view: View): Boolean { if (view !is MaterialToolbar) { return false } val item = view.menu.findItem(int) return (item.itemId == int) and (item.isVisible) } } }
测试断点暂停时手动长按列表项,id为action_delete_toolbar_activity_main的删除图标会正常显示,手动操作应用时该图标也能正常呼出,但执行上述测试代码会抛出断言失败异常:
androidx.test.espresso.base.DefaultFailureHandler$AssertionFailedWithCauseError: 'with visible icon id: 2131230778' doesn't match the selected view. ...
方案2:通过RecyclerViewActions执行长按
之后尝试使用RecyclerViewAction对指定位置项执行长按:
recyclerView.perform(RecyclerViewActions.actionOnItemAtPosition<StringAdapter.ViewHolder>( 0, longClick() ))
仍然无法触发选中。
方案3:自定义超长时长长按
将上述两种方案的长按替换为自定义实现,长按时长设为默认longClick的6又2/3倍,自定义Tap代码:
enum class Tap : Tapper { LONGER_CLICK { override fun sendTap( uiController: UiController?, coordinates: FloatArray?, precision: FloatArray?, inputDevice: Int, buttonState: Int ): Tapper.Status { var downEvent = MotionEvents.sendDown( uiController, coordinates, precision, inputDevice, buttonState ).down try { val longPressTimeout = (ViewConfiguration.getLongPressTimeout() * 10f).toLong() uiController!!.loopMainThreadForAtLeast(longPressTimeout) if (!MotionEvents.sendUp(uiController, downEvent)) { MotionEvents.sendCancel(uiController, downEvent) return Tapper.Status.FAILURE } } finally { downEvent!!.recycle() downEvent = null } return Tapper.Status.SUCCESS } @Deprecated("Deprecated in Java") override fun sendTap( uiController: UiController?, coordinates: FloatArray?, precision: FloatArray? ): Tapper.Status { return sendTap( uiController, coordinates, precision, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY ) } } }
调用代码:
recyclerView.perform( RecyclerViewActions.actionOnItemAtPosition<StringAdapter.ViewHolder>( 0, GeneralClickAction( Tap.LONGER_CLICK, GeneralLocation.VISIBLE_CENTER, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY ) ) )
调试SelectionTracker源码确认只要点击坐标正确就会触发选中,该方案依然无效,最初怀疑是框架Bug。
方案4:手动获取坐标直接注入点击事件
尝试直接通过Activity获取列表项屏幕坐标后注入长按事件:
val location = IntArray(2){0} val cdl = CountDownLatch(1) scenario.onActivity { val recyclerView = it.findViewById<RecyclerView>(R.id.fragment_editor_recycler_view) val ite = recyclerView.getChildAt(0) ite.getLocationOnScreen(location) cdl.countDown() } cdl.await() val recyclerViewWithId = withId(R.id.fragment_editor_recycler_view) onView(recyclerViewWithId).check(matches(isDisplayed())) onView(withId(R.id.activity_main)).perform( GeneralClickAction( Tap.LONG, { FloatArray(location.size){ i -> (location[i]+1).toFloat() } }, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY ) )
后续改为从根视图注入事件:
val cdl = CountDownLatch(1) scenario.onActivity { val recyclerView = it.findViewById<RecyclerView>(R.id.fragment_editor_recycler_view) val ite = recyclerView.getChildAt(0) ite.getLocationOnScreen(location) cdl.countDown() } cdl.await() onView(isRoot()).perform( GeneralClickAction( Tap.LONG, { FloatArray(location.size){ i -> (location[i]+1).toFloat() } }, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY ) )
已经确认SelectionTracker正确绑定到Fragment中的RecyclerView,以上方案全部无法触发选中逻辑。
这不是框架Bug,问题出在SelectionTracker的触摸事件监听逻辑和Espresso默认注入事件的源类型不匹配:SelectionTracker内部的OnItemTouchListener会校验输入事件的source类型,Espresso默认使用InputDevice.SOURCE_UNKNOWN注入事件,会被SelectionTracker直接过滤,不会进入选中判定逻辑。
正确的测试实现要点:
- 自定义点击/长按Action时,将inputDevice参数设置为
InputDevice.SOURCE_TOUCHSCREEN,不要使用SOURCE_UNKNOWN - 不需要自定义超长点击时长,使用系统默认的长按超时阈值即可,自定义长按时长只要保持和系统
ViewConfiguration.getLongPressTimeout()*1.5的安全系数一致就足够 - 选中后验证状态、执行取消选中的操作,直接对已选中的项执行普通点击即可,不需要再次长按
可直接复用的选中/取消选中Action实现:
fun selectItemInSelectionTracker(position: Int): ViewAction { return RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( position, GeneralClickAction( Tap.LONG, GeneralLocation.VISIBLE_CENTER, Press.FINGER, InputDevice.SOURCE_TOUCHSCREEN, MotionEvent.BUTTON_PRIMARY ) ) } fun deselectItemInSelectionTracker(position: Int): ViewAction { return RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>( position, GeneralClickAction( Tap.SINGLE, GeneralLocation.VISIBLE_CENTER, Press.FINGER, InputDevice.SOURCE_TOUCHSCREEN, MotionEvent.BUTTON_PRIMARY ) ) }
测试代码中直接调用即可:
// 选中第0项 onView(withId(R.id.fragment_editor_recycler_view)).perform(selectItemInSelectionTracker(0)) // 验证删除图标显示 onView(withId(R.id.toolbar_activity_main)).check(matches(withVisibleIcon(R.id.action_delete_toolbar_activity_main))) // 取消选中第0项 onView(withId(R.id.fragment_editor_recycler_view)).perform(deselectItemInSelectionTracker(0)) // 验证删除图标隐藏 onView(withId(R.id.toolbar_activity_main)).check(matches(withInvisibleIcon(R.id.action_delete_toolbar_activity_main)))
注意:如果你的
ItemDetailsLookup实现中对触摸事件做了额外的坐标校验,确保GeneralLocation.VISIBLE_CENTER取到的坐标落在列表项可点击的有效区域内即可,大部分场景下不需要额外调整坐标。
内容的提问来源于stack exchange,提问作者John Glen

