如何使用Espresso为代码创建的自定义视图编写UI测试并等待渲染
嘿,刚好碰到过类似的场景,给你分享几个纯Espresso的解决方案,完全不用依赖Robolectric:
1. 先搞定代码生成自定义视图的定位问题
Espresso默认对XML生成的视图支持很好,但动态生成的自定义视图经常因为没有明确的标识导致定位失败。你可以这么做:
- 给自定义视图设置唯一标识:生成视图时,用
setTag()或者setContentDescription()给它加个独有的标记,比如:// 自定义视图生成代码里 val placeCard = PlaceInfoView(context) placeCard.setTag(R.id.place_card_tag, "unique_restaurant_card_${place.id}") - 在测试里用标记定位:结合
withTagValue()或者withContentDescription()来精准找到视图:onView(allOf( withTagValue(equalTo("unique_restaurant_card_123")), isDisplayed() )).check(matches(isDisplayed())) - 还可以通过子控件反向定位:如果自定义视图里有带文本的子控件,用
hasDescendant()来定位父视图,比如:onView(allOf( instanceOf(PlaceInfoView::class.java), hasDescendant(withText("XX火锅店")), isDisplayed() ))
2. 等待视图渲染完成的两种方案
针对Mapbox地图加载(约2秒)的场景,Espresso有两种靠谱的等待方式:
推荐:用IdlingResource监听异步加载完成
这是Espresso官方推荐的异步等待方案,不会像固定延迟那样导致测试不稳定。比如监听Mapbox地图加载完成:
- 实现一个Mapbox专属的IdlingResource:
class MapboxLoadIdlingResource(private val mapView: MapView) : IdlingResource { private var callback: ResourceCallback? = null private var isMapLoaded = false init { mapView.getMapAsync { mapboxMap -> mapboxMap.addOnMapLoadedCallback { isMapLoaded = true callback?.onTransitionToIdle() } } } override fun getName() = "MapboxLoadIdlingResource" override fun isIdleNow() = isMapLoaded override fun registerIdleTransitionCallback(callback: ResourceCallback?) { this.callback = callback } } - 在测试的
@Before和@After里注册/注销:@get:Rule val activityRule = ActivityScenarioRule(MainMapActivity::class.java) @Before fun setUp() { activityRule.scenario.onActivity { activity -> val mapView = activity.findViewById<MapView>(R.id.main_map_view) IdlingRegistry.getInstance().register(MapboxLoadIdlingResource(mapView)) } } @After fun tearDown() { IdlingRegistry.getInstance().unregisterAll() }
这样Espresso会自动等待地图加载完成,再执行后续的匹配操作。如果是自定义视图自己的渲染逻辑,你可以给视图加个onRenderComplete回调,然后在回调里标记IdlingResource为空闲状态。
应急:固定延迟等待(不推荐,但好用)
如果暂时没时间写IdlingResource,也可以用Espresso的主线程循环来延迟,比Thread.sleep()更稳定:
// 让主线程循环3秒,保证视图有足够时间渲染 onView(withId(R.id.main_map_view)).perform(object : ViewAction { override fun getConstraints() = isDisplayed() override fun getDescription() = "Wait for 3 seconds to load place views" override fun perform(uiController: UiController, view: View) { uiController.loopMainThreadForAtLeast(3000) } })
不过这种方式尽量少用,因为不同设备的加载速度不一样,容易导致测试在某些设备上失败。
3. 完整测试示例
假设你要测试搜索餐厅后,自定义的PlaceInfoView是否正确显示:
@Test fun testRestaurantPlaceViewIsDisplayedAndInteractive() { // 1. 触发搜索操作 onView(withId(R.id.search_bar)).perform(typeText("火锅店"), pressImeActionButton()) // 2. Espresso会自动等待Mapbox加载完成(通过IdlingResource) // 3. 定位自定义视图并验证 onView(allOf( instanceOf(PlaceInfoView::class.java), hasDescendant(withText("XX老火锅")), isDisplayed() )).check(matches(isDisplayed())) // 4. 测试视图交互,比如点击预约按钮 onView(allOf( withId(R.id.book_table_btn), isDescendantOfA(instanceOf(PlaceInfoView::class.java)) )).perform(click()) // 5. 验证交互结果,比如跳转预约页面 onView(withId(R.id.book_table_activity_layout)).check(matches(isDisplayed())) }
4. 常见坑点排查
- 确认视图已经加入到视图层级:可以用
adb shell dumpsys activity your.app.package查看当前视图树,确保自定义视图存在。 - 如果视图在滚动容器里:需要先用
scrollTo()操作把视图滚到可见区域,比如perform(scrollTo())。 - 自定义视图的异步逻辑:尽量把耗时的渲染逻辑抽成可监听的回调,方便用IdlingResource等待,避免直接在构造函数里做耗时操作。
内容的提问来源于stack exchange,提问作者rozeri dilar
相关产品推荐
相关产品推荐

