28、Test KTX:单元测试与UI测试的KTX扩展,简化测试编写。
说实话,写测试这件事,很多Android开发者一开始是抗拒的。我自己也不例外。早些年做项目,总觉得“功能跑通就行了”,测试?那是QA的事。直到有一次,我改了一个看似无关紧要的工具类,结果导致三个页面同时崩溃——嗯,那次上线前的通宵加班,让我彻底明白了测试的重要性。
但写测试确实有门槛。尤其是Android的测试,涉及生命周期、上下文、异步任务,光搭建环境就够折腾的。好在Google推出了Test KTX,说白了就是给测试框架加了一层“语法糖”,让测试代码写起来更像Kotlin,更简洁,也更直观。
Test KTX 到底解决了什么问题?
我们先看一个典型场景。你写了一个ViewModel,里面用到了LiveData。你想测试数据变化时UI是否更新。传统写法是这样的:
@Test
fun testLiveData() {
val observer = mock(Observer::class.java)
liveData.observeForever(observer)
liveData.value = "hello"
verify(observer).onChanged("hello")
liveData.removeObserver(observer)
}
这段代码能跑,但每次都要手动注册、移除观察者,很烦。而且一旦忘记移除,还可能造成内存泄漏。我早期项目里就踩过这个坑——测试跑多了,内存直接爆掉。
用Test KTX,可以写成这样:
@Test
fun testLiveData() = liveDataTest {
val observer = mock(Observer::class.java)
observeForever(observer)
emit("hello")
verify(observer).onChanged("hello")
}
看到了吗?liveDataTest这个作用域函数帮我们自动管理了观察者的生命周期。测试结束后,观察者会被自动移除。这就是KTX的魅力——把繁琐的模板代码藏起来,让你只关注业务逻辑。
核心扩展函数一览
Test KTX 主要提供了以下几个扩展,我按使用频率排个序:
| 扩展函数 | 作用 | 适用场景 |
|---|---|---|
liveDataTest |
创建LiveData测试作用域,自动管理观察者 | ViewModel中LiveData的单元测试 |
InstantTaskExecutorRule |
让LiveData和Room的异步操作变为同步执行 | 避免测试中的线程问题 |
createAndroidComputation |
在测试环境中模拟Android主线程 | 需要主线程调用的场景 |
runOnUiThread |
在UI线程上执行代码块 | UI测试中的视图操作 |
我个人最常用的是InstantTaskExecutorRule。为什么?因为LiveData默认是在主线程更新数据的,而单元测试跑在后台线程。不加这个Rule,你十有八九会遇到CalledFromWrongThreadException。我曾经花了一整个下午排查这个错误,最后发现只是少加了一行注解——你说冤不冤?
UI测试中的KTX:让Espresso更Kotlin
UI测试方面,Test KTX 对 Espresso 和 Compose 测试都做了扩展。拿 Espresso 来说,传统写法是:
onView(withId(R.id.button)).perform(click())
onView(withText("提交")).check(matches(isDisplayed()))
用KTX后,可以写成:
onView(withId(R.id.button)).click()
onView(withText("提交")).isDisplayed()
少写几个方法调用,代码读起来更像自然语言。你想想看,.click() 和 .perform(click()),哪个更直观?显然是前者。
对于 Compose 测试,Test KTX 提供了 createComposeRule 和一系列语义匹配扩展。比如:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testComposeButton() {
composeTestRule.setContent {
MyButton("点击我")
}
composeTestRule.onNodeWithText("点击我").assertIsDisplayed()
}
这里要注意一点:Compose 测试的节点查找是基于语义树的,不是基于ID。如果你用惯了 Espresso 的 withId,刚转过来可能会不习惯。我刚开始写 Compose 测试时,就老想着找ID,结果发现根本找不到——因为 Compose 里没有传统意义上的 View ID。
避坑指南:我曾经踩过的三个坑
坑一:忘记加 @RunWith(AndroidJUnit4::class)
这个注解告诉测试框架要用 Android 的测试运行器。不加的话,InstantTaskExecutorRule 可能不会生效。我有一阵子测试总是莫名其妙地超时,排查了半天才发现是运行器没配对。
坑二:LiveData 的 observeForever 和 liveDataTest 混用
如果你在 liveDataTest 块里又手动调用了 observeForever,会导致观察者被注册两次。一次是框架帮你注册的,一次是你自己注册的。结果就是回调执行两次,断言可能通过,但逻辑其实是错的。我的建议是:要么全用框架,要么全手动,别混着来。
坑三:UI测试中忘记等待异步操作
Espresso 默认会等待主线程空闲,但如果你有自定义的异步任务(比如协程),它可能等不到。这时候需要用 IdlingResource 或者 Compose 的 waitUntil。我早期一个项目里,网络请求的UI测试总是时好时坏,后来才发现是异步等待没处理好。
知识体系总览
下面这张图是我整理的 Test KTX 核心脉络,你可以对照着看:
实际项目中的最佳实践
说了这么多,到底怎么用?我分享一个我项目里的真实案例。我们有个登录模块,ViewModel 里有个 loginResult 的 LiveData。测试时我需要验证:
- 点击登录按钮后,显示加载状态
- 网络请求成功后,跳转到主页
- 网络请求失败后,显示错误提示
用 Test KTX 写出来是这样的:
@RunWith(AndroidJUnit4::class)
class LoginViewModelTest {
@get:Rule
val instantRule = InstantTaskExecutorRule()
private lateinit var viewModel: LoginViewModel
@Before
fun setup() {
viewModel = LoginViewModel()
}
@Test
fun `login success should navigate to home`() = liveDataTest {
// 模拟成功响应
viewModel.login("user", "pass")
// 验证加载状态
assertThat(viewModel.isLoading.getOrAwaitValue()).isTrue()
// 模拟网络返回
viewModel.onLoginResult(Result.success(User("test")))
// 验证最终状态
assertThat(viewModel.loginResult.getOrAwaitValue()).isEqualTo(
LoginState.Success
)
}
}
这里用到了 getOrAwaitValue(),它是 LiveData 测试的一个常用扩展,可以阻塞等待直到 LiveData 有值返回。这个扩展不是 Test KTX 自带的,但配合起来用效果很好。
liveDataTest 的 emit 方法逐个发送值,而不是直接修改 LiveData 的 value。这样可以保证测试的时序可控。
liveDataTest 内部使用了协程的 runBlockingTest,如果你的测试代码里也有协程,要注意避免嵌套阻塞。我遇到过测试死锁的情况,就是因为两个阻塞操作互相等待。
总结一下
Test KTX 不是什么高深的技术,它做的就是一件事:让测试代码更像Kotlin。去掉冗余的模板,保留核心的断言逻辑。如果你还在手写 observeForever 和 removeObserver,不妨试试 liveDataTest。相信我,写一次你就会爱上它。
最后说一句:测试不是负担,而是保障。尤其是团队协作的项目,好的测试能帮你省下无数个通宵。嗯,这话是我用加班换来的经验。