UI测试:用Compose或Espresso测试网络请求触发的UI变化

UI测试这件事,说实话,很多团队都做得不够好。我见过不少项目,网络请求的逻辑写得很漂亮,但一到UI测试就抓瞎——要么测不到真实状态,要么测试跑起来比乌龟还慢。今天我们就来聊聊,怎么用Compose Test或者Espresso,把网络请求触发的UI变化测明白。

为什么UI测试网络请求这么难?

你想想看,网络请求是异步的。你点了个按钮,发了个请求,UI要等数据回来才能更新。但测试框架是同步执行的,它不会等你。这就导致了一个经典问题:

测试代码跑完了,UI还没更新。

我在项目中遇到过好几次这种情况。测试报告全是绿的,但手动一测就崩。后来我才发现,测试根本没等到网络请求完成。

核心痛点:异步操作导致测试与UI状态不同步。测试需要一种机制,告诉它「嘿,等网络请求完再检查UI」。

解决方案:IdlingResource

IdlingResource是Google官方提供的同步机制。说白了,它就是一个「忙闲指示器」。测试框架通过它来判断当前有没有异步任务在执行。

它的工作流程是这样的:

// 核心接口定义
interface IdlingResource {
    fun getName(): String
    fun isIdleNow(): Boolean
    fun registerIdleTransitionCallback(callback: Callback)
}

isIdleNow()返回false时,测试框架会等待。直到它返回true,测试才会继续往下走。

我的习惯:我会为每个网络请求模块创建一个专用的IdlingResource。这样测试时只等待真正需要的请求,不会因为其他无关请求拖慢测试速度。

实战:用Espresso测试网络请求

我们先看一个最经典的场景:点击按钮,发起网络请求,更新TextView显示结果。

假设我们有这样一个ViewModel:

class MainViewModel(
    private val repository: UserRepository
) : ViewModel() {

    private val _userName = MutableLiveData<String>()
    val userName: LiveData<String> = _userName

    fun loadUser() {
        viewModelScope.launch {
            val user = repository.fetchUser()
            _userName.value = user.name
        }
    }
}

对应的Activity很简单:

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.btn_load).setOnClickListener {
            viewModel.loadUser()
        }

        viewModel.userName.observe(this) { name ->
            findViewById<TextView>(R.id.tv_name).text = name
        }
    }
}

现在我们来写测试。关键一步是注册IdlingResource:

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

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

    private lateinit var idlingResource: IdlingResource

    @Before
    fun setUp() {
        // 模拟网络请求的IdlingResource
        idlingResource = CountingIdlingResource("NetworkCall")
        IdlingRegistry.getInstance().register(idlingResource)
    }

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

    @Test
    fun testLoadUser_updatesTextView() {
        // 模拟网络请求开始
        idlingResource.increment()

        // 点击按钮触发请求
        onView(withId(R.id.btn_load)).perform(click())

        // 模拟网络请求完成
        idlingResource.decrement()

        // 验证UI更新
        onView(withId(R.id.tv_name))
            .check(matches(withText("John Doe")))
    }
}

我曾经踩过的坑:忘记在@After中注销IdlingResource。结果导致后续测试全部卡死。记住,每个测试用例结束后一定要清理资源。

Compose测试中的IdlingResource

如果你用的是Compose,思路是一样的。只是API换成了Compose Test的composeTestRule

看个例子:

@RunWith(AndroidJUnit4::class)
class MainScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private lateinit var idlingResource: CountingIdlingResource

    @Before
    fun setUp() {
        idlingResource = CountingIdlingResource("ComposeNetwork")
        IdlingRegistry.getInstance().register(idlingResource)
    }

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

    @Test
    fun testLoadUser_showsName() {
        composeTestRule.setContent {
            MainScreen(viewModel = fakeViewModel())
        }

        // 模拟网络请求开始
        idlingResource.increment()

        // 点击加载按钮
        composeTestRule.onNodeWithText("加载用户").performClick()

        // 模拟网络请求完成
        idlingResource.decrement()

        // 验证UI
        composeTestRule.onNodeWithText("John Doe").assertExists()
    }
}

嗯,这里要注意一点。Compose的performClick()是异步的,它内部会等待Compose的渲染完成。但网络请求的等待,还是得靠IdlingResource自己来管。

更优雅的方式:封装IdlingResource

每次手动increment()decrement()太容易出错了。我建议封装一个工具类:

class NetworkIdlingResource : IdlingResource {

    private val counter = AtomicInteger(0)
    private var callback: IdlingResource.ResourceCallback? = null

    override fun getName(): String = "NetworkIdlingResource"

    override fun isIdleNow(): Boolean = counter.get() == 0

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

    fun onRequestStart() {
        counter.incrementAndGet()
    }

    fun onRequestEnd() {
        if (counter.decrementAndGet() == 0) {
            callback?.onTransitionToIdle()
        }
    }
}

然后在测试中这样用:

class MainActivityTest {

    private val networkIdlingResource = NetworkIdlingResource()

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

    @Test
    fun testLoadUser() {
        networkIdlingResource.onRequestStart()

        onView(withId(R.id.btn_load)).perform(click())

        // 模拟网络响应
        networkIdlingResource.onRequestEnd()

        onView(withId(R.id.tv_name)).check(matches(withText("John Doe")))
    }
}

我的建议:把IdlingResource的注册和注销放到测试基类中。这样每个测试用例都不用重复写@Before@After,代码干净很多。

测试网络错误状态

UI测试不仅要测成功,还要测失败。比如网络超时、服务器返回500等。这些场景怎么测?

我的做法是:用MockWebServer模拟不同的响应。

@Test
fun testNetworkError_showsErrorMessage() {
    // 模拟网络错误
    mockWebServer.enqueue(MockResponse().setResponseCode(500))

    networkIdlingResource.onRequestStart()

    onView(withId(R.id.btn_load)).perform(click())

    networkIdlingResource.onRequestEnd()

    // 验证错误提示
    onView(withId(R.id.tv_error))
        .check(matches(withText("网络异常,请稍后重试")))
}

这里有个细节:MockWebServer的响应是同步的,但你的网络请求库(比如Retrofit)是异步的。所以IdlingResource依然需要。你想想看,如果不用IdlingResource,测试代码可能还没等到网络库处理完响应,就已经去检查UI了。

SVG流程图:UI测试网络请求的核心流程

UI测试网络请求核心流程 测试用例开始 注册IdlingResource 触发网络请求(点击按钮) 测试框架等待Idle 验证UI状态 关键点 • isIdleNow()返回false时等待 • 网络请求完成后回调 • 测试框架自动恢复执行 • 确保UI已更新再断言

常见问题与避坑指南

我总结几个实际项目中容易踩的坑:

  • IdlingResource注册时机不对:应该在测试用例执行前注册,执行后注销。放在@Before@After是最稳妥的。
  • 多个IdlingResource互相干扰:每个IdlingResource要有唯一的name。我习惯用类名+方法名来命名。
  • 忘记处理超时:如果网络请求永远不会完成(比如Mock没配好),测试会一直卡住。建议给IdlingResource加超时机制。
  • Compose的 recomposition 还没完成:Compose的UI更新是异步的。即使IdlingResource返回了idle,Compose可能还在重组。我一般会在断言前加一个waitForIdle()

我曾经踩过的坑:有一次测试一直超时,查了半天发现是IdlingResource的isIdleNow()实现里用了Thread.sleep()。这会导致测试框架的等待机制失效。正确的做法是用回调通知。

总结

UI测试网络请求,说白了就是解决一个同步问题。IdlingResource就是那个「同步器」。它让测试框架知道什么时候该等,什么时候该走。

我个人习惯是:每个网络请求模块配一个IdlingResource,测试基类统一管理注册和注销。这样写出来的测试既稳定又清晰。

记住,测试不是为了跑而跑,是为了发现问题。如果你的UI测试从来不变红,那说明它可能根本没测到真正的东西。


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