第5章 Repository层单元测试:Repository模式与测试策略、测试Room数据库操作、测试网络请求(Retrofit Mock)、测试数据转换与缓存逻辑、使用Fake对象替代Mock
Repository层,说白了就是咱们App的数据大管家。我刚开始做Android开发那会儿,经常把网络请求和数据库操作直接写在ViewModel里,结果测试起来那叫一个痛苦。后来用了Repository模式,整个世界都清净了。
这一章,咱们就来聊聊怎么给Repository层写单元测试。我会结合自己踩过的坑,把测试策略、Room数据库、Retrofit Mock、数据转换和缓存逻辑,还有Fake对象的使用,一次性讲清楚。
5.1 Repository模式与测试策略
Repository模式的核心思想,就是让数据来源对上层透明。ViewModel不需要知道数据是从网络来的,还是从本地数据库来的。它只管调用Repository的方法,拿到数据就行。
测试Repository层,我们主要关注三点:
- 数据来源切换是否正确:比如有网络时用网络数据,没网络时用缓存数据。
- 数据转换是否准确:从API拿到的DTO(数据传输对象)能不能正确转成Domain Model。
- 缓存逻辑是否可靠:缓存过期了会不会重新请求,缓存没过期会不会直接返回。
我个人习惯把Repository的测试分成两类:
- 集成测试:真的去操作Room数据库,真的去调用Retrofit接口(用MockWebServer模拟)。
- 单元测试:用Fake对象替代真实的数据库和网络请求,只测试Repository的业务逻辑。
你可能会问,那到底该用哪种?我的建议是:核心业务逻辑用单元测试,数据持久化和网络通信用集成测试。这样既能保证速度,又能覆盖关键路径。
重要:Repository层的测试,不要试图覆盖所有分支。优先覆盖正常流程和常见的异常流程。比如网络超时、数据库写入失败、数据为空等场景。
5.2 测试Room数据库操作
测试Room数据库,我推荐用 Room.inMemoryDatabaseBuilder。它会在内存中创建数据库,测试结束后自动销毁,不会污染你的开发环境。
来看一个例子。假设我们有一个 UserDao:
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUser(userId: String): UserEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity)
}
测试代码可以这样写:
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
private lateinit var db: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
userDao = db.userDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertAndGetUser() = runTest {
val user = UserEntity(id = "1", name = "张三", age = 25)
userDao.insertUser(user)
val result = userDao.getUser("1")
assertEquals(user, result)
}
}
这里有个坑,我曾经踩过:记得在 @After 里关闭数据库。不然多个测试用例之间会互相影响,出现一些莫名其妙的错误。
小技巧:如果你的DAO方法返回的是Flow,测试时可以用 flow.first() 来获取第一个发射的值。这样就不用担心协程的异步问题了。
5.3 测试网络请求(Retrofit Mock)
测试网络请求,我一般用 MockWebServer。它是Square公司出的一个库,可以模拟HTTP请求和响应。用起来很简单:
class UserApiTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var api: UserApi
@Before
fun setUp() {
mockWebServer = MockWebServer()
mockWebServer.start()
val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
api = retrofit.create(UserApi::class.java)
}
@After
fun tearDown() {
mockWebServer.shutdown()
}
@Test
fun testGetUserSuccess() = runTest {
// 模拟返回数据
val mockResponse = MockResponse()
.setResponseCode(200)
.setBody("""{"id":"1","name":"张三","age":25}""")
mockWebServer.enqueue(mockResponse)
val user = api.getUser("1")
assertEquals("张三", user.name)
}
}
你可能会问,为什么要用MockWebServer而不是直接Mock Retrofit接口?嗯,这里有个关键点:MockWebServer能测试整个HTTP请求链路,包括序列化、反序列化、请求头、超时等。而Mock Retrofit接口只能测试业务逻辑,没法发现序列化的问题。
注意:MockWebServer默认是异步的,测试时记得用 runTest 或者 awaitility 来等待请求完成。不然测试可能会提前结束,导致断言失败。
5.4 测试数据转换与缓存逻辑
数据转换,说白了就是把网络数据转成数据库实体,再转成UI层需要的模型。这个过程最容易出bug,尤其是字段名不一致或者类型不匹配的时候。
我建议把数据转换逻辑单独抽出来测试。比如:
class UserMapperTest {
@Test
fun testApiResponseToEntity() {
val apiResponse = UserApiResponse(
id = "1",
fullName = "张三",
age = 25,
createdAt = "2024-01-01T00:00:00Z"
)
val entity = UserMapper.toEntity(apiResponse)
assertEquals("1", entity.id)
assertEquals("张三", entity.name)
assertEquals(25, entity.age)
}
@Test
fun testEntityToDomain() {
val entity = UserEntity(id = "1", name = "张三", age = 25)
val domain = UserMapper.toDomain(entity)
assertEquals("1", domain.id)
assertEquals("张三", domain.name)
}
}
缓存逻辑的测试,稍微复杂一点。我一般会定义一个 CachePolicy 接口,然后测试它的行为:
class CachePolicyTest {
@Test
fun testCacheExpired() {
val policy = CachePolicy(duration = 60_000) // 1分钟过期
val cachedTime = System.currentTimeMillis() - 120_000 // 2分钟前缓存
assertTrue(policy.isExpired(cachedTime))
}
@Test
fun testCacheNotExpired() {
val policy = CachePolicy(duration = 60_000)
val cachedTime = System.currentTimeMillis() - 30_000 // 30秒前缓存
assertFalse(policy.isExpired(cachedTime))
}
}
核心思路:数据转换和缓存逻辑是Repository层的核心,一定要单独测试。不要等到集成测试时才发现问题,那会儿定位成本就高了。
5.5 使用Fake对象替代Mock
Mock对象和Fake对象,有什么区别?简单来说:Mock是模拟行为,Fake是简化实现。
举个例子。假设我们有一个 UserRepository,它依赖 UserApi 和 UserDao。如果用Mock,你需要写一堆 when 和 verify。如果用Fake,你直接写一个简化版的实现:
class FakeUserDao : UserDao {
private val users = mutableMapOf<String, UserEntity>()
override suspend fun getUser(userId: String): UserEntity? {
return users[userId]
}
override suspend fun insertUser(user: UserEntity) {
users[user.id] = user
}
}
class FakeUserApi : UserApi {
private val users = mutableMapOf<String, UserApiResponse>()
fun addUser(user: UserApiResponse) {
users[user.id] = user
}
override suspend fun getUser(userId: String): UserApiResponse {
return users[userId] ?: throw HttpException(404)
}
}
然后测试Repository:
class UserRepositoryTest {
private lateinit var repository: UserRepository
private lateinit var fakeDao: FakeUserDao
private lateinit var fakeApi: FakeUserApi
@Before
fun setUp() {
fakeDao = FakeUserDao()
fakeApi = FakeUserApi()
repository = UserRepository(fakeApi, fakeDao)
}
@Test
fun testGetUserFromNetwork() = runTest {
val apiUser = UserApiResponse(id = "1", fullName = "张三", age = 25)
fakeApi.addUser(apiUser)
val user = repository.getUser("1")
assertEquals("张三", user.name)
// 验证数据是否缓存到了数据库
val cachedUser = fakeDao.getUser("1")
assertNotNull(cachedUser)
}
}
我个人更喜欢用Fake对象。为什么呢?因为Fake对象的行为是确定的,不会像Mock那样出现「明明调用了方法,但verify就是过不了」的尴尬情况。而且Fake对象的维护成本低,改起来也方便。
建议:如果你的Repository依赖多个数据源,可以考虑用Fake对象组合出一个完整的测试环境。这样测试代码读起来就像在讲故事,特别清晰。
5.6 知识体系总览
为了让你更直观地理解Repository层的测试结构,我画了一张图:
这张图把Repository层的测试要点都串起来了。你写测试的时候,可以对照着看看自己有没有遗漏。
5.7 总结
Repository层的测试,说白了就是验证数据流动的正确性。从网络到数据库,从数据库到UI,每一步都不能出错。
我个人经验是:先用Fake对象把核心逻辑测透,再用MockWebServer和inMemoryDatabase做集成验证。这样既能保证测试速度,又能覆盖关键路径。
嗯,这一章的内容就到这里。记住,测试不是为了写而写,而是为了让你在改代码时心里有底。下次重构Repository的时候,你就知道该测什么、怎么测了。