第15章:测试入门

写出有信心的代码 —— 用自动化测试为你的应用保驾护航

⚖️

本章导师:包青天

核心方法论:铁面无私,代码必须过堂

「开封有个包青天,铁面无私辨忠奸。代码世界亦需如此——每一个函数都是嫌疑人,每一个断言都是呈堂证供,每一个测试用例都是一场公正的审判。不要相信任何代码的'自我辩护',要用测试来验明正身。今日,本官便教你如何在代码的公堂上,明察秋毫、铁面无私。」

15.1 为什么要写测试

堂下何人!本官见过太多开发者,写完代码便自以为万事大吉,从不审查、从不验证——这就好比衙门断案只听原告一面之词,便急匆匆下判决,岂非荒唐?写测试就是给你的代码开一场公正的审判:让每个函数上堂受审,拿出证据(断言)来证明自己清白无辜。觉得"写测试是浪费时间"的人,就好比说"审案是浪费时间"——等到冤假错案酿成大祸,悔之晚矣!

Bug 的成本与重构的信心

本官断案多年,深知一个道理:罪犯(Bug)被抓得越晚,造成的危害就越大。开发阶段发现问题,不过费几分钟功夫;若是放任它逃到生产环境,便可能要紧急发版、回滚数据,犹如罪犯流窜作案后才被通缉,追捕成本何止百倍!自动化测试的核心价值就是在案发之初便将嫌犯缉拿归案。同时,有了完善的测试套件,你便可以放心大胆地重构代码——跑一遍测试全部通过,便如同所有案件复审均无误判,可以安心签字画押。好的测试本身也是最好的案宗档案:它明明白白地记录着"当输入是 X 时,输出应该是 Y"。

测试金字塔

本官治理辖区,讲究层层把关。代码的测试体系亦当如此,遵循测试金字塔这一审判体系:

测试层级比例速度覆盖范围
单元测试约 70%毫秒级单个函数/类
集成测试约 20%秒级多个组件协作
UI 测试约 10%分钟级完整用户流程

本官将之比作三级司法体系:单元测试如同县衙地方官,数量最多、审案最快,处理单个函数的小案件;集成测试犹如省级按察使,审理多个组件之间的协作纠纷,速度稍慢但管辖更广;UI 测试则如同朝廷大理寺终审,模拟完整的用户流程,虽然耗时最长,但一旦通过便可保天下太平。三级审判缺一不可,方能确保代码江山社稷稳固。

包青天断案准则

不要妄图对天下所有事都管辖到——追求 100% 覆盖率并不现实。明智的做法是优先审理最容易出事最关键的案件。将有限的精力用在刀刃上,方为上策。

15.2 测试项目配置

工欲善其事,必先利其器。要在代码公堂上开庭审案,首先要把审判的场所和工具准备妥当。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 原生的替身术,可以伪造证人来辅助破案。

15.3 单元测试基础

单元测试乃是本官审理的第一类案件——将代码中最小的嫌疑单元提堂审问,给定特定的"案情"(输入),看其是否给出正确的"供词"(输出)。若供词与事实不符,当即判定有罪!以下这个计算器类便是今日的第一个嫌疑人:

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()
在 Android Studio 中开庭审案

右键点击测试类或方法,选择 "Run" 即可升堂。绿色表示嫌犯清白无罪——无罪释放;红色表示罪证确凿——当庭宣判有罪!

15.4 测试 ViewModel

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

今日提堂受审的是 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
)

完整的审判记录——ViewModel 测试

以下是对 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)
    }
}
本官严令:不得遗漏 MainDispatcherRule

若测试报错 Module with the Main dispatcher had failed to initialize,便是你忘了设置审判环境!每个涉及 viewModelScope 的测试类都必须声明 MainDispatcherRule。这就好比开庭审案却忘了挂"明镜高悬"的牌匾——程序不合法,审判无效!

15.5 测试协程

协程是代码世界中的"潜逃犯"——它们会挂起、延迟、在不同线程之间逃窜。要审讯这类狡猾的嫌犯,需要特殊的刑侦手段。kotlinx-coroutines-test 提供的 runTest 便是本官的"追踪令",可以自动跳过 delay 调用,让协程嫌犯无处遁形、瞬间归案:

@Test
fun fetchUser_returnsUserData() = runTest {
    val repo = UserRepository(FakeApiService())
    val user = repo.fetchUser(id = 1)
    assertEquals("张三", user.name)
}

StandardTestDispatcher vs UnconfinedTestDispatcher

审讯协程有两种策略,恰如两种不同的审讯风格:

调度器执行方式适用场景
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 中的挂起函数

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)
    }
}

15.6 测试 Flow

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()
}

用 Turbine 开庭审讯

每次 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()
    }
}

Turbine 审讯工具箱

方法用途
awaitItem()等待并返回下一个发射值
awaitComplete()断言 Flow 已正常完成
awaitError()断言 Flow 以异常结束
expectNoEvents()断言此刻没有新事件
cancelAndIgnoreRemainingEvents()取消收集(用于 StateFlow 等无限流)

审讯 StateFlow 时需格外留意:Turbine 会自动将其初始值作为第一条"供词"(第一个 awaitItem()),且由于 StateFlow 永不自行结案,必须用 cancelAndIgnoreRemainingEvents() 强制退堂。

15.7 Compose UI 测试

如果说单元测试是县衙审案、集成测试是省级会审,那么 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("登录") }
    }
}

大理寺终审——完整的 UI 测试

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()验证组件可交互
testTag 使用原则——身份标签不可滥用

本官告诫:传唤证人优先用其真名实姓(onNodeWithTextonNodeWithContentDescription),只在无法通过姓名辨认时才贴"嫌犯编号"(testTag)。满堂的编号标签会让生产代码变得杂乱不堪,如同案卷堆积如山却不分类归档。

15.8 Mock 与 Fake

审案断狱,有时需要"替身证人"——用测试替身替代真实的网络、数据库等外部依赖。否则每次开庭都要联网、连数据库,审案效率何其低下!本官常用两类替身:Fake(假扮证人,有简化的真实行为)和 Mock(傀儡证人,只会背诵预设台词)。

对比项FakeMock
实现方式手写的简化实现框架自动生成
包含逻辑有简化的业务逻辑只返回预设值
适用场景Repository 等核心依赖边缘依赖、验证调用行为
可读性更清晰直观需配置桩(stub)

打造 Fake 替身证人

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——傀儡证人术

若不想亲自打造替身,可以用 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) }
    }
}
本官断案原则:Fake 优先

本官与朝廷(Google 官方)一样推荐优先使用 Fake。Fake 的供词更可预测,案卷更易读。只有在接口方法太多(手写替身太累)或者需要核查"嫌犯是否确实被传唤过"时,才考虑请出 MockK 这等傀儡。

15.9 测试最佳实践

本官断案数十年,积累了一套"铁面无私"的审判准则。以下便是代码公堂上的最佳实践,望诸位谨记在心。

Arrange-Act-Assert 模式——升堂三步曲

每场审判都应遵循"升堂三步曲":先布置案情(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),就必须对它建立长期监控(补上测试)。有前科之人,更需严加看管!不要追求审遍天下所有案件,但绝不能放过任何惯犯。

本章练习

练习 1:初审计算器 入门

为以下 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  "))
    }
}

练习 2:审讯 ViewModel 进阶

假设有一个 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.valueerror.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())
    }
}

练习 3:用 Turbine 审讯 Flow 进阶

为以下 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()
        }
    }
}

练习 4:Fake vs Mock 对决 挑战

给定以下 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)
    }
}
« 上一章:依赖注入实战 目录 下一章:综合项目实战 »