13、RecyclerView与ListView测试:onData与onChildView使用、测试列表滚动、测试列表项点击、测试列表项数据验证、测试空列表状态

列表测试,说白了就是验证你的列表能不能正常工作。我见过太多开发把列表写好了,跑起来看着没问题,结果一写测试就翻车。嗯,今天咱们就把这块彻底讲透。

先看一张整体知识图,帮你建立全局认知:

RecyclerView / ListView 测试知识体系 列表测试核心 ListView 测试 onData() 匹配数据行 onChildView() 获取子视图 数据验证 + 点击测试 RecyclerView 测试 onView() + atPosition() scrollTo() 滚动测试 空列表 + 边界测试

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行,得先滚动过去。我在项目中遇到过测试一直报错,查了半天才发现是行没滚到可见区域。

注意: onData() 匹配的是 Adapter 中的数据对象,不是视图。如果你的 Adapter 数据是自定义类型,记得实现合理的 equals() 方法。

再说 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()))
}
小技巧: scrollToPosition() 只是滚动到那个位置,但不保证 item 完全可见。如果你要确保 item 可见,可以用 scrollTo() 配合 Matcher。

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()))
}
核心要点: actionOnItem() 和 actionOnItemAtPosition() 的区别在于,前者用 Matcher 匹配数据,后者用位置索引。数据驱动测试时用前者,位置固定时用后者。

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())))
}
注意: 空列表测试一定要在 UI 线程更新数据后等待布局刷新。建议用 onView().check() 的轮询机制,不要用 Thread.sleep() 硬等。

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% 的列表测试场景。你拿去改改就能用。

好了,列表测试的核心内容就这些。记住一点:测试不是写给别人看的,是写给自己未来省时间的。把列表测稳了,后面重构、加功能都踏实。