16、IdlingResource与异步测试:从原理到实战

各位同学,今天我们来聊聊异步测试里最核心的一个话题——IdlingResource。说实话,我刚接触Android测试那会儿,最头疼的就是异步操作。网络请求、数据库读写、动画播放……这些玩意儿在测试里就像个“黑箱”,你永远不知道它什么时候结束。后来我明白了,不是测试框架不行,是我没用好IdlingResource。

16.1 为什么需要IdlingResource?

先问大家一个问题:Espresso是怎么判断UI是否空闲的?

默认情况下,Espresso会监控主线程的消息队列。当消息队列空了,它就认为“嗯,UI空闲了,可以执行下一个操作了”。但问题来了——网络请求是异步的,数据库操作也是异步的,它们不会往主线程消息队列里塞消息。Espresso根本不知道这些任务还在跑。

结果就是:你明明发了个网络请求,Espresso却以为已经结束了,直接去检查结果。这时候UI还没更新呢,测试当然会失败。

我在项目里就踩过这个坑。有一次写登录测试,点击登录按钮后直接检查TextView是否显示“登录成功”。结果呢?网络请求还没回来,测试就断言失败了。后来排查了半天,才发现是异步任务没等完。

核心结论:IdlingResource就是告诉Espresso——“嘿,别急,我这还有任务在跑,等完了再检查UI。”

16.2 IdlingResource的工作原理

说白了,IdlingResource就是一个“状态监听器”。它有三个核心方法:

  • getName():返回资源名称,方便调试
  • isIdleNow():当前是否空闲。如果返回true,Espresso就认为可以继续
  • registerIdleTransitionCallback():当状态从“忙碌”变为“空闲”时,通知Espresso

Espresso会定期轮询所有注册的IdlingResource。只要有一个返回false,它就等着。直到全部返回true,才执行下一个测试步骤。

我画了个流程图,方便大家理解:

测试步骤执行 轮询所有IdlingResource 全部空闲? 执行下一步 等待并重试 循环直到超时或空闲

16.3 注册与取消IdlingResource

使用IdlingResource之前,必须先注册到Espresso的框架里。我习惯在@Before方法里注册,在@After里取消。这样每个测试用例都是干净的,不会互相影响。

class LoginViewModelTest {

    private lateinit var idlingResource: CountingIdlingResource

    @Before
    fun setUp() {
        idlingResource = CountingIdlingResource("network_call")
        Espresso.registerIdlingResources(idlingResource)
    }

    @After
    fun tearDown() {
        Espresso.unregisterIdlingResources(idlingResource)
    }

    @Test
    fun testLogin() {
        // 测试逻辑
    }
}
小技巧:我建议在Application或测试基类里统一管理IdlingResource。比如写一个IdlingResourceManager单例,方便在多个测试类间共享。但要注意,千万别忘了取消注册,否则会内存泄漏。

16.4 自定义IdlingResource

Espresso自带的CountingIdlingResource已经能满足大部分场景了。但有些特殊需求,比如等待某个自定义View加载完成,就得自己动手写。

我记得有一次,项目里用了一个第三方图表库,加载数据需要时间。Espresso不知道它在忙,测试总是失败。后来我写了个自定义IdlingResource,监听图表库的内部状态。

class ChartIdlingResource(private val chartView: ChartView) : IdlingResource {

    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName(): String = "ChartIdlingResource"

    override fun isIdleNow(): Boolean {
        val idle = chartView.isDataLoaded()
        if (idle) {
            callback?.onTransitionToIdle()
        }
        return idle
    }

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
        this.callback = callback
    }
}

这里有个关键点:isIdleNow()返回true时,一定要调用callback.onTransitionToIdle()。否则Espresso可能收不到通知,一直卡在那。

注意:自定义IdlingResource里不要做耗时操作。Espresso是在主线程轮询的,你如果在里面做网络请求,那就把主线程卡死了。我曾经见过有人把数据库查询写在isIdleNow()里,结果测试跑得比蜗牛还慢。

16.5 测试网络请求与数据库操作

网络请求和数据库操作是异步测试的重灾区。我给大家总结一下常见的做法:

场景 推荐方案 注意事项
Retrofit网络请求 使用CountingIdlingResource,在请求开始前increment,结束后decrement 注意回调线程,确保decrement在正确的地方调用
Room数据库操作 同样用CountingIdlingResource,包裹LiveData或Flow的观察 Room的异步查询默认在后台线程,需要等数据回调到主线程
协程+网络请求 可以用runBlockingTest,或者自定义IdlingResource监听协程状态 协程的调度器要替换为TestDispatcher

拿Retrofit举例,我一般会写一个拦截器,自动管理IdlingResource的状态:

class IdlingResourceInterceptor(
    private val idlingResource: CountingIdlingResource
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        idlingResource.increment()
        return try {
            chain.proceed(chain.request())
        } finally {
            idlingResource.decrement()
        }
    }
}

这样每个网络请求都会自动被IdlingResource监控,测试代码里不用手动管理。省心不少。

16.6 测试动画与延迟

动画和延迟是另一个坑。Espresso默认会等待动画结束,但有些动画是无限循环的,或者延迟时间太长。这时候就需要特殊处理。

我的做法是:在测试环境里禁用动画。在@Before里设置:

@Before
fun disableAnimations() {
    val animatorDurationScale = Settings.Global.getFloat(
        context.contentResolver,
        Settings.Global.ANIMATOR_DURATION_SCALE,
        1f
    )
    // 设置为0,禁用动画
    Settings.Global.putFloat(
        context.contentResolver,
        Settings.Global.ANIMATOR_DURATION_SCALE,
        0f
    )
}

但注意,这个操作需要系统权限。在模拟器上没问题,真机可能不行。另一种方案是用Espresso的IdlingPolicies

@Before
fun setUpIdlingPolicies() {
    IdlingPolicies.setMasterPolicyTimeout(10, TimeUnit.SECONDS)
    IdlingPolicies.setIdlingResourceTimeout(5, TimeUnit.SECONDS)
}

这样即使动画或延迟稍微超时,测试也不会立刻失败。但别设太大,否则测试跑起来太慢。

避坑指南:我曾经遇到过一个bug——某个动画是无限循环的,Espresso永远等不到它结束。后来排查发现,是动画里用了repeatCount = infinite。解决方案是在测试环境里把动画替换成一次性动画,或者干脆禁用。

16.7 实战:组合使用多个IdlingResource

实际项目中,往往同时有网络请求、数据库操作和动画。这时候需要注册多个IdlingResource。Espresso会等所有资源都空闲了才继续。

我习惯写一个工具类来管理:

object TestIdlingResourceManager {

    private val networkResource = CountingIdlingResource("network")
    private val dbResource = CountingIdlingResource("database")
    private val animResource = CountingIdlingResource("animation")

    fun registerAll() {
        Espresso.registerIdlingResources(networkResource, dbResource, animResource)
    }

    fun unregisterAll() {
        Espresso.unregisterIdlingResources(networkResource, dbResource, animResource)
    }

    fun getNetworkResource() = networkResource
    fun getDbResource() = dbResource
    fun getAnimResource() = animResource
}

然后在测试基类里统一注册和取消。每个测试用例只管自己的业务逻辑,不用操心IdlingResource的管理。

好了,关于IdlingResource的内容就讲到这里。异步测试其实没那么可怕,关键是把“等待”这件事交给框架,而不是自己写Thread.sleep。相信我,用好了IdlingResource,你的测试会稳定很多。


公众号:蓝海资料掘金营,微信deep3321