单元测试:MockWebServer模拟接口、测试Repository层、测试ViewModel层

单元测试这件事,说实话,很多Android开发者一开始都不太重视。我以前也是这样——总觉得「跑一遍App不就知道对不对了吗?」直到有一次,我改了一个网络请求的返回结构,结果三个页面同时崩溃,而我完全没发现。嗯,从那以后,我再也不敢不写单元测试了。

今天我们就来聊聊,怎么用MockWebServer模拟接口,怎么测试Repository层,以及怎么测试ViewModel层。说白了,就是让你的网络层代码「裸奔」也能跑通。

核心要点:单元测试不是为了测框架,而是测你自己的逻辑。Retrofit、OkHttp这些库本身不需要你测,你需要测的是——数据来了之后,你的代码怎么处理。

为什么选MockWebServer?

你可能用过Mockito来mock接口,但MockWebServer有个天然优势:它启动一个真实的HTTP服务器,返回你指定的JSON数据。你的Retrofit代码完全不用改,就像在跟真实服务器通信一样。

我个人的习惯是:能用真实HTTP交互的,就别用纯mock。因为MockWebServer能帮你发现序列化、字段映射这类问题——这些坑,纯mock是发现不了的。

搭建测试环境

首先,加依赖。build.gradle里加上:

// MockWebServer
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
// Coroutine测试
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
// 其他常用
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.5")

注意,MockWebServer是testImplementation,不是androidTestImplementation。我们跑纯JVM测试,不需要模拟器。

MockWebServer基础用法

先看一个最简单的例子。假设我们有个API接口:

interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Int): User
}

测试代码这样写:

class ApiServiceTest {
    private val mockWebServer = MockWebServer()
    private lateinit var apiService: ApiService

    @Before
    fun setup() {
        mockWebServer.start()
        val baseUrl = mockWebServer.url("/").toString()
        apiService = Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }

    @After
    fun teardown() {
        mockWebServer.shutdown()
    }

    @Test
    fun `getUser returns expected user`() = runBlocking {
        // 准备mock数据
        val mockResponse = MockResponse()
            .setResponseCode(200)
            .setBody("""{"id":1,"name":"张三"}""")
        mockWebServer.enqueue(mockResponse)

        // 执行
        val user = apiService.getUser(1)

        // 验证
        assertEquals(1, user.id)
        assertEquals("张三", user.name)
    }
}

这里有个细节:mockWebServer.url("/")会返回一个类似http://127.0.0.1:随机端口/的地址。每次测试端口都不同,所以不会冲突。

小技巧:如果你有多个测试用例,记得在每个@Test之前调用mockWebServer.enqueue()。MockWebServer是按队列顺序返回响应的,你enqueue了几个,它就能处理几次请求。

测试Repository层

Repository层是网络层和ViewModel之间的桥梁。它通常包含一些数据转换、缓存逻辑、错误处理。这些逻辑必须测。

举个例子,假设我们有个UserRepository:

class UserRepository(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUser(id: Int): Result<User> {
        return try {
            val remoteUser = apiService.getUser(id)
            userDao.insertUser(remoteUser.toEntity())
            Result.success(remoteUser)
        } catch (e: Exception) {
            // 网络失败时,从本地缓存读取
            val cachedUser = userDao.getUser(id)
            if (cachedUser != null) {
                Result.success(cachedUser.toDomain())
            } else {
                Result.failure(e)
            }
        }
    }
}

测试这个Repository,我们需要同时mock网络和数据库。MockWebServer负责网络,Mockk负责mock DAO。

class UserRepositoryTest {
    private val mockWebServer = MockWebServer()
    private lateinit var apiService: ApiService
    private val userDao = mockk<UserDao>()
    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        mockWebServer.start()
        val baseUrl = mockWebServer.url("/").toString()
        apiService = Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
        repository = UserRepository(apiService, userDao)
    }

    @After
    fun teardown() {
        mockWebServer.shutdown()
    }

    @Test
    fun `getUser success saves to cache`() = runBlocking {
        // 准备
        val mockResponse = MockResponse()
            .setResponseCode(200)
            .setBody("""{"id":1,"name":"张三"}""")
        mockWebServer.enqueue(mockResponse)
        coEvery { userDao.insertUser(any()) } returns Unit

        // 执行
        val result = repository.getUser(1)

        // 验证
        assertTrue(result.isSuccess)
        assertEquals("张三", result.getOrNull()?.name)
        coVerify { userDao.insertUser(any()) }
    }

    @Test
    fun `getUser network fails returns cached data`() = runBlocking {
        // 准备:网络返回500
        val mockResponse = MockResponse()
            .setResponseCode(500)
        mockWebServer.enqueue(mockResponse)
        val cachedEntity = UserEntity(1, "缓存数据")
        coEvery { userDao.getUser(1) } returns cachedEntity

        // 执行
        val result = repository.getUser(1)

        // 验证
        assertTrue(result.isSuccess)
        assertEquals("缓存数据", result.getOrNull()?.name)
    }
}

你看,这里测了两个场景:网络成功时数据是否写入缓存,网络失败时是否回退到缓存。这两个逻辑是Repository的核心,必须覆盖。

注意:coEverycoVerify而不是everyverify,因为我们的函数是suspend函数。这个坑我踩过——用错了API,测试一直报错,查了半天才发现。

测试ViewModel层

ViewModel层通常持有StateFlow或SharedFlow,负责把数据转换成UI状态。测试ViewModel时,我们关注的是:输入一个动作,UI状态是否正确变化

看个例子:

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<User>>(UiState.Loading)
    val uiState: StateFlow<UiState<User>> = _uiState.asStateFlow()

    fun loadUser(id: Int) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            val result = userRepository.getUser(id)
            _uiState.value = result.fold(
                onSuccess = { UiState.Success(it) },
                onFailure = { UiState.Error(it.message ?: "未知错误") }
            )
        }
    }
}

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

测试代码:

class UserViewModelTest {
    private val userRepository = mockk<UserRepository>()
    private lateinit var viewModel: UserViewModel

    @Before
    fun setup() {
        viewModel = UserViewModel(userRepository)
    }

    @Test
    fun `loadUser emits Loading then Success`() = runTest {
        // 准备
        val user = User(1, "张三")
        coEvery { userRepository.getUser(1) } returns Result.success(user)

        // 执行
        viewModel.loadUser(1)

        // 验证:先Loading,后Success
        val states = viewModel.uiState.toList()
        assertEquals(UiState.Loading, states[0])
        assertTrue(states[1] is UiState.Success)
        assertEquals("张三", (states[1] as UiState.Success).data.name)
    }

    @Test
    fun `loadUser emits Loading then Error`() = runTest {
        // 准备
        coEvery { userRepository.getUser(1) } returns Result.failure(Exception("网络异常"))

        // 执行
        viewModel.loadUser(1)

        // 验证
        val states = viewModel.uiState.toList()
        assertEquals(UiState.Loading, states[0])
        assertTrue(states[1] is UiState.Error)
        assertEquals("网络异常", (states[1] as UiState.Error).message)
    }
}

这里用了runTest而不是runBlocking。为什么?因为ViewModel内部用了viewModelScope,而runTest能正确处理协程的调度。我刚开始用runBlocking测ViewModel,结果协程还没跑完,测试就结束了——状态永远拿不到。

关于toList()StateFlow的toList()会一直收集直到协程被取消。在runTest中,测试结束后协程自动取消,所以能拿到完整的状态序列。这个写法很简洁,推荐使用。

知识体系总览

下面这张图总结了本章的核心逻辑:

单元测试分层架构 MockWebServer 模拟HTTP接口,返回JSON数据,验证请求路径和参数 Repository 层测试 MockWebServer + Mockk(DAO) | 验证数据转换、缓存策略、错误处理 ViewModel 层测试 Mockk(Repository) + runTest | 验证UI状态机:Loading → Success / Error 测试金字塔:越往下层,测试越稳定;越往上,越接近用户行为

常见问题与避坑

最后分享几个我实际踩过的坑:

  • MockWebServer端口冲突:每个测试类都start一个MockWebServer,如果忘记shutdown,端口会被占用。我建议在@Before里start,@After里shutdown,保证成对出现。
  • 协程作用域问题:测试ViewModel时,一定要用runTest。用runBlocking会导致viewModelScope里的协程还没执行完,测试就结束了。
  • JSON格式错误:MockWebServer返回的JSON字符串,一定要和你的数据类字段完全匹配。我曾经少写了一个字段,结果反序列化失败,测试报错半天找不到原因。
  • 请求断言:MockWebServer可以验证请求路径和参数。用mockWebServer.takeRequest()拿到请求对象,然后断言path、method、body等。

总结一下:MockWebServer + Mockk + runTest 是测试网络层三件套。MockWebServer负责模拟网络,Mockk负责mock其他依赖,runTest负责管理协程。这三样用好,你的网络层代码就能被稳稳地保护起来。

说实话,单元测试写起来确实比写业务代码慢。但你想啊,每次改完代码跑一遍测试,比手动点App、等网络请求、看日志要快得多。这笔账,怎么算都划算。


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