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测试网络请求的核心流程
常见问题与避坑指南
我总结几个实际项目中容易踩的坑:
- 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