写出有信心的代码 —— 用自动化测试为你的应用保驾护航
核心方法论:铁面无私,代码必须过堂
「开封有个包青天,铁面无私辨忠奸。代码世界亦需如此——每一个函数都是嫌疑人,每一个断言都是呈堂证供,每一个测试用例都是一场公正的审判。不要相信任何代码的'自我辩护',要用测试来验明正身。今日,本官便教你如何在代码的公堂上,明察秋毫、铁面无私。」
堂下何人!本官见过太多开发者,写完代码便自以为万事大吉,从不审查、从不验证——这就好比衙门断案只听原告一面之词,便急匆匆下判决,岂非荒唐?写测试就是给你的代码开一场公正的审判:让每个函数上堂受审,拿出证据(断言)来证明自己清白无辜。觉得"写测试是浪费时间"的人,就好比说"审案是浪费时间"——等到冤假错案酿成大祸,悔之晚矣!
本官断案多年,深知一个道理:罪犯(Bug)被抓得越晚,造成的危害就越大。开发阶段发现问题,不过费几分钟功夫;若是放任它逃到生产环境,便可能要紧急发版、回滚数据,犹如罪犯流窜作案后才被通缉,追捕成本何止百倍!自动化测试的核心价值就是在案发之初便将嫌犯缉拿归案。同时,有了完善的测试套件,你便可以放心大胆地重构代码——跑一遍测试全部通过,便如同所有案件复审均无误判,可以安心签字画押。好的测试本身也是最好的案宗档案:它明明白白地记录着"当输入是 X 时,输出应该是 Y"。
本官治理辖区,讲究层层把关。代码的测试体系亦当如此,遵循测试金字塔这一审判体系:
| 测试层级 | 比例 | 速度 | 覆盖范围 |
|---|---|---|---|
| 单元测试 | 约 70% | 毫秒级 | 单个函数/类 |
| 集成测试 | 约 20% | 秒级 | 多个组件协作 |
| UI 测试 | 约 10% | 分钟级 | 完整用户流程 |
本官将之比作三级司法体系:单元测试如同县衙地方官,数量最多、审案最快,处理单个函数的小案件;集成测试犹如省级按察使,审理多个组件之间的协作纠纷,速度稍慢但管辖更广;UI 测试则如同朝廷大理寺终审,模拟完整的用户流程,虽然耗时最长,但一旦通过便可保天下太平。三级审判缺一不可,方能确保代码江山社稷稳固。
不要妄图对天下所有事都管辖到——追求 100% 覆盖率并不现实。明智的做法是优先审理最容易出事和最关键的案件。将有限的精力用在刀刃上,方为上策。
工欲善其事,必先利其器。要在代码公堂上开庭审案,首先要把审判的场所和工具准备妥当。Android 项目为测试提供了两个"衙门":
| 源集路径 | 类型 | 运行环境 | 用途 |
|---|---|---|---|
src/test/ | 本地单元测试 | JVM(无需设备) | 纯逻辑、ViewModel、Repository |
src/androidTest/ | 仪器化测试 | 真机 / 模拟器 | UI 测试、数据库测试 |
src/test/ 好比本官在府衙内开设的简易公堂,不需要搬到大理寺(启动模拟器),审案速度极快。大部分案件应在此审理。只有涉及 UI 和设备相关的"大案要案",才需要移交 src/androidTest/ 这座正式大堂。
审案需要各种刑侦工具。以下是本官钦定的"办案装备":
// build.gradle.kts (Module :app)
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
}
JUnit 是公堂的基本框架,如同惊堂木和令牌,不可或缺;kotlinx-coroutines-test 提供审讯协程的专用刑具;Turbine 专门用来审问 Flow 这类"连环作案"的嫌疑人;MockK 则是 Kotlin 原生的替身术,可以伪造证人来辅助破案。
单元测试乃是本官审理的第一类案件——将代码中最小的嫌疑单元提堂审问,给定特定的"案情"(输入),看其是否给出正确的"供词"(输出)。若供词与事实不符,当即判定有罪!以下这个计算器类便是今日的第一个嫌疑人:
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int {
if (b == 0) throw IllegalArgumentException("除数不能为零")
return a / b
}
fun isEven(n: Int): Boolean = n % 2 == 0
}
以下便是对这个嫌疑人的正式审讯。注意 @Before 相当于每次开庭前的"净堂"——确保每场审判都有干净独立的环境,不受前案污染:
class CalculatorTest {
private lateinit var calculator: Calculator
@Before
fun setUp() { calculator = Calculator() }
@Test
fun add_twoPositiveNumbers_returnsSum() {
assertEquals(5, calculator.add(2, 3))
}
@Test(expected = IllegalArgumentException::class)
fun divide_byZero_throwsException() {
calculator.divide(10, 0)
}
@Test
fun isEven_evenNumber_returnsTrue() {
assertTrue(calculator.isEven(4))
}
@Test
fun isEven_oddNumber_returnsFalse() {
assertFalse(calculator.isEven(7))
}
}
断言便是呈堂证供,用以证明嫌疑代码有罪或无罪。本官常用的证供类型如下:
| 断言方法 | 用途 |
|---|---|
assertEquals(expected, actual) | 验证两个值相等 |
assertTrue(condition) | 验证条件为 true |
assertFalse(condition) | 验证条件为 false |
assertNotNull(object) | 验证对象不为 null |
assertThrows<E> { block } | 验证代码抛出指定异常 |
本官治下的案卷必须命名规范、条理分明。推荐格式:方法名_条件_预期结果,犹如案卷标注"嫌疑人_作案情节_判决结果",一目了然:
// 规范的案卷命名——一目了然
fun login_emptyPassword_returnsValidationError()
fun fetchUsers_networkError_returnsEmptyList()
// 混乱的命名——本官绝不允许!
fun test1()
fun testLogin()
右键点击测试类或方法,选择 "Run" 即可升堂。绿色表示嫌犯清白无罪——无罪释放;红色表示罪证确凿——当庭宣判有罪!
ViewModel 包含核心业务逻辑,乃是整个应用的"主谋"——最值得提堂审问的对象。但此犯有一个特殊之处:viewModelScope 默认使用 Dispatchers.Main,在 JVM 测试公堂中没有 Android 主线程。因此,本官需要先设一道"换堂令",将审判环境替换妥当:
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) { Dispatchers.setMain(dispatcher) }
override fun finished(description: Description) { Dispatchers.resetMain() }
}
今日提堂受审的是 LoginViewModel,它负责登录业务的核心逻辑。本官要审查它在各种情况下的表现——空邮箱、短密码、正常登录、网络异常,一个都不能放过:
class LoginViewModel(
private val authRepository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun updateEmail(email: String) { _uiState.update { it.copy(email = email) } }
fun updatePassword(password: String) { _uiState.update { it.copy(password = password) } }
fun login() {
val state = _uiState.value
if (state.email.isBlank()) { _uiState.update { it.copy(error = "请输入邮箱") }; return }
if (state.password.length < 6) { _uiState.update { it.copy(error = "密码至少6位") }; return }
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
authRepository.login(state.email, state.password)
.onSuccess { _uiState.update { it.copy(isLoading = false, isLoggedIn = true) } }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
}
}
}
data class LoginUiState(
val email: String = "", val password: String = "",
val isLoading: Boolean = false, val isLoggedIn: Boolean = false,
val error: String? = null
)
以下是对 LoginViewModel 的四项罪名逐一审查。空邮箱、短密码、登录成功、网络异常——每一条路径都不可遗漏,本官铁面无私,绝不容许任何侥幸:
class LoginViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: LoginViewModel
private lateinit var fakeAuth: FakeAuthRepository
@Before
fun setUp() {
fakeAuth = FakeAuthRepository()
viewModel = LoginViewModel(fakeAuth)
}
@Test
fun login_emptyEmail_showsError() {
viewModel.updatePassword("123456")
viewModel.login()
assertEquals("请输入邮箱", viewModel.uiState.value.error)
}
@Test
fun login_shortPassword_showsError() {
viewModel.updateEmail("user@test.com")
viewModel.updatePassword("123")
viewModel.login()
assertEquals("密码至少6位", viewModel.uiState.value.error)
}
@Test
fun login_validCredentials_setsLoggedIn() {
fakeAuth.shouldSucceed = true
viewModel.updateEmail("user@test.com")
viewModel.updatePassword("password123")
viewModel.login()
assertTrue(viewModel.uiState.value.isLoggedIn)
assertNull(viewModel.uiState.value.error)
}
@Test
fun login_networkError_showsError() {
fakeAuth.shouldSucceed = false
viewModel.updateEmail("user@test.com")
viewModel.updatePassword("password123")
viewModel.login()
assertFalse(viewModel.uiState.value.isLoggedIn)
assertEquals("网络连接失败", viewModel.uiState.value.error)
}
}
若测试报错 Module with the Main dispatcher had failed to initialize,便是你忘了设置审判环境!每个涉及 viewModelScope 的测试类都必须声明 MainDispatcherRule。这就好比开庭审案却忘了挂"明镜高悬"的牌匾——程序不合法,审判无效!
协程是代码世界中的"潜逃犯"——它们会挂起、延迟、在不同线程之间逃窜。要审讯这类狡猾的嫌犯,需要特殊的刑侦手段。kotlinx-coroutines-test 提供的 runTest 便是本官的"追踪令",可以自动跳过 delay 调用,让协程嫌犯无处遁形、瞬间归案:
@Test
fun fetchUser_returnsUserData() = runTest {
val repo = UserRepository(FakeApiService())
val user = repo.fetchUser(id = 1)
assertEquals("张三", user.name)
}
审讯协程有两种策略,恰如两种不同的审讯风格:
| 调度器 | 执行方式 | 适用场景 |
|---|---|---|
StandardTestDispatcher | 需手动推进 | 测试加载状态、时序逻辑 |
UnconfinedTestDispatcher | 立即执行 | 简单测试,不关心中间状态 |
StandardTestDispatcher 犹如审案时按部就班地推进时间线,每一秒都在本官掌控之中——适合审查"嫌犯在某个时间点做了什么"。UnconfinedTestDispatcher 则像快刀斩乱麻,立即出结果——适合不关心中间状态的简单案件。
@Test
fun advanceTimeBy_controlsDelay() = runTest {
var status = "等待中"
launch {
delay(3000)
status = "已完成"
}
advanceTimeBy(2000)
assertEquals("等待中", status) // 还没到 3 秒
advanceTimeBy(1001)
assertEquals("已完成", status) // 已超过 3 秒
}
Repository 往往是网络和数据库的中间人——本官要审查它在网络通畅和网络中断两种情况下的行为。本官断案讲究正反两面都要审清楚,不能只审"好天气"下的表现:
class NewsRepository(
private val api: NewsApiService,
private val dao: NewsDao
) {
suspend fun getLatestNews(): Result<List<News>> {
return try {
val news = api.fetchNews()
dao.insertAll(news)
Result.success(news)
} catch (e: Exception) {
val cached = dao.getAll()
if (cached.isNotEmpty()) Result.success(cached) else Result.failure(e)
}
}
}
// 审讯网络成功和失败两条案情线
class NewsRepositoryTest {
private val fakeApi = FakeNewsApiService()
private val fakeDao = FakeNewsDao()
private val repo = NewsRepository(fakeApi, fakeDao)
@Test
fun getLatestNews_success_returnsFreshData() = runTest {
fakeApi.news = listOf(News(1, "Kotlin 2.0 发布"))
val result = repo.getLatestNews()
assertTrue(result.isSuccess)
assertEquals("Kotlin 2.0 发布", result.getOrNull()?.first()?.title)
}
@Test
fun getLatestNews_networkError_returnsCachedData() = runTest {
fakeDao.insertAll(listOf(News(1, "缓存的新闻")))
fakeApi.shouldThrow = true
val result = repo.getLatestNews()
assertEquals("缓存的新闻", result.getOrNull()?.first()?.title)
}
}
Flow 是一种"连环作案"的嫌疑人——它不是一次性给出供词,而是随着时间不断发射新的值。要审讯这种犯人,需要 Turbine 这柄特制的"录供笔",它提供 .test { } API,让你逐条审查 Flow 吐出的每一个值,确保其供词前后一致、毫无破绽。
// 被告人:搜索功能
class SearchRepository(private val api: SearchApiService) {
fun search(query: String): Flow<SearchState> = flow {
emit(SearchState.Loading)
try {
emit(SearchState.Success(api.search(query)))
} catch (e: Exception) {
emit(SearchState.Error(e.message ?: "未知错误"))
}
}
}
sealed class SearchState {
data object Loading : SearchState()
data class Success(val results: List<String>) : SearchState()
data class Error(val message: String) : SearchState()
}
每次 awaitItem() 就像问嫌犯"下一句供词是什么",然后与预期对比。供词不符,当即判罪:
@Test
fun search_success_emitsLoadingThenResults() = runTest {
fakeApi.results = listOf("Kotlin", "Kotlin Compose")
repository.search("Kotlin").test {
assertEquals(SearchState.Loading, awaitItem())
val success = awaitItem() as SearchState.Success
assertEquals(2, success.results.size)
awaitComplete()
}
}
@Test
fun search_error_emitsLoadingThenError() = runTest {
fakeApi.shouldThrow = true
repository.search("test").test {
assertEquals(SearchState.Loading, awaitItem())
assertTrue(awaitItem() is SearchState.Error)
awaitComplete()
}
}
| 方法 | 用途 |
|---|---|
awaitItem() | 等待并返回下一个发射值 |
awaitComplete() | 断言 Flow 已正常完成 |
awaitError() | 断言 Flow 以异常结束 |
expectNoEvents() | 断言此刻没有新事件 |
cancelAndIgnoreRemainingEvents() | 取消收集(用于 StateFlow 等无限流) |
审讯 StateFlow 时需格外留意:Turbine 会自动将其初始值作为第一条"供词"(第一个 awaitItem()),且由于 StateFlow 永不自行结案,必须用 cancelAndIgnoreRemainingEvents() 强制退堂。
如果说单元测试是县衙审案、集成测试是省级会审,那么 Compose UI 测试便是大理寺终审——在仪器化环境中模拟真实用户的操作,验证界面是否如实呈现。这是最高级别的审判,也是对应用的最终考验。首先,要给被审查的界面元素贴上"身份标签"(testTag),以便公堂上精准传唤:
@Composable
fun LoginScreen(onLoginClick: (String, String) -> Unit) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = email, onValueChange = { email = it },
label = { Text("邮箱") },
modifier = Modifier.testTag("email_input")
)
TextField(
value = password, onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.testTag("password_input")
)
Button(
onClick = { onLoginClick(email, password) },
modifier = Modifier.testTag("login_button")
) { Text("登录") }
}
}
class LoginScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun loginScreen_displaysAllElements() {
composeTestRule.setContent { LoginScreen(onLoginClick = { _, _ -> }) }
composeTestRule.onNodeWithTag("email_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("password_input").assertIsDisplayed()
composeTestRule.onNodeWithText("登录").assertIsDisplayed()
}
@Test
fun loginScreen_enterCredentials_clickLogin() {
var capturedEmail = ""
composeTestRule.setContent {
LoginScreen { email, _ -> capturedEmail = email }
}
composeTestRule.onNodeWithTag("email_input").performTextInput("user@test.com")
composeTestRule.onNodeWithTag("password_input").performTextInput("123456")
composeTestRule.onNodeWithTag("login_button").performClick()
assertEquals("user@test.com", capturedEmail)
}
}
大理寺审案需要各种手段来查找嫌犯、模拟操作、核实证据:
| 类别 | 方法 | 用途 |
|---|---|---|
| 查找 | onNodeWithText("...") | 按文本内容查找 |
| 查找 | onNodeWithTag("...") | 按 testTag 查找 |
| 查找 | onNodeWithContentDescription("...") | 按无障碍描述查找 |
| 操作 | performClick() | 模拟点击 |
| 操作 | performTextInput("...") | 模拟文本输入 |
| 操作 | performScrollTo() | 滚动到可见区域 |
| 断言 | assertIsDisplayed() | 验证节点可见 |
| 断言 | assertTextEquals("...") | 验证文本内容 |
| 断言 | assertIsEnabled() | 验证组件可交互 |
本官告诫:传唤证人优先用其真名实姓(onNodeWithText、onNodeWithContentDescription),只在无法通过姓名辨认时才贴"嫌犯编号"(testTag)。满堂的编号标签会让生产代码变得杂乱不堪,如同案卷堆积如山却不分类归档。
审案断狱,有时需要"替身证人"——用测试替身替代真实的网络、数据库等外部依赖。否则每次开庭都要联网、连数据库,审案效率何其低下!本官常用两类替身:Fake(假扮证人,有简化的真实行为)和 Mock(傀儡证人,只会背诵预设台词)。
| 对比项 | Fake | Mock |
|---|---|---|
| 实现方式 | 手写的简化实现 | 框架自动生成 |
| 包含逻辑 | 有简化的业务逻辑 | 只返回预设值 |
| 适用场景 | Repository 等核心依赖 | 边缘依赖、验证调用行为 |
| 可读性 | 更清晰直观 | 需配置桩(stub) |
Fake 是本官更信赖的替身——它虽然是假的,但行为逻辑与真人相似,供词可信度更高:
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
fun isLoggedIn(): Boolean
}
class FakeAuthRepository : AuthRepository {
var shouldSucceed = true
private var loggedIn = false
override suspend fun login(email: String, password: String): Result<User> {
return if (shouldSucceed) {
loggedIn = true
Result.success(User(id = 1, name = "测试用户", email = email))
} else {
Result.failure(Exception("网络连接失败"))
}
}
override fun isLoggedIn(): Boolean = loggedIn
}
若不想亲自打造替身,可以用 MockK 框架自动生成"傀儡证人"——告诉它该说什么,它就说什么,还能记录下它被传唤了几次、问了什么问题:
class UserServiceTest {
private val mockRepo = mockk<UserRepository>()
@Test
fun getUser_callsRepository() = runTest {
coEvery { mockRepo.getUser(1) } returns User(id = 1, name = "张三")
val user = UserService(mockRepo).getUser(1)
assertEquals("张三", user.name)
coVerify(exactly = 1) { mockRepo.getUser(1) }
}
}
本官与朝廷(Google 官方)一样推荐优先使用 Fake。Fake 的供词更可预测,案卷更易读。只有在接口方法太多(手写替身太累)或者需要核查"嫌犯是否确实被传唤过"时,才考虑请出 MockK 这等傀儡。
本官断案数十年,积累了一套"铁面无私"的审判准则。以下便是代码公堂上的最佳实践,望诸位谨记在心。
每场审判都应遵循"升堂三步曲":先布置案情(Arrange),再让嫌犯行动(Act),最后核验证据(Assert)。结构清晰、条理分明:
@Test
fun formatPrice_withDiscount_showsDiscountedPrice() {
// Arrange(布置案情)
val formatter = PriceFormatter()
// Act(令嫌犯行动)
val result = formatter.formatWithDiscount(100.0, 0.2)
// Assert(核验证据、宣判结果)
assertEquals("¥80.00", result)
}
本官审案看的是行为证据,不是嫌犯心里在想什么。好的测试只关注"做了什么",不关心"怎么做的"。如此一来,即使嫌犯改头换面(重构代码),只要行为没变,判决就不会被推翻:
// 不好:窥探嫌犯的内心(测试内部实现)
coVerify { api.post("/auth/login", any()) }
// 好:审查可观察的行为(测试外在结果)
assertTrue(viewModel.uiState.value.isLoggedIn)
| 常见错误 | 正确做法 |
|---|---|
命名含糊(test1) | 使用"方法_条件_预期结果"格式 |
使用 Thread.sleep | 使用 runTest + advanceTimeBy |
| 依赖真实网络/文件 | 使用 Fake/Mock 替代外部依赖 |
| 多个测试共享可变状态 | 在 @Before 中创建新实例 |
| 只测正常路径 | 同时覆盖边界条件和异常路径 |
| 忽略失败的测试 | 立即修复或删除过时的测试 |
最有价值的审判针对的是核心业务逻辑和常见罪行场景。本官的经验法则:如果某段代码曾经犯过案(出过 Bug),就必须对它建立长期监控(补上测试)。有前科之人,更需严加看管!不要追求审遍天下所有案件,但绝不能放过任何惯犯。
为以下 StringUtils 类编写完整的单元测试。要求覆盖正常情况和边界条件(空字符串、特殊字符等),每个测试方法都使用规范的"方法_条件_预期结果"命名。
class StringUtils {
fun reverse(s: String): String = s.reversed()
fun isPalindrome(s: String): Boolean = s == s.reversed()
fun wordCount(s: String): Int = s.trim().split("\\s+".toRegex()).count { it.isNotEmpty() }
}
记住包青天的教诲:不能只审"好天气"下的表现。对于 isPalindrome,要测试回文和非回文两种情况;对于 wordCount,要考虑空字符串、前后有空格、多个连续空格等边界案情。
class StringUtilsTest {
private val utils = StringUtils()
@Test
fun reverse_normalString_returnsReversed() {
assertEquals("olleH", utils.reverse("Hello"))
}
@Test
fun reverse_emptyString_returnsEmpty() {
assertEquals("", utils.reverse(""))
}
@Test
fun isPalindrome_palindromeString_returnsTrue() {
assertTrue(utils.isPalindrome("abcba"))
}
@Test
fun isPalindrome_nonPalindrome_returnsFalse() {
assertFalse(utils.isPalindrome("hello"))
}
@Test
fun wordCount_normalSentence_returnsCorrectCount() {
assertEquals(3, utils.wordCount("hello world kotlin"))
}
@Test
fun wordCount_emptyString_returnsZero() {
assertEquals(0, utils.wordCount(""))
}
@Test
fun wordCount_extraSpaces_ignoresSpaces() {
assertEquals(2, utils.wordCount(" hello world "))
}
}
假设有一个 TodoViewModel,它维护一个待办事项列表,支持添加和删除操作。请编写测试覆盖以下案情:添加一个待办事项后列表长度变为 1;添加空标题的待办事项时应显示错误信息;删除待办事项后列表恢复为空。
class TodoViewModel : ViewModel() {
private val _todos = MutableStateFlow<List<String>>(emptyList())
val todos: StateFlow<List<String>> = _todos.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun addTodo(title: String) {
if (title.isBlank()) { _error.value = "标题不能为空"; return }
_todos.value = _todos.value + title
}
fun removeTodo(title: String) {
_todos.value = _todos.value - title
}
}
此案不涉及 viewModelScope,所以不需要 MainDispatcherRule。直接创建 ViewModel 实例,调用方法后检查 todos.value 和 error.value 即可。记住"升堂三步曲":Arrange-Act-Assert。
class TodoViewModelTest {
private lateinit var viewModel: TodoViewModel
@Before
fun setUp() { viewModel = TodoViewModel() }
@Test
fun addTodo_validTitle_addedToList() {
viewModel.addTodo("买菜")
assertEquals(1, viewModel.todos.value.size)
assertEquals("买菜", viewModel.todos.value.first())
}
@Test
fun addTodo_blankTitle_showsError() {
viewModel.addTodo(" ")
assertEquals("标题不能为空", viewModel.error.value)
assertTrue(viewModel.todos.value.isEmpty())
}
@Test
fun removeTodo_existingItem_removedFromList() {
viewModel.addTodo("买菜")
viewModel.removeTodo("买菜")
assertTrue(viewModel.todos.value.isEmpty())
}
}
为以下 CountdownTimer 编写 Turbine 测试。要求验证 Flow 依次发射 3、2、1、0,然后正常完成。
class CountdownTimer {
fun countdown(from: Int): Flow<Int> = flow {
for (i in from downTo 0) {
emit(i)
delay(1000)
}
}
}
使用 runTest 包裹整个测试(它会自动跳过 delay)。在 .test { } 块中依次用 awaitItem() 接收每个值,最后调用 awaitComplete() 确认 Flow 已结案。
class CountdownTimerTest {
private val timer = CountdownTimer()
@Test
fun countdown_from3_emitsDescendingValues() = runTest {
timer.countdown(3).test {
assertEquals(3, awaitItem())
assertEquals(2, awaitItem())
assertEquals(1, awaitItem())
assertEquals(0, awaitItem())
awaitComplete()
}
}
}
给定以下 WeatherRepository 接口,分别用 Fake 和 MockK 两种方式为 WeatherViewModel 编写测试。对比两种方式的代码量和可读性,体会包青天"Fake 优先"的判案哲学。
interface WeatherRepository {
suspend fun getTemperature(city: String): Result<Int>
}
class WeatherViewModel(
private val repo: WeatherRepository
) : ViewModel() {
private val _temp = MutableStateFlow("")
val temp: StateFlow<String> = _temp.asStateFlow()
fun loadWeather(city: String) {
viewModelScope.launch {
repo.getTemperature(city)
.onSuccess { _temp.value = "${it}°C" }
.onFailure { _temp.value = "获取失败" }
}
}
}
Fake 方式:创建一个 FakeWeatherRepository 类,实现接口,通过 shouldSucceed 标志控制返回成功或失败。MockK 方式:使用 mockk<WeatherRepository>() 创建 mock 对象,用 coEvery 配置返回值。两种方式都需要 MainDispatcherRule。
// === Fake 方式(包青天推荐)===
class FakeWeatherRepository : WeatherRepository {
var shouldSucceed = true
var temperature = 25
override suspend fun getTemperature(city: String): Result<Int> =
if (shouldSucceed) Result.success(temperature)
else Result.failure(Exception("网络错误"))
}
class WeatherViewModelFakeTest {
@get:Rule val rule = MainDispatcherRule()
private val fakeRepo = FakeWeatherRepository()
private val viewModel = WeatherViewModel(fakeRepo)
@Test
fun loadWeather_success_showsTemperature() {
fakeRepo.temperature = 30
viewModel.loadWeather("北京")
assertEquals("30°C", viewModel.temp.value)
}
@Test
fun loadWeather_failure_showsError() {
fakeRepo.shouldSucceed = false
viewModel.loadWeather("北京")
assertEquals("获取失败", viewModel.temp.value)
}
}
// === MockK 方式 ===
class WeatherViewModelMockTest {
@get:Rule val rule = MainDispatcherRule()
private val mockRepo = mockk<WeatherRepository>()
private val viewModel = WeatherViewModel(mockRepo)
@Test
fun loadWeather_success_showsTemperature() {
coEvery { mockRepo.getTemperature("北京") } returns Result.success(30)
viewModel.loadWeather("北京")
assertEquals("30°C", viewModel.temp.value)
}
@Test
fun loadWeather_failure_showsError() {
coEvery { mockRepo.getTemperature("北京") } returns
Result.failure(Exception("网络错误"))
viewModel.loadWeather("北京")
assertEquals("获取失败", viewModel.temp.value)
}
}