为什么测试是给重构买保险
没测试时改代码像走雷区:怕动一处坏三处。有了测试,每次改动跑完用例就能知道有没有把旧功能弄坏。测试的最大回报不在写的那一刻,而在三个月后你敢不敢大改。
测试不是为了证明代码能跑,而是为了让未来的修改有安全网。
这篇从单元测试到 UI 测试,讲怎么在安卓里把这件事落地。
测试金字塔
测试分三层,数量从底到顶递减。底层是大量快速的单元测试,测纯逻辑;中层是集成测试,测模块协作;顶层是少量慢速的 UI 测试,测关键用户路径。全堆 UI 测试会又慢又脆,全堆单元测试又覆盖不到真实流程。
- 单元测试最多最快,测 ViewModel/工具类。
- 集成测试测数据库、仓库层。
- UI 测试最少最关键,测主流程。
第一个 JUnit 单元测试
单元测试放在 test 目录,跑在本地 JVM 上,不依赖安卓设备。给定输入、执行方法、断言输出。
import org.junit.Assert.assertEquals
import org.junit.Test
class CalcTest {
@Test fun `正数相加正确`() {
assertEquals(3, add(1, 2))
}
}
方法名用反引号写中文描述,失败时一眼看懂哪个场景出了问题。
用 Mock 隔离依赖
被测逻辑如果真的去发网络请求或读数据库,测试又慢又不稳定。用 Mock 把依赖替换成假对象,只验证"调用了正确的方法",不关心真实实现。
@Test fun `登录成功会回调用户`() = runTest {
whenever(repo.login("a", "b")).thenReturn(User("a"))
vm.login()
assertEquals("a", vm.ui.value.user?.name)
}
仓库被打桩,测试只关注 ViewModel 的状态流转,毫秒级跑完。
ViewModel 与协程测试
测带协程的 ViewModel 要用 runTest,它把虚拟调度器跑快,不用真等延时。把 viewModelScope 换成测试调度器,异步逻辑就变成同步可断言的。
核心断言状态流:加载中、成功、失败三种分支都要有用例覆盖。
Espresso UI 测试
UI 测试放 androidTest 目录,跑在真机或模拟器上,像真人一样点界面。Espresso 通过找到控件、执行操作、断言可见来写用例。
- onView(withId(R.id.user)) 找到输入框。
- perform(typeText("a")) 输入。
- check(matches(isDisplayed())) 断言结果出现。
只给最关键的路径写 UI 测试(登录、下单、支付成功),不要给每个按钮都录一遍。
测试的组织与覆盖
覆盖率是参考不是目标。盲目追高覆盖率会逼你写一堆没价值的用例。真正重要的是:边界条件、错误分支、历史上出过 bug 的地方,必须有用例。
把测试和生产代码放一起演进——改需求先改测试,让测试描述新行为,再让代码通过它。
实战:给登录逻辑写测试
登录要覆盖三条路径:空字段提示、密码错误、登录成功。
@Test fun `空账号显示错误`() = runTest {
vm.login("", "123")
assertTrue(vm.ui.value.error?.isNotEmpty() == true)
}
@Test fun `正确凭据进入主页`() = runTest {
whenever(repo.login("a","123")).thenReturn(User("a"))
vm.login("a","123")
assertTrue(vm.ui.value is UiState.Ready)
}
三条用例把登录的主要分支都钉住了。以后改登录逻辑,跑一遍就知道有没有改坏。测试写到这份上,重构才真的敢动手。