为什么测试全绿,程序还是错了?
Gary Bernhardt 的隔离测试和 Andrej Karpathy 的训练演示里,代码出错时,屏幕上都可能传来好消息。
改完代码,测试全绿。你有理由比刚才更放心。可如果有人问「哪种错误即使还在,这些测试也会绿?」,你会想到什么?两位讲者各给了一个容易漏掉的答案。
测试替身和代码可以一起搞错
Gary Bernhardt 在《Boundaries》里讲了一个例子。程序要找出到期的用户,再发出邮件。为了单独测试这段流程,可以用一个替身代替真实的用户查询。你预先告诉它会查到谁,测试就能很快检查下一步有没有调用邮件服务。
这里藏着一个循环:写代码的人对真实接口有一个理解,又按同一个理解写了替身。如果理解错了,代码和替身依然可以配合得天衣无缝。绿色结果告诉你流程在这个约定下能走通;真正的用户查询是否也遵守这个约定,还得去另一处看。
if you don't stub the boundary correctly, your test will pass and your production system will be wrong.回看原片 4:14–4:25 →
连真实运行的程序,也可能给你好消息
看完第一个例子,很容易想到「那我把真的程序跑一遍」。Karpathy 恰好这么做了。他亲手写了一个小型神经网络,训练时用梯度决定每个参数往哪个方向调。预测越来越接近答案,训练误差降得很快。然后他指出了代码里的一处错误。
这段训练本该在每轮重新计算前把旧梯度清零,程序却把上一轮的数值也加了进来。这个小问题恰好很容易学,累加让参数迈出了更大的步子。修好之后,误差反而降得慢了一些。只看「误差有没有下降」,两个版本都会过关,错误版本甚至更讨喜。
you may have lots of bugs in the code and uh your network might actually work回看原片 2:13:44–2:13:51 →
把「误差下降」换成一个能算错的预言
上面的小实验里,第二轮开始时参数是 3。判断实现是否正确,可以先写下预言:此刻的损失是 w²/2,对当前参数的导数应当是 3。它不是「大概朝下」的方向,而是一个具体数值。反向传播的微积分讲解把同一检查推广到网络:沿一条计算路径,把几段局部变化率相乘,才得到当前权重对损失的影响。
This right here is the chain rule, where multiplying together these three ratios gives us the sensitivity of c to small changes in WL.回看原片 3:45–3:55 →
现在再看累积出的 7。它也能让参数朝零走,所以损失下降的测试会放行;但它不等于刚刚手算的 3。这一次,失败能指向具体原因:使用的量含有上一轮的贡献。这和故意跨多个样本累积梯度是两种不同的更新约定。
相等、够近、还是同一笔钱?
以为 0.1 是一个能原样储存的输入,很容易把失败的相等测试当作算法漏洞。它在二进制里循环,有限长度的浮点表示先作了一次近似。几何计算可以先约定误差界;若问题是这两张收据的总价是否相同,而所有价格都按整分记,直接对整数分求和就能保留这项承诺。给测试写容差以前,先说说被容许的差额在业务里代表什么。
对照原文与时间戳
We've run out of digits!回看原片 6:07–6:14 →
store everything as an integer in terms of pence, cents回看原片 8:13–8:18 →
标准弹头也有一道验收题
实验室的标准弹头上有人为制造的纹路。和案件样品一起跑它,能帮人判断仪器是否还能读出已知的特征。这里比「用一个熟悉的输入做测试」更严格:参照物的特征应当事先独立测定,材料还应接近待测对象。否则,两次得到相同读数,可能只说明你很稳定地测错了东西。
对照原文与时间戳
you would want to use the closest standard reference material回看原片 6:26–6:31 →
They weren't fired through a gun, they were manufactured.回看原片 14:19–14:21 →
两个实现同意,还是它们读了同一份错误的文档?
Tom Scott 和代码助手都把 Gmail 标签理解成作用在单封邮件上,而他报告的 Apps Script 行为以会话为单位。给出一个会话内包含两封邮件的反例,比再让另一个人照文档重写程序更能区分两种解释。这里要找的是会让实现给出不同可见结果的输入。
对照原文与时间戳
made the same mistake I did with my backup.回看原片 7:59–8:03 →
treats labels as attaching to threads not messages.回看原片 8:05–8:09 →
没有正确实现时,这个测试会变红吗?
green-tests 已经追问过:测试与实现若共享同一个误解,绿色就可能只证明二者一致。Cooper 补充一个编写测试时可检查的环节:先把测试当作接口的第一位消费者,表达调用者期待的行为,再确认它在缺少正确实现时确实会失败。这个 red 信号不能证明所有需求都被覆盖,但能暴露某些即使功能没有实现也一直为绿的检查。他对脆弱测试的反思也有限定:优先测试行为,并不等于取消集成验证;避免测试互相干扰,也不等于必须模拟每个类或禁止访问数据库。依赖替换的理由仍要落到共享状态、速度和所选边界。这里采用的是讲者口述的方法与经验,没有核验代码画面,也不把他的个人项目结果当成普遍保证。
对照原文与时间戳
the test becomes the first consumer of your code回看原片 34:44–34:52 →
my test will fail in the absence of correct implementation回看原片 40:29–40:37 →
让你怀疑的错误有机会露面
把两段放在一起看,第一处要追问的是「换回真实依赖,这个约定还成立吗?」;第二处已经跑了真实代码,要追问的是「照这个标准,做错了也能通过吗?」这两种疑问需要不同的检查。
下次评审代码时,可以挑一条正在通过的测试,试着说出一个它应该抓住的具体错误。比如「用户查询返回的结果和替身约定的格式不同」,那就用一次真实查询对照两者。换成「第二轮更新混入了第一轮的梯度」,那就挑一个能手算两轮的小例子,直接核对第二轮的更新值。再有意引入你怀疑的错误,看看检查会不会变红。
同样是一盏绿灯,这时你能更准确地说出自己有把握的那件事。