17、DataBinding与测试:单元测试Binding逻辑、UI测试与DataBinding、Mockito模拟数据、Espresso与DataBinding
说实话,很多开发者写DataBinding的时候挺爽,一到测试就头疼。我见过不少项目,DataBinding用得很溜,但测试覆盖率几乎为零。为什么?因为大家觉得Binding这层东西不好测,跟UI绑得太紧。
其实这是个误区。DataBinding不但可以测,而且测起来有套路。今天我就把这套东西掰开揉碎了讲给你听。
单元测试Binding逻辑:别让UI绑架你的代码
先说说单元测试。很多人以为DataBinding只能在Android设备上跑,其实不是。你的ViewModel、数据模型、甚至一些自定义的BindingAdapter,都可以在纯JVM环境下测。
我个人的习惯是:把业务逻辑和UI逻辑彻底分开。ViewModel里只放数据和行为,不碰任何View相关的引用。这样单元测试就变得非常纯粹。
核心原则:ViewModel不持有Context、不持有View引用。所有数据通过LiveData或ObservableField暴露给UI层。
举个例子,假设你有一个登录ViewModel:
class LoginViewModel : ViewModel() {
val username = ObservableField<String>("")
val password = ObservableField<String>("")
val loginResult = MutableLiveData<Result<User>>()
fun login() {
// 业务逻辑
if (username.get().isNullOrBlank() || password.get().isNullOrBlank()) {
loginResult.value = Result.failure(IllegalArgumentException("输入不能为空"))
return
}
// 调用仓库层...
}
}
这个ViewModel怎么测?直接new出来,设值,调用方法,断言结果。不需要任何Android环境。
@Test
fun testLoginWithEmptyFields() {
val viewModel = LoginViewModel()
viewModel.username.set("")
viewModel.password.set("")
viewModel.login()
val result = viewModel.loginResult.getOrAwaitValue()
assertTrue(result.isFailure)
}
这里有个小技巧:getOrAwaitValue() 是我自己写的一个扩展函数,用来在单元测试中同步获取LiveData的值。因为LiveData默认是异步的,但单元测试里我们想要同步结果。
我的经验:写单元测试时,尽量用JUnit的@Test注解,不要依赖AndroidX Test。这样测试跑得快,CI上也稳定。我有个项目,ViewModel的单元测试覆盖率做到了95%,全部在本地JVM上跑,每次提交代码几秒钟就跑完了。
UI测试与DataBinding:Espresso的正确打开方式
单元测试搞定逻辑层,UI测试就要上场了。Espresso是Android官方推荐的UI测试框架,跟DataBinding配合起来其实很自然。
为什么?因为DataBinding把数据和View绑定在一起,你改变数据,View自动更新。Espresso要做的就是等待这个更新完成。
我刚开始用Espresso测DataBinding时踩过一个坑:数据更新后,UI不会立即刷新。因为Binding是在下一帧才执行的。所以直接用onView(withId(...)).check(matches(withText("xxx")))有时候会失败。
解决办法很简单:用IdlingResource。但说实话,手动写IdlingResource有点麻烦。我推荐用DataBindingIdlingResource,这是Google官方提供的工具。
@Rule
@JvmField
val activityRule = ActivityTestRule(MainActivity::class.java, false, false)
@Test
fun testLoginButtonEnabled() {
val idlingResource = DataBindingIdlingResource()
idlingResource.monitorActivity(activityRule)
val activity = activityRule.launchActivity(null)
// 等待Binding完成
idlingResource.waitForIdle()
onView(withId(R.id.btn_login))
.check(matches(not(isEnabled())))
onView(withId(R.id.et_username))
.perform(typeText("admin"))
onView(withId(R.id.et_password))
.perform(typeText("123456"))
idlingResource.waitForIdle()
onView(withId(R.id.btn_login))
.check(matches(isEnabled()))
}
注意:DataBindingIdlingResource需要添加依赖:androidx.databinding:databinding-common。而且它只监控Activity级别的Binding,Fragment里的Binding需要额外处理。
Mockito模拟数据:让测试更可控
UI测试里最头疼的是什么?网络请求、数据库、各种外部依赖。你总不能在测试环境里真的去调API吧?这时候Mockito就派上用场了。
我习惯在ViewModel的构造函数里注入Repository,这样测试时就可以用Mockito替换掉真实的Repository。
class LoginViewModel(private val repository: UserRepository) : ViewModel() {
// ...
}
// 测试代码
@Test
fun testLoginSuccess() {
val mockRepo = mock(UserRepository::class.java)
`when`(mockRepo.login(anyString(), anyString()))
.thenReturn(Result.success(User("test_user")))
val viewModel = LoginViewModel(mockRepo)
viewModel.username.set("admin")
viewModel.password.set("123456")
viewModel.login()
val result = viewModel.loginResult.getOrAwaitValue()
assertTrue(result.isSuccess)
assertEquals("test_user", result.getOrNull()?.name)
}
这里有个细节:Mockito默认返回null。如果你的方法返回的是基本类型或者LiveData,记得用thenReturn给个默认值。我曾经因为这个原因,排查了半天才发现是Mockito返回了null导致NPE。
避坑指南:用Mockito模拟LiveData时,记得用mock(LiveData::class.java)而不是mock(LiveData::class)。前者会保留LiveData的基本行为,后者可能会丢失一些内部状态。
Espresso与DataBinding:实战中的配合
好了,现在我们把所有东西串起来。一个完整的测试流程应该是这样的:
- 用Mockito模拟数据层
- 通过依赖注入把Mock对象传给ViewModel
- 启动Activity,DataBinding自动把ViewModel和布局绑定
- 用Espresso模拟用户操作
- 用DataBindingIdlingResource等待UI更新
- 断言最终结果
我画了一张图,帮你理清这个流程:
你看,这个流程其实很清晰。我个人的经验是:不要试图在一个测试方法里测太多东西。每个测试只验证一个行为,这样出错了能快速定位。
实战案例:测试一个带DataBinding的登录页面
最后,我给你一个完整的测试案例。假设你的登录页面用DataBinding绑定了ViewModel,布局文件里有用户名输入框、密码输入框和登录按钮。
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@Rule
@JvmField
val activityRule = ActivityTestRule(LoginActivity::class.java, false, false)
private val mockRepo = mock(UserRepository::class.java)
@Before
fun setup() {
// 用Mockito模拟成功登录
`when`(mockRepo.login("admin", "123456"))
.thenReturn(Result.success(User("admin", "admin@test.com")))
}
@Test
fun testLoginSuccess() {
val idlingResource = DataBindingIdlingResource()
val activity = activityRule.launchActivity(null)
idlingResource.monitorActivity(activity)
// 输入用户名和密码
onView(withId(R.id.et_username))
.perform(typeText("admin"), closeSoftKeyboard())
onView(withId(R.id.et_password))
.perform(typeText("123456"), closeSoftKeyboard())
// 点击登录按钮
onView(withId(R.id.btn_login))
.perform(click())
// 等待DataBinding更新
idlingResource.waitForIdle()
// 验证跳转到主页
onView(withId(R.id.tv_welcome))
.check(matches(withText("欢迎回来,admin")))
}
@After
fun tearDown() {
// 清理Mock
reset(mockRepo)
}
}
我的建议:写UI测试时,尽量用closeSoftKeyboard()关闭键盘。不然键盘弹出来会挡住其他View,导致Espresso找不到目标。我因为这个原因浪费过不少时间。
好了,DataBinding的测试就讲到这里。说白了,测试DataBinding跟测试普通Android代码没什么两样,关键是把逻辑层和UI层解耦,然后用合适的工具做合适的事。单元测试用Mockito,UI测试用Espresso,中间用DataBindingIdlingResource搭个桥,齐活。
公众号:蓝海资料掘金营,微信deep3321