17、Barrier与等待策略:Espresso的等待机制、自定义等待条件、测试异步回调、测试Handler与Looper、测试WorkManager

说到Android测试,异步操作绝对是绕不开的坎儿。我刚开始写UI测试那会儿,最头疼的就是「明明代码逻辑没问题,测试却老是随机挂」。后来才明白,问题出在「等待」上——你测试的是UI,但背后跑的是异步任务,两者节奏对不上,测试自然就崩了。

这一章,我们就来聊聊Espresso的等待机制,以及怎么搞定那些让人抓狂的异步场景。

Espresso的等待机制:它到底在等什么?

Espresso有个很聪明的设计——它不会傻等固定时间,而是主动去检查条件是否满足。说白了,它内部维护了一个「Idling Resource」机制,只有当所有异步任务都完成、UI线程空闲时,才会执行下一个操作。

这个机制的核心逻辑,我画了张图帮你理解:

Espresso 等待机制流程图 测试操作(点击/输入) 检查所有 Idling Resource 空闲 → 执行操作 忙碌 → 等待并重试 超时 → 抛出异常

你看,Espresso不是傻等,而是「轮询+条件检查」。默认的超时时间是30秒,如果30秒内条件一直不满足,它就会抛出异常。这个机制的好处是——测试跑得快,不会浪费无谓的等待时间。

核心要点:Espresso的等待机制基于Idling Resource,不是固定延时。你不需要手动加Thread.sleep(),那是最糟糕的做法。

自定义等待条件:什么时候需要自己写?

Espresso内置的等待机制能覆盖大部分场景,但总有例外。我记得有一次测试一个第三方SDK的登录流程,它内部用了一个自定义的线程池,Espresso根本感知不到。结果就是——测试总是先于登录完成执行,然后断言失败。

这时候就需要自定义Idling Resource了。说白了,就是告诉Espresso:「嘿,你等等,我这边还有活儿没干完」。

实现起来其实不复杂,核心就是实现IdlingResource接口:

class LoginIdlingResource : IdlingResource {
    private var isIdle = true
    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName() = "LoginIdlingResource"

    override fun isIdleNow() = isIdle

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

    fun setBusy() {
        isIdle = false
    }

    fun setIdle() {
        isIdle = true
        callback?.onTransitionToIdle()
    }
}

然后在测试里注册它:

@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java)

private val loginIdlingResource = LoginIdlingResource()

@Before
fun setUp() {
    IdlingRegistry.getInstance().register(loginIdlingResource)
}

@After
fun tearDown() {
    IdlingRegistry.getInstance().unregister(loginIdlingResource)
}

@Test
fun testLogin() {
    loginIdlingResource.setBusy()
    onView(withId(R.id.login_button)).perform(click())
    // 模拟登录完成
    loginIdlingResource.setIdle()
    onView(withText("登录成功")).check(matches(isDisplayed()))
}

我的经验:自定义IdlingResource的关键是「谁触发setIdle()」。我习惯在异步回调的末尾调用它,这样能保证时机准确。千万别在测试线程里直接调,那会破坏异步的语义。

测试异步回调:Callback的等待艺术

异步回调是Android开发的家常便饭。网络请求、数据库查询、文件读写……到处都是回调。测试这些代码,最怕的就是「回调还没回来,断言已经执行了」。

我常用的方案有两种:

  1. CountDownLatch——简单粗暴,适合单次回调
  2. IdlingResource——更优雅,适合多次回调或复杂场景

先看CountDownLatch的用法:

@Test
fun testAsyncCallback() {
    val latch = CountDownLatch(1)
    var result: String? = null

    // 模拟异步操作
    Thread {
        Thread.sleep(1000)
        result = "Hello"
        latch.countDown()
    }.start()

    // 等待回调完成
    assertTrue(latch.await(2, TimeUnit.SECONDS))
    assertEquals("Hello", result)
}

这个方案的好处是简单,但有个坑——如果回调永远不触发,测试就会挂住。所以我习惯加上超时时间,并且用assertTrue来断言等待结果。

再说IdlingResource的方案。如果你有多个异步操作,或者同一个操作会触发多次回调,CountDownLatch就不太够用了。这时候IdlingResource更合适:

class CallbackIdlingResource(private val expectedCallbacks: Int) : IdlingResource {
    private var completedCallbacks = 0
    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName() = "CallbackIdlingResource"

    override fun isIdleNow() = completedCallbacks >= expectedCallbacks

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

    fun onCallbackCompleted() {
        completedCallbacks++
        if (isIdleNow()) {
            callback?.onTransitionToIdle()
        }
    }
}

注意:CountDownLatch的await()方法会阻塞当前线程。如果你在Android主线程上调用它,就会导致ANR。所以一定要确保它在测试线程(非主线程)上执行。

测试Handler与Looper:主线程的消息队列

Handler和Looper是Android消息机制的核心。测试它们,说白了就是测试「消息什么时候被处理」。Espresso默认会等待主线程的消息队列空闲,所以大部分Handler测试其实不需要额外处理。

但有一种情况例外——你用了Handler.postDelayed()。比如:

class DelayedTaskHandler(private val handler: Handler = Handler(Looper.getMainLooper())) {
    fun postDelayedTask(delay: Long, callback: () -> Unit) {
        handler.postDelayed(callback, delay)
    }
}

测试这种代码,Espresso的默认等待机制就不够用了。因为消息队列虽然空闲了,但延迟消息还没到执行时间。我一般用两种方式解决:

  1. 注入一个假的Handler,让它立即执行
  2. 使用IdlingResource,手动控制等待

先看注入方案:

@Test
fun testDelayedTask() {
    val mockHandler = mock(Handler::class.java)
    val taskHandler = DelayedTaskHandler(mockHandler)
    var executed = false

    taskHandler.postDelayedTask(1000) { executed = true }

    // 验证postDelayed被调用
    verify(mockHandler).postDelayed(any(), eq(1000L))
}

这个方案的好处是快,不依赖真实时间。但如果你需要测试真实的延迟行为,那就得用IdlingResource了:

class DelayedTaskIdlingResource(
    private val handler: Handler,
    private val delay: Long
) : IdlingResource {
    private var isIdle = false
    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName() = "DelayedTaskIdlingResource"

    override fun isIdleNow() = isIdle

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

    fun start() {
        handler.postDelayed({
            isIdle = true
            callback?.onTransitionToIdle()
        }, delay)
    }
}

我的习惯:测试Handler时,我倾向于用注入方案。因为真实延迟会让测试变慢,而且不稳定。你想想看,一个测试等1秒,100个测试就是100秒,谁受得了?

测试WorkManager:后台任务的测试策略

WorkManager是Android官方推荐的后台任务库。测试它,核心是「验证任务是否按预期执行」。WorkManager本身提供了很好的测试支持,但很多人不知道怎么用。

先看依赖配置:

androidTestImplementation "androidx.work:work-testing:2.9.0"

然后初始化测试环境:

@RunWith(AndroidJUnit4::class)
class UploadWorkerTest {
    private lateinit var context: Context
    private lateinit var testDriver: TestListenableWorkerBuilder

    @Before
    fun setUp() {
        context = ApplicationProvider.getApplicationContext()
        testDriver = TestListenableWorkerBuilder<UploadWorker>(context).build()
    }

    @Test
    fun testUploadWorker() {
        // 执行Worker
        val result = testDriver.startWork().get()

        // 验证结果
        assertTrue(result is ListenableWorker.Result.success)
    }
}

WorkManager的测试有几个关键点:

场景 测试方法 注意事项
一次性任务 TestListenableWorkerBuilder 直接调用startWork()
周期性任务 TestDriver + 模拟约束 需要手动触发执行
链式任务 WorkManagerTestInitHelper 需要模拟整个WorkManager
带输入/输出的任务 设置inputData / 验证outputData Data对象不可变

我曾经踩过一个坑——测试WorkManager时,忘了初始化WorkManagerTestInitHelper。结果测试一直报错,说WorkManager没有初始化。后来才发现,测试环境需要手动初始化:

@Before
fun setUp() {
    context = ApplicationProvider.getApplicationContext()
    val config = Configuration.Builder()
        .setMinimumLoggingLevel(Log.DEBUG)
        .build()
    WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
}

避坑指南:WorkManager的测试环境是单例的。如果你在多个测试类里初始化,会抛出异常。我习惯在测试基类里统一初始化,或者用@ClassRule来管理。

测试带约束的Worker时,需要模拟约束条件:

@Test
fun testWorkerWithConstraints() {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()

    val request = OneTimeWorkRequestBuilder<UploadWorker>()
        .setConstraints(constraints)
        .build()

    val testDriver = TestListenableWorkerBuilder<UploadWorker>(context)
        .setWorkerParameters(request.workSpec)
        .build()

    // 模拟网络可用
    testDriver.setNetworkConnected(true)

    val result = testDriver.startWork().get()
    assertTrue(result is ListenableWorker.Result.success)
}

嗯,这里要注意——setNetworkConnected()是测试库提供的方法,它不会真的去检查网络状态,而是直接告诉Worker「网络可用」。这样测试就不依赖真实网络了,跑起来又快又稳。

总结一下:测试异步代码,核心就两件事——「知道什么时候该等」和「知道怎么等」。Espresso的Idling Resource机制是基础,自定义Idling Resource是进阶,CountDownLatch是补充。WorkManager的测试工具很完善,关键是初始化要正确。

我个人觉得,测试异步代码最忌讳的就是「猜时间」。不要写Thread.sleep(2000),不要写SystemClock.sleep(1000)。这些硬编码的等待时间,换个设备、换个网络环境就挂了。用Idling Resource,用CountDownLatch,用WorkManager的测试工具——这些才是正经的解决方案。

好了,这一章的内容就到这里。记住,测试异步代码不是玄学,是有章可循的工程实践。你只要掌握了这些等待策略,再复杂的异步场景也能稳稳拿下。

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