幕 III · 手艺CH 09进度 09/24

挑输入:等价类、边界值,和 bug 藏在哪

第 2 章说,断言写好了,是你对「对」的定义。但定义得再准,喂错了输入也白搭 —— 你的断言只对你喂进去的那几个值负责。而一个函数的输入空间几乎是无限的,你不可能全试。好消息是:你也不用。bug 不是均匀撒在输入空间里的,它们扎堆。这一章讲两件事:怎么把无限的输入压成几个有代表性的(等价类),以及 bug 最爱扎堆的那个地方(边界)。掌握它,你用五个输入抓到的 bug,比新手用五十个还多。

穷举不可能等价类划分边界值刁钻输入清单对抗性思维

穷举是不可能的

一个函数 fun f(age: Int),Int 有约 43 亿个取值。你不可能每个都测。就算能,一个函数 fun g(a: Int, b: Int) 也有 43 亿的平方个组合 —— 宇宙热寂之前跑不完。

所以测试从来不是「试所有输入」,而是聪明地挑几个代表。挑得好不好,是新手和老手的分水岭。挑输入有两把系统性的工具。

第一把:等价类划分

核心洞察:很多输入,对代码来说是「一样的」。它们会走同一条分支、触发同样的行为。这样一组输入,叫一个等价类(equivalence partition)

◆ 判词 · 一个等价类,测一个就够

如果 age = 25age = 40 会让代码走完全相同的路径、返回相同性质的结果,那测了 25 就没必要再测 40 —— 它们在同一个等价类里。测一个代表,等于测了整类。

做法:把输入空间切成几段,每段是一个「代码眼里一样」的区间,每段挑一个代表。原来无限的输入,压成了三五个。

拿「租车年龄 18–65」举例,输入空间自然分成三个等价类:

  • 太小(< 18):应拒绝。代表:10。
  • 合格(18–65):应接受。代表:30。
  • 太大(> 65):应拒绝。代表:80。

三个输入,覆盖了三种行为。比随手测 5、7、23、41、55 这种「全落在同一类里」的输入高效得多。别在一个等价类里反复挑,那是白费力气。

第二把:边界值 —— bug 的老巢

等价类告诉你「每类挑一个」,但挑哪一个最好?答案是:挑边界上的。因为 bug 极少发生在区间中间,它们几乎全都扎堆在区间的交界处

下面这个探测器把这件事演给你看。规格是「18–65 接受」,但 AI 写的实现把 >=18 手滑成了 >18(漏了正好 18 岁那一刻)。你先点「各区间内部随手挑」的那些值,再点「边界值」的那些值,看哪个抓到了 bug。

结果一目了然:10、30、50、80 这些区间内部的值,全都测不出 bug(实现和规格一致,绿);而 18 一点就红。这不是巧合 —— 这是所有「差一 bug(off-by-one)」的共性:

◆ 判词 · bug 睡在边界上,因为边界是决策翻转的地方

代码里的每一个 < / <= / > / >=,都是一个决策翻转点。而「用 < 还是 <=」「循环到 n 还是 n-1」「≥ 还是 >」正是最容易写错、也最难一眼看出的地方。

所以边界值测试的规矩是:对每个边界,测它的两侧和它本身。区间 18–65,该测的是 17、18、1964、65、66 —— 尤其是那个「等于」的点(18 和 65)。区间中间的值几乎不含信息量。

一张「刁钻输入」清单

除了数值边界,还有一批「特殊到自成一类」的输入,是 bug 的常客。写测试时,把这张清单在脑子里过一遍,几乎每次都能捞到东西:

类别该试的刁钻值常炸出的 bug
集合 / 字符串空([] / "")、单个元素、超大除零、越界、first()
数字0、1、-1、最大值、最小值符号处理、溢出、除零
null / 缺失null、缺字段、后端不返回空指针、默认值错
字符串内容emoji、组合字符、多语言、空格、换行长度算错(第 1 章的 truncate)、截断出乱码
顺序 / 重复已排序、逆序、有重复、乱序去重错、排序不稳定
时间零点、月末、闰年、时区边界、夏令时差一天、时区偏移

回想第 1 章那个 Demo 的四个反例 —— 空列表(average)、除不尽(splitBill)、边界(totalPages 的 25/10)、全负数(maxOf 的初始值)—— 全都能在这张清单里对号入座。这不是巧合:AI 写的 bug,和这张清单高度重合。

换个姿势:从「证明能用」到「想办法弄崩」

这些工具背后,是第 1 章那个「证伪」心态的具体化。新手挑输入时问的是「我要证明它能用」,于是挑的全是会过的值(区间中间、happy path)。老手问的是「我要怎么把它弄崩」,于是直奔边界、空、零、溢出。

◆ 判词 · 挑输入,就是在给你的证伪心态配弹药

「等价类 + 边界值 + 刁钻清单」,本质上是一套系统化地寻找反例的方法。它把「想办法弄崩」从灵光一现,变成了可以按部就班执行的流程 —— 遇到任何输入区间,自动就去打它的边界和特殊值。

AI 时代:它天生只喂 happy path

这一章的意义,在 AI 时代被放大了,原因和第 1 章一脉相承:

▲ AI 挑输入,几乎全是 happy path

你让 AI「给这个函数写测试」,它挑的输入清一色是正常值 —— average([80, 90, 100])canRent(30)。因为在它见过的代码里,示例大多是这种「典型用法」。它不会主动去打边界、去喂空、去想溢出 —— 那需要的正是它缺的东西:对抗性的、专门找茬的意图。

所以审查 AI 写的测试时,第一件事就是数它喂了几个边界值和刁钻值。通常答案是:零。而 bug 恰恰全在那儿。补边界,是你在 AI 协作里最高性价比的一个动作。

多个输入:别测全组合,测「配对」

前面讲的是单个输入。真实函数常有多个参数,而参数一多,组合就爆炸:一个函数有 4 个参数、每个 3 种取值,就是 3⁴ = 81 种组合。全测是灾难。

好消息是,绝大多数 bug 是由一到两个参数的特定取值触发的,很少需要三个参数同时取到某个刁钻值才炸。这引出一个高性价比的技巧 —— 配对测试(pairwise / all-pairs):

◆ 判词 · 覆盖所有「两两组合」,而非所有组合

配对测试的目标是:让任意两个参数的任意取值组合,都至少出现一次,而不追求所有参数的全组合。数学上,这能把 81 种组合压到 9 种左右,却能抓到绝大多数「组合类」bug(经验上,多数缺陷是单参数或双参数交互引起的)。

你不用手算 —— 有现成的工具/库(如 pict、jqwik 的 @ForAll 组合、kotest 的组合生成)替你生成这张最小配对表。它是「穷举太多、单参数又不够」时的那个中间档。

复杂条件:用「决策表」把它摊平

当一段逻辑由多个布尔条件交织(是不是会员 × 是不是首单 × 金额是否达标 → 折扣多少),等价类不够用了 —— 你需要决策表把所有「条件组合 → 结果」的规则列清楚,然后每一行规则,至少一个测试:

会员首单满 100→ 折扣
叠加(会员 9 折 + 首单 8 折)
会员 9 折
首单 8 折
无折扣

决策表的价值,是把「藏在一串 if/else 里、你数不清有几种情况」的逻辑显式化。摊开之后你常会发现:有些组合的行为你自己都没想清楚(会员的首单大单,三个折扣到底怎么叠?)—— 而那些「没想清楚」的格子,正是 bug 和需求歧义最爱藏身的地方。列决策表本身,就是一次需求澄清。

几个高频的边界,值得刻进肌肉记忆

数值边界之外,有几类边界在真实代码里反复咬人,单独列出来:

  • 集合的空与一:emptyList()listOf(单个)first()/reduce()/求平均在空集合上崩,循环逻辑在「只有一个」时露馅(比如「用逗号连接」在单元素时不该有逗号)。
  • 字符串的长度陷阱:含 emoji、组合字符的字符串。"👨‍👩‍👧".length 在很多语言里不是 1 —— 按「字符数」截断会截出乱码(第 1 章那个 truncate 的近亲)。
  • 日期的边界:月末(28/29/30/31)、闰年(2 月 29,还有「百年不闰、四百年再闰」这种,第 18 章的 2100 年)、跨零点、时区分界、夏令时切换那一小时。日期逻辑几乎总有你没想到的边界。
  • 数值的极值:Int.MAX_VALUE 附近的加法(溢出变负)、除以 0、非常小的正数。

下一章会给出一个更狠的解法:与其自己一个个想边界,不如写下一条「对任何输入都该成立的规律」,让机器灌一千组随机输入(包括各种刁钻值)去替你撞 —— 那就是第 13 章的属性测试。但在那之前,先解决一个更基础的问题:当你要测的东西,依赖一个慢的、不确定的协作者(数据库、网络、时钟),怎么办?

✚ 动手

1. 找一个带区间判断的函数(任何含 < / >= 的),列出它的等价类,然后只测每个边界的两侧和本身。看看能不能捞到一个差一 bug。

2. 拿"刁钻输入清单"过一遍你最近写的一个函数:空?零?负?null?emoji?挨个问「它处理了吗」。

3. 让 AI 给一个函数写测试,然后数它喂了几个边界值。大概率是 0。你来补上,看能不能立刻捞到一个它漏掉的 bug。