18、测试Room数据库:本地单元测试、Instrumented测试、测试DAO、测试Migration
说实话,写数据库代码最怕什么?
最怕上线后才发现SQL写错了,或者表结构变更导致用户数据全丢了。我以前就吃过这个亏——一个简单的SELECT漏了个字段,测试环境跑得好好的,上线后直接崩溃。从那以后,我养成了一个习惯:数据库代码必须写测试。
Room作为Android官方数据库方案,测试体系其实很完善。今天我们就来聊聊怎么给Room写测试,包括单元测试、Instrumented测试、DAO测试,还有最让人头疼的Migration测试。
核心观点:Room测试不是可选项,而是保障数据安全的底线。尤其是Migration测试,能帮你避免生产环境的数据灾难。
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 测试的最佳实践
说了这么多,总结几条我自己的经验:
- 测试覆盖率要够:每个DAO方法至少有一个测试用例,复杂查询要有边界测试
- Migration测试必须写:每次数据库版本升级,都要写对应的Migration测试
- 本地测试和Instrumented测试结合:简单逻辑用本地测试,复杂逻辑用Instrumented测试
- 测试数据要独立:每个测试用例用独立的数据,不要依赖其他测试的结果
- 自动化运行:把测试集成到CI/CD流程中,每次提交代码自动跑一遍
嗯,其实写测试一开始确实有点麻烦,但想想看——如果因为没写测试导致线上事故,那修复的成本可高多了。所以,别偷懒,把测试写起来吧。