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:实战中的配合

好了,现在我们把所有东西串起来。一个完整的测试流程应该是这样的:

  1. 用Mockito模拟数据层
  2. 通过依赖注入把Mock对象传给ViewModel
  3. 启动Activity,DataBinding自动把ViewModel和布局绑定
  4. 用Espresso模拟用户操作
  5. 用DataBindingIdlingResource等待UI更新
  6. 断言最终结果

我画了一张图,帮你理清这个流程:

DataBinding测试流程 Mockito模拟数据 mock(Repository::class) 注入ViewModel LoginViewModel(mockRepo) 启动Activity activityRule.launchActivity() Espresso操作 perform(typeText, click) DataBinding更新 自动刷新UI 等待Idle DataBindingIdlingResource 断言结果 check(matches(withText(...))) 可重复执行 整个流程可以在一次测试方法中完成,也可以拆分成多个@Test方法

你看,这个流程其实很清晰。我个人的经验是:不要试图在一个测试方法里测太多东西。每个测试只验证一个行为,这样出错了能快速定位。

实战案例:测试一个带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