单元测试:最小、最快、最多的那一层
金字塔的底盘。你写的绝大多数测试,都该是这一层 —— 因为它快到几乎免费,你可以拿它去穷尽每一个边界、每一个分支。但「单元测试」这个词被用得很松,松到有人拿它指代任何「不是手点的测试」。这一章把它收紧:一个单元测试,应该只碰纯粹的逻辑、跑在 JVM 上、几毫秒结束、不依赖任何外部世界。达不到这几条,它就在慢慢变成别的东西 —— 一个跑得慢、还时不时抽风的假单元测试。
「单元」到底多大
争论最多的就是这个词。一个方法?一个类?一个模块?
其实纠结「一个单元 = 一个类还是一个方法」是抓错了重点。更有用的定义是行为导向的:
不是一个类、一个方法这种结构单位,而是一个行为单位:「满 100 打八折」「空购物车结算返回 0」「过期券抛异常」。它可能只用到一个方法,也可能牵动两三个协作的小对象 —— 只要它们能被孤立地、快速地验证,就是一个合适的单元。
之所以强调「行为」而非「结构」,是因为按结构测(每个方法一个测试)会把测试焊死在实现上 —— 你一重构(合并两个方法),测试就全红,哪怕行为没变。按行为测,重构才安全(第 18 章会展开这个坑)。
为什么单元测试跑在 JVM 上,不碰 Android 框架
这是 Android 开发者最该内化的一条。你的项目里有两个测试目录,它们是两个世界:
| 目录 | 跑在哪 | 速度 | 能碰什么 |
|---|---|---|---|
src/test/ | 本地 JVM | 毫秒级 | 纯 Kotlin/Java 逻辑。碰不到真的 Android 框架(Context、View、SharedPreferences 都是空壳)。 |
src/androidTest/ | 模拟器 / 真机 | 秒级起步 | 真的 Android 运行时。要装 APK、要连设备。这是集成/UI 测试(第 6、7 章)的地盘。 |
单元测试属于 src/test/。它之所以快,正是因为不启动 Android —— 没有模拟器、没有 APK 安装、没有真机通信。一个 JVM 单测跑完只要几毫秒,你可以在保存文件的一瞬间跑掉整个类。
Method ... not mocked新手常在 src/test/ 里不小心碰了 Android 的东西(比如 TextUtils.isEmpty()、Uri.parse()),然后撞上一个经典报错:Method isEmpty in android.text.TextUtils not mocked。
它在提醒你:你的「单元」漏进了框架依赖。正解通常是把逻辑和框架剥离 —— 别在业务逻辑里调 TextUtils,用纯 Kotlin 的 isNullOrEmpty()。如果实在绕不开,那说明这个测试其实需要 Robolectric(第 20 章),它是「在 JVM 上模拟一个 Android」的方案,比真机快但比纯单测慢。
FIRST:好单元测试的五个字母
Bob Martin 总结的 FIRST 原则,是单元测试的体检表。逐条对照:
- F — Fast(快):毫秒级。慢的单测你不会频繁跑,也就失去了单测的意义。
- I — Independent(独立):测试之间不能有依赖顺序。测试 B 不能靠测试 A 留下的状态。乱序跑、只跑一个,都得过(否则就是第 17 章的 flaky 温床)。
- R — Repeatable(可重复):同样的输入,每次同样的结果。不依赖今天几号、不依赖随机数、不依赖网络。跑一万次都一样。
- S — Self-validating(自验证):测试自己判定通过还是失败(靠
assert),不需要人肉去看输出对不对。「打印一堆东西让你自己看」不是测试。 - T — Timely(及时):最好在写实现的同时(甚至之前,见第 3 章)就写。事后补测试,往往补不全、还容易迁就 bug。
这五条里,I 和 R 是最常被违反、后果最阴险的 —— 违反它们的测试会「大部分时候是绿的」,于是你以为没事,直到某天它随机红一次。第 17 章整章讲这个。
隔离:单元测试不碰外部世界
FIRST 里的「可重复」直接推出一条硬规矩:单元测试不能碰任何不确定的、慢的外部资源。具体就是这几样:
- 网络 —— 慢、会断、结果不定。单测里出现真的 HTTP 请求,它就不是单测了。
- 数据库 / 磁盘 —— 慢,而且带状态。(内存数据库是集成测试的手段,第 6 章。)
- 系统时钟 ——
System.currentTimeMillis()/LocalDate.now()让结果依赖「跑的时候几点」。跨零点就红。 - 随机数 ——
Random()让每次结果不同,直接违反可重复。
解法是同一个词:依赖注入。把这些不确定的东西,从外面传进来,而不是在函数内部直接取。这样测试时你就能塞一个确定的替身进去:
// ✗ 时钟藏在里面 —— 没法测「会员刚好到期那天」
fun isExpired(user: User): Boolean =
user.expiresAt.isBefore(LocalDate.now()) // now() 依赖今天几号
// ✓ 时钟从外面传进来 —— 测试想让「今天」是几号都行
fun isExpired(user: User, today: LocalDate): Boolean =
user.expiresAt.isBefore(today)
@Test fun `到期日当天不算过期`() {
val u = User(expiresAt = LocalDate.of(2026, 7, 12))
assertFalse(isExpired(u, today = LocalDate.of(2026, 7, 12))) // 边界!
}
把 now() 变成参数,是让代码可测的最常见改造。Kotlin 里更工整的做法是注入一个 Clock 对象。记住这句话:可测性不是测试的属性,是代码的属性 —— 难测的代码,几乎总是设计上把该分开的东西粘在了一起。
一个完整的例子:测 ViewModel 的纯逻辑
Android 里,ViewModel 是单元测试的甜点区 —— 只要你没把 Android 框架焊进去。看一个登录表单校验:
class LoginViewModel(private val validator: Validator) {
fun onSubmit(email: String, pwd: String): FormState = when {
email.isBlank() -> FormState.Error("邮箱不能为空")
!validator.isEmail(email) -> FormState.Error("邮箱格式不对")
pwd.length < 8 -> FormState.Error("密码至少 8 位")
else -> FormState.Valid
}
}
class LoginViewModelTest {
private val vm = LoginViewModel(RealValidator())
@Test fun `空邮箱报错`() =
assertEquals(FormState.Error("邮箱不能为空"), vm.onSubmit("", "12345678"))
@Test fun `密码 7 位报错`() = // 边界:7
assertEquals(FormState.Error("密码至少 8 位"), vm.onSubmit("a@b.com", "1234567"))
@Test fun `密码正好 8 位通过`() = // 边界:8
assertEquals(FormState.Valid, vm.onSubmit("a@b.com", "12345678"))
}
注意那两个边界测试(7 位报错、8 位通过)—— pwd.length < 8 这行,是「差一 bug」最爱的地方(< 还是 <=?8 到底算不算够?)。第 9 章会专门讲怎么系统地挑这种边界。
单元测试的甜点区,和它的盲区
诚实地说清楚它能干什么、不能干什么,你才知道别的层为什么存在。
甜点区:纯逻辑、算法、状态计算、边界处理、失败分支。这些又快又能穷尽,是你最该重投的地方。
盲区:它结构上就看不见「部件接起来对不对」。你把每个方块都单独测绿了,但它们之间的接线 —— SQL 写错了、JSON 字段名对不上、线程调度出了岔 —— 单元测试一概照不到。因为你为了「隔离」,恰恰把这些连接给假掉了。
这正是下一章的主题。单元测试全绿、线上照炸,不是悖论 —— bug 只是从方块内部,搬到了方块之间。
1. 找一段你项目里"没法单测"的逻辑,看它为什么难测。九成是里面藏了 now()、Random()、网络调用或某个 Android 单例。试着把它注入进来。
2. 给一个含 < / <= / > 的判断写测试,专门测那个边界值本身(等于的那一点)。这是最高性价比的单测。
3. 检查你的 src/test/ 里有没有偷偷碰 Android 框架的测试。如果有 Robolectric 或大量 mock 框架类,它们其实是集成测试穿了单测的马甲。
下一章,我们走进方块之间的缝。集成测试专治那些「单元测试结构上看不见」的裂缝 —— Room 的 SQL、Retrofit 的 JSON、时区、编码。你会用上内存版数据库和一个假的 HTTP 服务器。