单元测试: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的核心,必须覆盖。
注意:用coEvery和coVerify而不是every和verify,因为我们的函数是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端口冲突:每个测试类都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、等网络请求、看日志要快得多。这笔账,怎么算都划算。