18、测试Room数据库:本地单元测试、Instrumented测试、测试DAO、测试Migration

说实话,写数据库代码最怕什么?

最怕上线后才发现SQL写错了,或者表结构变更导致用户数据全丢了。我以前就吃过这个亏——一个简单的SELECT漏了个字段,测试环境跑得好好的,上线后直接崩溃。从那以后,我养成了一个习惯:数据库代码必须写测试

Room作为Android官方数据库方案,测试体系其实很完善。今天我们就来聊聊怎么给Room写测试,包括单元测试、Instrumented测试、DAO测试,还有最让人头疼的Migration测试。

核心观点:Room测试不是可选项,而是保障数据安全的底线。尤其是Migration测试,能帮你避免生产环境的数据灾难。

Room 数据库测试体系 Room 测试 本地单元测试 Instrumented测试 Migration测试 DAO 逻辑验证 SQL 语法检查 真实设备/模拟器运行 版本升级验证 数据完整性检查 三种测试覆盖不同维度,共同保障数据库质量

18.1 本地单元测试:轻量级验证

本地单元测试,说白了就是在开发机上跑,不需要Android设备。它的优点是快,缺点是无法测试真正的SQLite行为。

适用场景:

  • 验证DAO接口的方法签名是否正确
  • 测试数据转换逻辑(比如Entity转UI模型)
  • 检查SQL语句的拼装逻辑

要写本地单元测试,你需要添加依赖:

// build.gradle (module)
testImplementation "androidx.room:room-testing:2.6.1"
testImplementation "junit:junit:4.13.2"
testImplementation "org.mockito:mockito-core:5.3.1"

然后写一个简单的测试类:

class UserDaoLocalTest {

    private lateinit var userDao: UserDao
    private lateinit var db: RoomDatabase

    @Before
    fun setUp() {
        // 使用内存数据库,速度最快
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        userDao = db.userDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    @Test
    fun testInsertAndQuery() {
        val user = User(id = 1, name = "张三", age = 25)
        userDao.insert(user)

        val result = userDao.getUserById(1)
        assertEquals("张三", result?.name)
    }
}

小技巧:本地测试用inMemoryDatabaseBuilder,每次测试完自动销毁,不会污染数据。我一般会在@Before里重建数据库,保证每个测试用例都是干净的。

18.2 Instrumented测试:真机验证

本地测试虽然快,但它用的是Android的mock环境,有些SQLite特性无法模拟。比如外键约束、触发器这些,必须上真机或模拟器才能测准。

Instrumented测试跑在Android设备上,能调用真实的SQLite引擎。配置方式如下:

// build.gradle (module)
androidTestImplementation "androidx.room:room-testing:2.6.1"
androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"

测试代码和本地测试很像,但注意要用androidx.test.runner.AndroidJUnit4

@RunWith(AndroidJUnit4::class)
class UserDaoInstrumentedTest {

    private lateinit var userDao: UserDao
    private lateinit var db: AppDatabase

    @Before
    fun createDb() {
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .build()
        userDao = db.userDao()
    }

    @After
    fun closeDb() {
        db.close()
    }

    @Test
    fun testInsertAndDelete() {
        val user = User(id = 2, name = "李四", age = 30)
        userDao.insert(user)

        userDao.delete(user)
        val result = userDao.getUserById(2)
        assertNull(result)
    }
}

注意:Instrumented测试虽然更真实,但运行速度慢。我建议把核心的CRUD操作和复杂查询放在Instrumented测试里,简单的逻辑用本地测试就够了。

18.3 测试DAO:覆盖所有操作

DAO是数据库操作的入口,测试DAO就是测试你的SQL语句对不对。我个人习惯把DAO测试分成三类:

测试类型 测试内容 示例
插入测试 验证单条/批量插入是否成功 insert(user) 后查询确认
查询测试 验证各种查询条件、排序、分页 getUsersByAge(20, 30) 返回正确范围
更新/删除测试 验证数据变更后的一致性 update(user) 后检查字段变化

举个例子,假设你的DAO有一个复杂查询:

@Query("SELECT * FROM users WHERE age BETWEEN :minAge AND :maxAge ORDER BY name ASC")
fun getUsersByAge(minAge: Int, maxAge: Int): List<User>

测试代码可以这样写:

@Test
fun testGetUsersByAge() {
    // 准备数据
    val users = listOf(
        User(1, "Alice", 22),
        User(2, "Bob", 28),
        User(3, "Charlie", 35)
    )
    users.forEach { userDao.insert(it) }

    // 执行查询
    val result = userDao.getUsersByAge(20, 30)

    // 验证结果
    assertEquals(2, result.size)
    assertEquals("Alice", result[0].name) // 按字母排序
    assertEquals("Bob", result[1].name)
}

关键点:测试DAO时,一定要覆盖边界条件。比如查询年龄范围时,测试20-30岁,也要测试0-100岁这种全量范围,还有空范围(比如200-300岁应该返回空列表)。

18.4 测试Migration:数据库升级的守护神

Migration测试,是我认为Room测试里最重要的部分。为什么?因为数据库升级一旦出错,用户数据可能就没了。我曾经在一个项目里,因为Migration写错了,导致用户收藏的笔记全部丢失——那感觉,真的不想再体验第二次。

Room提供了MigrationTestHelper来帮我们测试升级流程:

@RunWith(AndroidJUnit4::class)
class MigrationTest {

    private val TEST_DB_NAME = "migration-test"

    @Rule
    @JvmField
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java,
        listOf()
    )

    @Test
    fun testMigrationFrom1To2() {
        // 创建版本1的数据库
        val db = helper.createDatabase(TEST_DB_NAME, 1)
        // 插入一些测试数据
        db.execSQL("INSERT INTO users (id, name, age) VALUES (1, '测试用户', 20)")
        db.close()

        // 执行迁移到版本2
        val migratedDb = helper.runMigrationsAndValidate(
            TEST_DB_NAME,
            2,
            true,
            MIGRATION_1_2
        )

        // 验证数据是否还在
        val cursor = migratedDb.query("SELECT * FROM users")
        assertTrue(cursor.count > 0)
        cursor.close()
        migratedDb.close()
    }
}

这里有几个要点:

  • createDatabase会创建一个指定版本的数据库,你可以往里面塞测试数据
  • runMigrationsAndValidate会执行Migration,并验证数据库结构是否正确
  • 第三个参数true表示如果Migration失败就抛出异常,建议一直设为true

避坑指南:我曾经犯过一个错误——测试Migration时只验证了表结构,没验证数据。结果上线后发现,虽然表结构对了,但某些字段的值被清空了。所以,Migration测试一定要验证数据完整性,最好把旧版本的数据都查一遍。

18.5 测试的最佳实践

说了这么多,总结几条我自己的经验:

  1. 测试覆盖率要够:每个DAO方法至少有一个测试用例,复杂查询要有边界测试
  2. Migration测试必须写:每次数据库版本升级,都要写对应的Migration测试
  3. 本地测试和Instrumented测试结合:简单逻辑用本地测试,复杂逻辑用Instrumented测试
  4. 测试数据要独立:每个测试用例用独立的数据,不要依赖其他测试的结果
  5. 自动化运行:把测试集成到CI/CD流程中,每次提交代码自动跑一遍

嗯,其实写测试一开始确实有点麻烦,但想想看——如果因为没写测试导致线上事故,那修复的成本可高多了。所以,别偷懒,把测试写起来吧。


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