幕 II · 测试的形状CH 05进度 05/24

单元测试:最小、最快、最多的那一层

金字塔的底盘。你写的绝大多数测试,都该是这一层 —— 因为它快到几乎免费,你可以拿它去穷尽每一个边界、每一个分支。但「单元测试」这个词被用得很松,松到有人拿它指代任何「不是手点的测试」。这一章把它收紧:一个单元测试,应该只碰纯粹的逻辑、跑在 JVM 上、几毫秒结束、不依赖任何外部世界。达不到这几条,它就在慢慢变成别的东西 —— 一个跑得慢、还时不时抽风的假单元测试。

什么是单元JVM vs 真机FIRST 原则隔离外部世界一次一件事

「单元」到底多大

争论最多的就是这个词。一个方法?一个类?一个模块?

其实纠结「一个单元 = 一个类还是一个方法」是抓错了重点。更有用的定义是行为导向的:

◆ 判词 · 单元 = 一小块「可以孤立验证的行为」

不是一个类、一个方法这种结构单位,而是一个行为单位:「满 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.I.R.S.T
  • 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 服务器。