软件不是一次写完的静态作品,而是在一次次修改中演化的系统。人的意图(intent)、明确写出的规格(specification,简称 spec)和实际实现(implementation)之间,始终存在距离:我们可能没有准确表达自己要什么,也可能表达清楚了,却没有把它正确实现出来。这就是 intent-spec-impl gap。
生成式 AI 大幅降低了修改软件的成本,却没有让这两道鸿沟自动消失。不断向 Agent 许愿,确实可能得到一个越来越像样的应用;但如果我们只知道它在几次演示中表现如何,却不知道内部发生了什么,就可能逐渐失去对软件的掌控。理解一个已经演化得很复杂的系统,可以从某个自己还能理解的历史时刻开始,沿着后续变化重新建立认识。这里借用“rebase”的说法,强调的是把理解接回一条能够追踪的演化过程。
现在,我们仍然需要能驾驭软件。模型写完几百行代码,界面打开了,按钮也能点,并不意味着需求已经得到可靠实现。本讲讨论其中的 spec-impl gap:怎样知道实现符合规格?怎样让藏在程序里的错误更容易暴露?
人类程序员也会一本正经地写出错误代码。Shell 命令会敲错,变量名会看错,从上一段复制过来的代码会漏改一个字母。下面这段循环,看起来十分眼熟:
for (int i = 0; i < n; i++)
for (int j = 0; j < n; i++) {
// 对每一对 (i, j) 做点事情
}
内层循环应该递增 j,却递增了 i。只要 n > 0,内层的 j 就一直是 0,循环不能按预期推进;如果继续递增有符号整数 i 直到溢出,还会进入 C 语言的未定义行为。人的眼睛很容易把实际写出的代码,自动补全成自己以为写出的代码。
模型同样不能仅凭“感觉”得到正确程序,尤其是一次写出几百行、还要照顾许多关联约束时。生成代码的能力相比过去已经近乎神迹,我们甚至开始嫌模型跑得慢;但“生成得快”和“生成得对”仍然是两件需要分别检验的事。
为了讨论错误怎样出现,需要区分三个概念。
j++ 写成 i++。这三个概念不是三个同义词。程序有 fault,不代表这次运行一定出现 error;出现 error,也不代表用户立刻就能看到 failure。
仍看上面的循环。如果 n = 0,循环体根本不会执行,错误代码就藏在那里。再设想一个购物车:添加商品时,程序错误地把缓存中的总价算成了两倍。如果结账前总价会从商品清单重新计算,内部曾经出现错误状态,最终账单却可能是正确的;如果界面直接显示那个缓存值,错误就暴露给了用户。有没有 failure,既取决于后续执行,也取决于观察什么。
因此,质量保障不能只等用户投诉。更好的架构、语言约束和防御性编程,可以减少某些缺陷产生的机会;测试主动寻找能触发缺陷的输入;断言则把本来可能隐藏很久的错误状态,尽早变成可观察的测试失败。例如在更新购物车后检查“缓存总价等于各项金额之和”,就不必等到错误账单出现才发现问题。
可以把后两种方法放到两条箭头上理解:测试推动 Fault → Error,断言帮助 Error → Failure。这里主动暴露 failure 的目的是及时定位错误,避免让错误状态继续扩散。
测试看起来是最便宜的质量保障方法:顺便运行几次呗。但运行一个程序,需要的条件比命令行参数多得多。
首先,你的环境不是别人的环境。Windows、macOS 和 Linux 不同,硬件、编译器、解释器和依赖库的版本也可能不同。一份源码在开发者电脑上能运行,搬到另一台电脑却启动失败,并不稀奇。
这也是依赖锁文件存在的原因。以 npm 为例,package.json 描述项目的依赖要求,package-lock.json 记录实际解析出的依赖树,包括直接和间接依赖的具体版本等信息。把锁文件随代码一起保存,可以减少“同一份源码在不同时间装出不同依赖”的情况。不过,锁住依赖树不等于锁住整个运行环境,操作系统和运行时版本仍需另行考虑。参见 npm 的 package-lock.json 文档。
其次,即使环境相同,你的运行也不是别人的运行。文件内容、系统负载、gettimeofday() 返回的时间、网络延迟与中断,都可能改变执行结果。两个线程谁先运行、在哪里被切走,也会影响结果。两个线程同时执行“读取计数器、加一、写回”,如果都先读到 0,最后就可能只得到 1;单线程测试重复运行一万次,也不会覆盖这种交错。
从这个意义上说,时间、网络响应和线程调度也是程序的输入。测试要探索的空间,远远不止函数参数的取值。
我们希望程序在规格约定的任何环境、任何输入下,都符合 specification。这里既有功能性要求,例如结果符合设计、合法操作不会导致崩溃;也有非功能性要求,例如性能和安全性。登录功能能成功一次,与高负载下仍能及时响应、用户无法越权访问,是不同的要求。
有限次测试通常不能证明这个“任何”。测试发现了一个反例,就足以说明程序有错;运行一万次没有发现反例,却不能说明第一万零一次一定正确。这就是“测试能显示 bug 的存在,而不能显示 bug 的不存在”的含义。
但不要因此低估测试。程序是严格执行的过程,合适的输入能非常有效地排除错误实现。例如一个所谓的排序函数原样返回输入,遇到已经排好序的数组时会蒙混过关;对 n 个不同元素的均匀随机排列,它碰巧正确的概率只有 1/n!。这个计算针对的是这种具体的错误实现,不能直接推广成“所有 bug 都很难通过随机测试”,却很好地解释了为什么选择有区分力的输入如此重要。
许多有代表性的用例通过以后,我们就获得了程序“大概能工作”的证据。对于 Agent,这些证据还可以进入下一轮修改:写代码、运行、观察结果、再修改。SWE-agent 的研究把这种交互能力落实为专门的 Agent-Computer Interface(ACI),让模型能够浏览仓库、编辑文件、执行测试和其他程序。质量改进依赖于这个反馈过程,而不只是一次生成的自信程度。
软件工程中的测试,首先可以按观察范围分层。拿一个选课系统来说,四个层次分别在回答不同的问题。
单元测试检查一个函数或模块。例如,输入学生已修课程和某门课的先修要求,判断函数是否给出正确结果。它应该尽量小、快,并且容易把错误定位到局部。
集成测试检查模块连接起来以后是否协作正确。先修检查返回的字段,选课服务是否理解?数据库记录的课程编号,与接口传来的编号是否一致?两个模块单独工作正常,不代表接起来一定正常。接口不只是数据能传过去,还包括双方对数据含义、单位、错误情况的理解。
系统测试面对完整系统,在目标环境中走完流程。例如学生登录、查询课程、选课、刷新页面,再确认结果仍然存在。真实的服务、存储、身份验证和界面共同参与,才能发现只在整合之后出现的问题。
验收测试进一步追问:这些行为满足用户需求和约定的验收标准吗?技术上“提交成功”,不代表业务上“选课成功”。如果课程已满时系统仍允许超额选课,或者选课结果没有对学生清楚呈现,系统就可能违背了用户真正关心的规则。
这些层次互相补充。局部测试便于定位问题,整体测试验证连接和真实流程,验收则把检查接回需求。把所有检查都塞进完整界面流程,通常既慢,又难以解释一次失败到底发生在哪里。
挑选测试输入有两种基本视角。黑盒测试从规格出发,不依赖实现内部结构;白盒测试从代码出发,检查哪些逻辑实际被执行过。
黑盒测试可以先做等价类划分:把预计由同一种规则处理的输入分成一组,从每组挑代表。比如一个最多接受 100 个元素的接口,空数组、允许范围内的非空数组、超过上限的数组,就可能对应不同的处理规则。再做边界值分析,重点检查 0、1、99、100、101 等容易出现差一错误的位置。
测试之前必须知道预期结果。超过上限时应该拒绝、截断,还是返回某种错误?这是规格问题。若测试作者也不知道答案,运行程序只是观察现象,还没有完成验证。
规则涉及多个条件时,可以使用决策表。选课时“是否有余量”和“是否满足先修要求”有四种组合,逐一写清应该接受还是拒绝,就不容易只测最顺利的一条路。行为依赖历史时,可以使用状态转换:未选课程可以变成已选,已选可以退回未选,那么重复选课、重复退课、退课后重新选课,各自应该怎样处理?
白盒测试关注语句覆盖、分支覆盖和路径覆盖。执行过一行代码,叫覆盖了这条语句;一个条件的不同分支是否走过,是另一层问题;多次判断组合出的执行路径,又是更大的空间。循环和并发很快就会使路径数量大到无法穷尽。
覆盖率高不等于正确。假如加法函数误写成了减法:
def add(a, b):
return a - b
调用 add(1, 2) 就覆盖了函数中的计算语句。只有继续检查 assert add(1, 2) == 3,才抓住了错误;如果用例只检查 add(1, 0) == 1,它甚至会通过。覆盖告诉我们代码有没有被执行,断言告诉我们被观察到的结果是否符合要求,输入的选择则决定错误有没有机会暴露。
每个完整用例都应包含三个动作:准备环境和输入,执行操作,检查结果。缺少最后一步,就容易出现 Agent 跑了一大堆命令、得到一大串日志,却没有真正核对核心行为的情况。
测试工程师走进酒吧的笑话,正好指出另一种盲区:他测试了零杯、负数杯、极大数量以及各种奇怪的点单输入;普通顾客进门问厕所在哪里,酒吧却出问题了。笑话不是说边界值没有用,而是说测试者可能把全部精力放在“怎样点酒”上,却漏掉了用户实际会走的另一条流程。输入再刁钻,也不能代替对使用场景的理解。
测试金字塔是一种常见的组织方式:大量快速的单元测试,适量集成测试,少量端到端测试。越靠近局部,通常越容易控制条件、定位失败;越靠近完整系统,涉及的真实因素越多,代价也越高。它强调的是分工,不是要求所有项目遵守一个固定比例。
对难以控制的依赖,可以用 stub 或 mock 做替身。比如测试下单逻辑时,不必每次真的发起支付;可以让替身稳定返回“成功”“超时”或“余额不足”,检查相应处理。Stub 常用于提供预设响应,mock 还可以检查交互是否符合预期,例如同一操作有没有被调用两次。但替身正确模拟了什么、遗漏了什么,需要开发者负责;最终仍然要用真实依赖检查集成。
修复 bug 时,要把触发它的场景留下来,成为回归测试。最有说服力的检查,是确认同一个用例在修复前失败、修复后通过。这样,以后的修改如果把问题重新带回来,测试就能提醒我们。否则,一个从来抓不住原始错误的“回归测试”,只是在增加测试数量。
持续集成(Continuous Integration,CI)把这些检查变成每次修改后自动发生的事情。反馈越早,越容易把失败与刚刚引入的改动联系起来。测试本身也需要维护:用例要尽量独立,环境和数据可重复,失败信息能帮助定位问题。如果每次失败都要先猜“是不是测试自己又抽风了”,测试提供的信心就会不断被削弱。
生成式编程把大型任务变得前所未有地容易开始。课堂中的例子是向 Agent 许愿,做一个 CS:GO 风格的游戏。这是典型的 long-horizon task,也就是需要很多步骤、跨越较长执行过程的任务:生成代码、启动程序、操作界面、截图检查,再根据观察继续修改。
执行轨迹里,Agent 并没有偷懒。它反复截图验证,直到请求出现 413,也就是请求体过大的错误。这里值得追问的不是“有没有做测试”,而是这些测试到底观察到了什么。
一张截图可以帮助判断场景有没有显示、角色是否出现在预期位置,却很难充分验证射击规则、弹药消耗、碰撞判定、计时和胜负条件。反复从整个游戏界面出发,意味着每次检查都要同时依赖渲染、输入处理和游戏逻辑。软件系统越大,越难通过全系统整合测试覆盖它的行为。
因此,架构决定了质量。这句话的分量在于:架构决定哪些错误容易发生,也决定发生以后能不能被便宜而准确地检查出来。所有模块都应该尽量具备明确的接口,能够脱离系统其他部分单独测试。
例如,把游戏逻辑写成状态转换:输入旧状态和一个动作,输出新状态。测试“开枪后少一发子弹”,就不需要启动渲染器、移动鼠标再观察画面。弹匣为空能不能继续射击、目标生命值为零以后能不能再次受伤,也可以直接检查状态。
纯函数是这个方向上的有力工具:结果只由显式输入决定,不偷偷依赖或修改外部状态。同样输入得到同样输出,使局部推理和复现都容易得多。它不要求游戏没有状态,而是把状态作为数据明确传入、返回,让状态变化有可检查的边界。
时间和随机性也可以采用相同思路。测试冷却时间,不必真的等待几秒,而是把时钟作为依赖传入,由测试推进时间;测试随机战斗,则控制随机数来源,使某一段战斗能够重放。支付重试可以围绕操作标识设计去重规则,让重复请求不会轻易变成重复扣款。架构在这里不是画几张模块图,而是为真实的故障条件提供可控制、可观察的接口。
当面对一个真实系统时,背完测试分类还不足以知道下一步该怎么做。“所有测试理论都失效了,你需要的是灵光一闪”,说的是这种距离:我们还得找到一个适合当前系统的实验方式,把难以观察的行为变成可检查的证据。
学过计算机系统基础的同学,对 NEMU 不会陌生。NEMU 是一个用于教学的处理器模拟器。把程序跑在自己实现的模拟器上,运行了很久才发现游戏画面不对,应该从哪里开始找?最终画面距离某条指令的实现错误,可能已经隔了大量执行步骤。
差分测试(Differential Testing)的想法很直接:让两份实现从对应的初始状态出发,处理相同输入,再比较结果。对处理器,可以在指令执行后比较寄存器等体系结构状态,在首次出现分歧的位置停下来。原来的问题是“为什么游戏跑飞了”,现在变成“为什么执行这一条指令之后,两边的状态不同”。
两份独立实现的 bug 偏好可能不同。一方把操作数写反,另一方漏处理符号扩展,错误未必发生在同一个地方。因此,不必先由人手工写出整个复杂程序每一步的正确答案,就有机会发现异常。当然,两边也可能误解手册中的同一句话;结果一致是有用证据,却不是绝对正确的证明。出现分歧以后,还要分析哪一方违反了规格。
这套思路也进入了真实处理器的验证。香山的 DiffTest 框架让待测设计与 NEMU、Spike 等参考模型协同执行,比较验证状态。课堂举出的经历是:运行了 4 亿个时钟周期之后,发现香山的 mstatus CSR 状态不一致。CSR 是控制与状态寄存器,mstatus 保存处理器的一些关键运行状态。这样的例子说明,错误可能藏在很长的执行过程深处;自动比较提供了持续观察它的办法。
差分测试的关键贡献,是提供一种检查结果的机制。我们不必等到最终应用表现异常,再从海量历史中倒查,而是可以沿执行过程持续追问:两边是否仍然一致?
有了检查机制,还要让程序经历足够多、足够有价值的情况。模糊测试(Fuzz Testing)可以形象地理解为把软件按在地上“摩擦”:不断生成、变异输入,试着把隐藏的错误逼出来。
它本质上是一个搜索问题。所有文件内容、参数组合和操作序列构成巨大的输入空间,我们只能搜索其中很小一部分。如果每次都完全盲目地随机生成,许多输入可能刚到格式检查就被拒绝,根本进不到复杂逻辑。
AFL提供了一种带覆盖反馈的搜索思路:从已有输入出发进行变异,执行程序,观察是否到达了新的执行路径或状态转移;把有价值的输入留下,继续变异。课堂把它类比为遗传算法:输入发生变化,执行反馈提供“选择压力”,让搜索逐步进入新的区域。
例如,对一种文件格式的解析器,保留正确文件头、逐渐扰动长度字段和数据区,可能比每次生成一堆完全随机的字节更容易触及深层解析逻辑。这里的重点不是随机数越多越好,而是反馈能否引导我们探索尚未见过的行为。
搜索反馈和正确性检查仍然是两件事。执行到新代码,说明这个输入值得继续探索,并不说明它已经找到 bug;崩溃、超时、断言失败,或者与另一实现产生分歧,才提供了进一步检查错误的线索。因此,模糊测试可以与差分测试组合:一边负责找输入,一边负责比较结果。发现失败之后,留下能重现问题的输入,才能把偶然撞见的异常变成可以调试、可以回归的工程资产。
有些重要的输入,甚至不在电脑屏幕里面。课堂最后的例子是 San Mehat 向同事提出一个愿望:做一个能由软件控制的小装置,让 SD 卡插入、拔出,再把它接进一个闭环测试。
这个装置的价值不在于机械动作复杂,而在于它把原本依赖人的操作变成了程序能够重复安排的输入。软件发出操作,装置执行插拔,测试观察系统反应,再进行下一轮。反复插拔时才有机会出现的问题,终于可以持续追踪,而不必靠一个人坐在那里碰运气。
沿着这个思路,测试者可以进一步安排“正在访问卡时拔出”“拔出后再次插入”等操作序列,并记录出现异常时的条件。这些是从装置得到的测试设计思路,不意味着只要有了机器人,所有错误就会自动被发现。仍然需要知道观察什么、怎样判断异常、怎样复现它。
今天,我们可以把类似愿望交给 Agent,让它帮助编写测试程序、记录日志、控制设备或生成输入。代码生成成本下降以后,更值得投入精力的是那个实验设计:怎样让难以触发的条件可控制,让内部错误可观察,让一次偶发现象可以再次发生。能够想出这样的测试,并把它接回架构和日常开发,才是驾驭软件的一部分。