13、RecyclerView与ListView测试:onData与onChildView使用、测试列表滚动、测试列表项点击、测试列表项数据验证、测试空列表状态
列表测试,说白了就是验证你的列表能不能正常工作。我见过太多开发把列表写好了,跑起来看着没问题,结果一写测试就翻车。嗯,今天咱们就把这块彻底讲透。
先看一张整体知识图,帮你建立全局认知:
13.1 ListView 测试:onData 与 onChildView
ListView 虽然老了,但很多遗留项目还在用。我个人习惯用 onData() 来匹配数据行,而不是直接找视图。为什么?因为 ListView 的 item 是复用的,直接找视图容易找到错的。
先看一个基础例子:
@Test
fun testListViewItemData() {
// 匹配第3行的数据
onData(allOf(
`is`(instanceOf(String::class.java)),
`is`("Item 2")
)).check(matches(withItemContent("Item 2")))
}
这里有个坑——onData() 默认只匹配屏幕上可见的行。如果你要测试第50行,得先滚动过去。我在项目中遇到过测试一直报错,查了半天才发现是行没滚到可见区域。
再说 onChildView()。当你拿到某一行后,想操作行里面的子控件,就得用它:
@Test
fun testListViewChildViewClick() {
onData(allOf(
`is`(instanceOf(String::class.java)),
`is`("Item 3")
)).onChildView(withId(R.id.item_button))
.perform(click())
// 验证点击后的效果
onView(withText("Button clicked")).check(matches(isDisplayed()))
}
你想想看,如果不用 onChildView(),直接 onView(withId(R.id.item_button)) 会怎样?它会找到屏幕上所有匹配的按钮,然后报 AmbiguousViewMatcherException。嗯,我当年就踩过这个坑。
13.2 RecyclerView 测试:更现代的方式
RecyclerView 的测试思路和 ListView 不太一样。它没有 onData(),取而代之的是 onView() 配合 atPosition()。说白了,就是通过位置来定位 item。
先加依赖:
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
然后写测试:
@Test
fun testRecyclerViewItemAtPosition() {
// 验证第5个item的文本
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(5))
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(5, click()))
// 验证点击后的状态
onView(withText("Item 5 clicked")).check(matches(isDisplayed()))
}
13.3 测试列表滚动
滚动测试分两种:一种是验证滚动本身是否正常,另一种是滚动到某个位置后验证数据。
先看滚动到末尾:
@Test
fun testScrollToEnd() {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToLastPosition<RecyclerView.ViewHolder>())
// 验证最后一项
onView(withText("Item 99")).check(matches(isDisplayed()))
}
再看逐项滚动:
@Test
fun testScrollAndCheckEachItem() {
for (i in 0 until 10) {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(i))
onView(withText("Item $i")).check(matches(isDisplayed()))
}
}
我个人习惯在测试滚动时加个 Thread.sleep(200),虽然不优雅,但能避免滚动动画没完成就检查视图。当然,用 IdlingResource 是更好的选择。
13.4 测试列表项点击
点击测试的核心就两个:点击后触发什么行为,以及行为是否正确。
@Test
fun testItemClickNavigatesToDetail() {
// 点击第3项
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(3, click()))
// 验证跳转到详情页
onView(withId(R.id.detail_title)).check(matches(withText("Detail of Item 3")))
}
还有一种情况是点击 item 里的子按钮:
@Test
fun testItemChildButtonClick() {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("Item 7")),
clickChildViewWithId(R.id.favorite_button)
))
// 验证收藏状态
onView(withText("Favorited")).check(matches(isDisplayed()))
}
13.5 测试列表项数据验证
数据验证是列表测试的重头戏。我一般会验证三样东西:文本内容、图片加载、以及 item 的可见性。
@Test
fun testItemDataValidation() {
// 验证文本
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(0))
onView(withText("Title: Android Testing"))
.check(matches(isDisplayed()))
// 验证图片(假设有占位图)
onView(withId(R.id.item_image))
.check(matches(hasDrawable()))
// 验证子文本
onView(withText("Author: John"))
.check(matches(isDisplayed()))
}
这里有个细节——hasDrawable() 是自定义 Matcher,Espresso 没有内置。你可以自己写一个:
fun hasDrawable(): Matcher<View> = object : TypeSafeMatcher<View>() {
override fun matchesSafely(view: View): Boolean {
return (view as ImageView).drawable != null
}
override fun describeTo(description: Description) {
description.appendText("has a drawable")
}
}
我曾经在项目里遇到图片加载失败但测试通过的情况,就是因为没写这个 Matcher。后来加上后,立马抓到了 Glide 配置错误的问题。
13.6 测试空列表状态
空列表状态是最容易被忽略的测试点。你想想看,用户第一次打开应用,列表是空的,这时候应该显示什么?
@Test
fun testEmptyListShowsEmptyView() {
// 假设 Adapter 数据为空
onView(withId(R.id.empty_view))
.check(matches(isDisplayed()))
onView(withId(R.id.empty_text))
.check(matches(withText("暂无数据")))
// 确保 RecyclerView 不可见
onView(withId(R.id.recycler_view))
.check(matches(not(isDisplayed())))
}
还有一种情况是列表从有数据变成空数据:
@Test
fun testListClearsShowsEmptyState() {
// 先确认有数据
onView(withText("Item 0")).check(matches(isDisplayed()))
// 执行清空操作
onView(withId(R.id.clear_button)).perform(click())
// 验证空状态
onView(withId(R.id.empty_view)).check(matches(isDisplayed()))
onView(withId(R.id.recycler_view)).check(matches(not(isDisplayed())))
}
13.7 实战:完整列表测试示例
最后,我给你一个完整的测试类模板,覆盖了列表测试的常见场景:
@RunWith(AndroidJUnit4::class)
class RecyclerViewTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun testListLoadsSuccessfully() {
// 验证列表加载了至少一项
onView(withId(R.id.recycler_view))
.check(matches(hasMinimumChildCount(1)))
}
@Test
fun testScrollToBottom() {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToLastPosition<RecyclerView.ViewHolder>())
onView(withText("Item 99")).check(matches(isDisplayed()))
}
@Test
fun testItemClick() {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(2, click()))
onView(withText("Clicked item 2")).check(matches(isDisplayed()))
}
@Test
fun testEmptyState() {
onView(withId(R.id.clear_button)).perform(click())
onView(withId(R.id.empty_view)).check(matches(isDisplayed()))
onView(withId(R.id.recycler_view)).check(matches(not(isDisplayed())))
}
}
这个模板我用了好几年,基本覆盖了日常开发中 90% 的列表测试场景。你拿去改改就能用。
好了,列表测试的核心内容就这些。记住一点:测试不是写给别人看的,是写给自己未来省时间的。把列表测稳了,后面重构、加功能都踏实。