第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,它依赖 UserApiUserDao。如果用Mock,你需要写一堆 whenverify。如果用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层测试知识体系 Repository Room数据库操作 Retrofit Mock 数据转换与缓存 Fake对象替代Mock inMemoryDatabase DAO操作验证 MockWebServer 请求链路测试 DTO转Entity 缓存过期策略 简化实现 行为确定 核心:数据来源切换 + 数据转换 + 缓存逻辑

这张图把Repository层的测试要点都串起来了。你写测试的时候,可以对照着看看自己有没有遗漏。

5.7 总结

Repository层的测试,说白了就是验证数据流动的正确性。从网络到数据库,从数据库到UI,每一步都不能出错。

我个人经验是:先用Fake对象把核心逻辑测透,再用MockWebServer和inMemoryDatabase做集成验证。这样既能保证测试速度,又能覆盖关键路径。

嗯,这一章的内容就到这里。记住,测试不是为了写而写,而是为了让你在改代码时心里有底。下次重构Repository的时候,你就知道该测什么、怎么测了。

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