29、Jetpack测试:单元测试、UI测试、Hilt测试与Room测试

说实话,测试这个话题,很多Android开发者都绕道走。我刚入行那会儿也觉得,写测试干嘛?浪费时间。直到有一次,我改了一个工具类里的日期格式化方法,结果导致三个业务模块的订单时间全部显示异常。线上bug炸了,我加班到凌晨三点才定位到问题。嗯,从那以后,我再也不敢不写测试了。

Jetpack架构组件给我们提供了非常完善的测试支持。今天我就带你把这套东西捋清楚。说白了,测试就分几类:单元测试测逻辑、UI测试测界面、Hilt测试测依赖注入、Room测试测数据库。咱们一个一个来。

29.1 单元测试:最基础的防线

单元测试,说白了就是测你的函数对不对。我个人习惯,每个ViewModel、每个工具类,都要配上单元测试。你想想看,如果连最基础的业务逻辑都没验证过,你怎么敢上线?

核心原则:单元测试要快、要独立、要可重复。不要依赖Android框架,尽量用纯JVM测试。

举个例子,假设我们有一个计算订单总价的函数:

// 待测试的类
class OrderCalculator {
    fun calculateTotal(price: Double, quantity: Int, discount: Double): Double {
        val subtotal = price * quantity
        val discountedAmount = subtotal * discount
        return subtotal - discountedAmount
    }
}

// 单元测试
class OrderCalculatorTest {
    private val calculator = OrderCalculator()

    @Test
    fun `calculateTotal should return correct value`() {
        val result = calculator.calculateTotal(100.0, 2, 0.1)
        assertEquals(180.0, result, 0.001)
    }

    @Test
    fun `calculateTotal with zero discount`() {
        val result = calculator.calculateTotal(50.0, 3, 0.0)
        assertEquals(150.0, result, 0.001)
    }
}

我在项目中遇到过一个问题:测试里用了浮点数比较,结果因为精度问题,测试时好时坏。后来我统一用了assertEquals的第三个参数——delta,才彻底解决。避坑指南:浮点数比较一定要给误差范围。

29.2 UI测试:用Compose Test验证界面

UI测试,说白了就是模拟用户操作,看界面反应对不对。以前用Espresso写UI测试,那叫一个痛苦。现在有了Compose Test,舒服多了。

我的习惯:UI测试不要写太多,覆盖核心用户路径就行。比如登录、下单、支付这些关键流程。写太多UI测试,维护成本会很高。

// 一个简单的Compose UI测试
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `login button should be disabled when fields are empty`() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // 验证登录按钮初始状态为禁用
        composeTestRule
            .onNodeWithText("登录")
            .assertIsNotEnabled()

        // 输入用户名和密码
        composeTestRule
            .onNodeWithTag("username_field")
            .performTextInput("test@example.com")

        composeTestRule
            .onNodeWithTag("password_field")
            .performTextInput("password123")

        // 验证登录按钮变为可用
        composeTestRule
            .onNodeWithText("登录")
            .assertIsEnabled()
    }
}

为什么会这样?因为Compose Test直接操作Composable节点,不需要像Espresso那样找ID、等异步。你想想看,这省了多少事。

29.3 Hilt测试:依赖注入的测试之道

Hilt测试,说白了就是测试你的依赖注入对不对。我记得刚开始用Dagger的时候,测试配置能写一整天。Hilt在这方面做了大量优化。

注意:Hilt测试需要添加@HiltAndroidTest注解,并且测试类要用@RunWith(AndroidJUnit4::class)运行。

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class UserRepositoryTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var userRepository: UserRepository

    @Inject
    lateinit var userDao: UserDao

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun `getUser should return cached user after first fetch`() {
        // 模拟网络请求
        val testUser = User(id = 1, name = "Test User")
        
        // 第一次调用,应该从网络获取
        val user1 = userRepository.getUser(1)
        
        // 第二次调用,应该从缓存获取
        val user2 = userRepository.getUser(1)
        
        assertEquals(testUser, user1)
        assertEquals(testUser, user2)
    }
}

我曾经踩过一个坑:Hilt测试里忘了加@HiltAndroidTest注解,结果依赖注入一直失败,排查了半天。嗯,这里要注意,注解一个都不能少。

29.4 Room测试:数据库的可靠性保障

Room测试,说白了就是验证你的数据库操作对不对。数据库这东西,一旦出问题,数据就乱了。所以Room测试我建议写得细一点。

核心要点:Room测试要用内存数据库,不要用真数据库文件。这样测试跑得快,而且每次都是干净的。

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private lateinit var userDao: UserDao
    private lateinit var database: AppDatabase

    @Before
    fun setup() {
        // 创建内存数据库
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        userDao = database.userDao()
    }

    @After
    fun teardown() {
        database.close()
    }

    @Test
    fun `insert and retrieve user`() {
        val user = User(
            id = 1,
            name = "张三",
            email = "zhangsan@example.com"
        )

        userDao.insertUser(user)

        val retrievedUser = userDao.getUserById(1)
        assertEquals(user.name, retrievedUser?.name)
        assertEquals(user.email, retrievedUser?.email)
    }

    @Test
    fun `delete user should remove from database`() {
        val user = User(id = 1, name = "测试用户")
        userDao.insertUser(user)
        
        userDao.deleteUser(user)
        
        val result = userDao.getUserById(1)
        assertNull(result)
    }
}

我在项目中遇到过一个问题:Room测试里用了LiveData,结果测试一直不结束。后来发现是LiveData的观察者没清理。避坑指南:用LiveData做测试时,记得用getOrAwaitValue()这类扩展函数,或者直接用Kotlin Flow。

29.5 测试覆盖率:别盲目追求数字

很多团队把测试覆盖率当成KPI,要求必须达到80%甚至90%。我个人觉得,这有点本末倒置。你想想看,有些工具类、数据类,测试覆盖率再高也没意义。关键是要把核心业务逻辑覆盖到。

测试类型 推荐覆盖率 重点关注
单元测试 70%-80% ViewModel、工具类、业务逻辑
UI测试 20%-30% 核心用户路径
Hilt测试 50%-60% 依赖注入配置、模块集成
Room测试 80%-90% DAO操作、数据迁移

我的建议:先写单元测试和Room测试,这两个性价比最高。UI测试和Hilt测试可以等核心功能稳定了再补。别一上来就想把所有测试都写完,那样你会崩溃的。

29.6 知识体系总览

下面这张图,我把整个Jetpack测试的知识体系梳理了一下。你可以把它当成一个检查清单,看看自己哪些地方还没覆盖到。

Jetpack测试知识体系 Jetpack测试 单元测试 UI测试 Hilt测试 Room测试 JUnit + Mockito ViewModel测试 工具类测试 Compose Test Espresso UI自动化 @HiltAndroidTest 依赖注入验证 模块集成测试 DAO操作测试 数据迁移测试 内存数据库 测试覆盖率不是目的,代码质量才是

这张图把测试分成了四大块,每一块都有对应的工具和关注点。你写测试的时候,可以对照着看看,哪些地方还没覆盖到。

最后说一句:测试不是银弹,但它能帮你省下大量排查bug的时间。我现在的习惯是,写完一个功能,先跑一遍单元测试,再跑一遍Room测试,最后才考虑UI测试。这个顺序,能让你最快发现问题。

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