如何测试程序 — 改变认识与人工代码检查

More details about this document
Drafting to Completion / Publication:
Date of last modification:
2025-11-07T15:56Z
Creation Tools:
Emacs 31.0.50 (Org mode 9.7.11) ox-w3ctr 0.2.4
Public License:
This work by include-yy is licensed under CC BY-SA 4.0

在之前的一篇文章中,我们学习了如何为函数和变量编写规范的 docstring。在后续重构 ox-w3ctr 的过程中,我发现修改 docstring 时常会涉及到对函数本身的实现及对应测试代码的修改,一些蹩脚的测试改起来不比程序本身简单。既然要追求「可维护」的 docstring,那么「可维护」的单元测试自然也必不可少。

尽管文章标题聚焦于在 Elisp 中做测试实践,但大量的篇幅其实是《如何做软件测试》。​在本文和后续文章中,我们会探讨测试的基本概念与作用,介绍不同语言中的单元测试框架,并最终重点讲解如何使用 Elisp 自带的 ERT 框架来编写和运行单元测试。本文在很大程度上参考了《软件测试的艺术》(The Art of Software Testing) 和软件测试的维基词条内容,感兴趣的读者可以进一步了解。

拿我曾经比较熟悉的控制框图做类比的话,软件测试可以看作一个闭环控制系统,具体对应我会在下文展开。

1.png
https://www.polytechnichub.com/block-diagram-process-control-system/

1. 测试是为发现错误而执行程序的过程

…而不是为了证明软件​不存在错误​、或证明软件能够​正确完成​其预定功能的过程。

大概是五年前,在一次软件工程课上(我们自动化真是什么都学),老师提问「什么是软件测试?」。我的回答是「通过穷举程序所有输入并检查输出,来判断程序能否正常工作」。然后我就因为课前没有预习被叼了。这种「穷尽且证明正确」的思维模式,可能代表了许多初学者对软件测试的普遍误解。

The Art of Software Testing​(以下简称 TAST)一书,从经济学和心理学两个角度,阐述了将软件测试定义为「发现错误」而非「证明正确」的必要性。

1.1. 「证明不存在错误」在客观上不可行

如果我们将程序视为一个实现不可见的黑盒子,想要发现程序的所有错误,判断的标准就是将​所有可能​的输入条件都作为测试用例做穷举输入测试,在这种方法中,测试数据完全来源于软件规范,而不需要了解程序的内部结构。TSAT 使用了判断三个数能否构成等边三角形的程序来举例,这里我用一个更简单的例子,某程序将两个小于 1000 的正整数相加得到第三数,若参数不符合条件则输出 -1。

从穷举的思路出发,仅针对合法输入,你可能会写出这样的代码:

void test(void)
{
  for (int i = 1; i < 1000; i++)
    for (int j = 1; j < 1000; j++)
      assert(add(i, j) == i + j);
}

在上面的代码中,我们穷尽了 a 和 b 在 [1, 999] 内的所有 999×999≈106 种组合。尽管这个数字是有限且相对较小的,但这种穷举离发现所有错误还差得很远。测试还需要覆盖非法输入,根据规范,所有不符合条件的输入都应返回 -1。然而,非法输入的集合是无限的:它可能包括负数、零、超过 1000 的整数、浮点数、甚至是字符串或内存地址。能作为潜在错误输入的数据格式和值是无限的。

即使是对这样一个功能明确且参数范围有限的 Add 函数,我们都没法做到穷尽所有输入(非法输入空间无限),那么对于更复杂的程序,穷尽测试更是不可能。在复杂的真实世界应用中,输入空间和状态空间会呈现指数级爆炸,这主要体现在:

数据的无限性
许多程序的有效输入本身就是无穷的(更不用说非法输入)。比如,一个 JSON Parser 的输入是所有合法的 JSON 格式字符串,一个 C++ 编译器的输入是所有合法的源代码。
状态的组合爆炸
程序的行为不仅取决于当前输入,还取决于其​历史状态​和​外部环境​。如果程序涉及到数据存储,比如数据库操作,它不仅要测试所有有效和无效的事务处理,还要测试所有可能的事务处理顺序。
环境的动态依赖
现代复杂的软件系统几乎不可能独立运行,它们严重依赖于外部环境因素和运行时条件,这些因素的组合和状态是无法穷尽的。测试需要考虑不同的硬件和操作系统,各种极端的资源状态,依赖的外部服务和接口等等,这些动态且相互作用的环境因素,使得任何基于固定环境的穷尽测试都失去意义。
需求的模糊性
即使代码是完美的,程序也无法「正确完成」其预定功能,因为预定功能本身可能是模糊的。需求文档往往用自然语言编写,导致其存在不完备或模糊不清之处,例如要求「系统应妥善处理」非法输入但未定义何为「妥善」。更进一步,如果规范将用户可能遇到的非法输入定义为未定义行为,这本质上是将风险转移给了用户,是规范本身的缺陷。测试无法穷尽所有对模糊规范的解释,也无法证明一个缺乏健壮性定义的规范是正确的。

除了黑盒测试外,TAST 也说明了在白盒测试中做穷尽路径测试的不可行性。所谓白盒测试,是指测试人员了解并利用程序内部逻辑结构、代码和设计细节来设计测试用例的一种方法。由于穷尽测试从原理上来说就不可行,我们也就无法通过测试来保证一个程序是无错的。测试投入的目标在于通过​有限的测试用例​,最大限度地提高发现问题的数量,以取得最好的测试效果。

1.2. 「证明正确」在主观上不可取

既然证明程序不存在错误是不可能的,那么我们应该转向尽力证明程序能够正常工作吗?对此,TAST 同样给出了否定的答案:​人类行为总是倾向于高度的目标性​,而目标设定对测试的成功有着关键的心理学影响。

如果我们将测试的目的设定为「证明程序中不存在错误」,测试人员在潜意识中就会倾向于实现这个目标,从而选择可能较少导致程序失效的测试数据,产生确认偏误。相反,如果我们的目标在于「证明程序中存在错误」,测试人员则会本能地采取一种怀疑、破坏甚至「施虐」的思维模式,设计出更有针对性的、能够暴露程序弱点的测试用例。这种目标导向的转变使得后者能更有效地发现缺陷,从而更多地增加程序的价值。

TAST 中提到大多数的项目经理会将没发现错误的测试用例称为「成功的测试」,而发现了新错误的测试反而被称为「不成功的测试」,这是一种本末倒置的看法。TAST 作者认为如果在测试程序时发现了错误且错误可以被修复,那么合理设计并得到有效执行的测试就是「成功的」。所谓「不成功的」测试,仅指那些未能适当地对程序进行检查,导致程序在测试下输出正确的结果而没发现任何错误的测试。大多数情况下未能找出错误的测试被认为是「不成功的」。

现在我们知道了「证明软件不存在错误」是一个几乎不可能完成的目标。心理学研究表明,当人们在开始一项工作时,如果已经预知它是不可行或无法完成的,其工作表现往往会大打折扣,产生畏难情绪。而将软件测试定义为发现程序错误的过程,就将一个无限的任务转化为了一个可以完成的任务。这一转变有效克服了心理障碍,使得测试人员能够更积极、更有效地投入到缺陷探索中。这种定义不仅提高了士气和积极性,还暗示了测试的最终目的:通过对错误的不断研究,来建立程序「做了其应该做的、未做其不应该做的」这一最终信心。

总结一下,软件测试更适宜被视为​试图发现程序中错误(假设其存在)的破坏性过程​。一个成功的测试用例,是通过诱发程序发生错误,来促进软件质量的改进。当然,测试的最终目的,仍然是通过建立某种程度的信心,来证明软件「做了其应该做的,未做其不应该做的」。但是,通过对错误的不断研究,是实现这个目的的最佳途径。

2. 阅读代码也是测试?

多年以来,软件界的大多数人都持有一个想法,即编写程序仅仅是为了提供给机器执行,并不是供人们阅读的,软件测试的惟一方法就是在计算机上执行它。20 世纪 70 年代早期,一些程序员最先意识到​​阅读代码对于构成完善的软件测试和调试手段的价值​,通过他们的努力,原有的观念开始发生变化。

今天,并不是所有的软件测试人员都要阅读代码,但是​研读程序代码作为测试工作的一部分,这个观念已经得到了广泛认同​。以下几个因素会影响到特定的测试和调试工作需要人工实际阅读代码的可能性:软件的规模和复杂度、软件开发团队的规模、软件开发的时限(例如时间安排表是松散还是紧密)等,当然还有编程小组的技术背景和文化。

基于这些原因,在深入研究较为传统的基于计算机的测试技术之前,我们首先讨论非基于计算机测试的过程(即“人工测试”)。​人工测试技术在查找错误方面非常有效​,以至于任何编程项目都应该使用其中的一种或多种技术。​应该在程序开始编码之后、基于计算机的测试开始之前使用这些方法​。同样,也可以在编程过程的更早阶段就开始设计和应用类似的方法(例如在每个设计阶段的末尾),但是这些内容超出了本书讨论的范围。

在开始讨论人工测试技术之前,有一条重要的注意事项:由于包含了人为因素在内,导致很多方法的正规性要差于由计算机执行的数学证明,人们可能会怀疑某些如此简单和不正规的东西是否有用。反之亦然。这些不正规的方法并没有妨碍测试取得成功;相反,它们从以下两个方面显著地提高了测试的功效和可靠性。

首先,人们普遍认识到​错误发现得越早,改正错误的成本越低​,正确改正错误的可能性也越大。其次,程序员在开始基于计算机的测试时似乎要经历一个心理上的转变。从内部产生的压力似乎会急剧增长,并产生一个趋势,要“尽可能快地修正这个缺陷”。由于这些压力的存在,程序员在改正某个由基于计算机测试发现的错误时所犯的失误,要比改正早期发现的问题时所犯的失误更多一些。

《软件测试的艺术》第三章 — 代码检查、走查与评审

既然测试是为发现错误执行代码的过程,那仅仅是阅读代码也能叫测试吗?虽然狭义的软件测试是动态测试(即计算机运行程序),但广义的测试还包括静态测试,其中最具代表性的就是代码检查,走查和评审 (Inspections, Walkthroughs, and Reviews)。某种意义上来说,读代码就是在「运行代码」:当工程师仔细研读代码时,他们的大脑充当了 CPU,在进行一种高强度的认知模拟执行。他们会选择假设的输入,追踪变量状态和逻辑分支,并预测输出,这个过程​在本质上​与机器的动态执行是完全一致的。

2.1. 代码是给人看的,机器顺便能执行

TAST 提到上世纪 70 年代就有了从「代码给机器执行」到「代码要给人看」的认识转变,不过他并没有进一步展开。这里让我尝试给出一种解释。论证思路大概是 早查早省提前检查代码​,再到 要求代码可读​。

「错误发现得越早,改正错误的成本越低」是一条跨越所有复杂系统管理领域的普遍真理,缺陷随着时间的推移,在系统中不断积累和耦合会导致指数级的修复成本。从治病救人的角度来说,最佳的境界是防患于未然,正如《鶡冠子·世賢》所言:『長兄於病視神,未有形而除之,故名不出於家。中兄治病,其在毫毛,故名不出於閭。若扁鵲者,鑱血脈,投毒藥,副肌膚,閒而名出聞於諸侯。』,上等的医者在病未有形时便将其消除,中等的医者在病灶尚在毫毛时便能治愈。而像扁鹊那样名声远扬的,反而是因为需要「鑱血脈,投毒藥」才能救治,证明病已深重。

从个人财产管理来看,统计收支、建立合理的消费预算、购买适当的保险来应对意外,其成本远低于陷入负债或信用破产时进行危机救火。同样,在环境保护领域,严格控制工业废水和生活污水的排放标准,在源头进行深度处理,是最高效的方式;这远远优于发生重大水污染事故后,投入巨额资金和漫长时间进行生态修复和饮用水源的紧急更换。这些案例共同证明:成本是随着流程的推进,因返工和不可逆资源投入而倍增的。很多时候,一旦第一颗扣子扣错,我们就不得不付出十倍、百倍的代价,甚至不得不重来了。

鉴于此,如果将动态测试类比为建筑的验收工作,那么我们最好能在设计图阶段(也就是编码阶段)就发现大量的错误。然而,要使人工的代码检查有效,必须先解决一个根本问题:如果仅仅是写给机器执行的没有可读性的代码,人就难以高效阅读并发现错误。代码不仅是机器的执行指令,更是程序员的设计图纸和沟通介质。​当代码具有高可读性时,它就成了一张清晰的「施工图纸」,极大地降低了人脑模拟执行和追踪逻辑的认知负担,使得审查人员能够更早、更准确地发现设计意图上的偏差和深层次的缺陷,从而实现了在编码阶段就进行预防性纠错。

为了将「代码的可读性有意义」这一认知从理念转化为实践,软件工程界付出了持续的努力:新的编程语言通过引入高级抽象、面向对象、强类型系统等特性,使代码能够用更接近人类思维的方式来组织和表达逻辑;代码风格和命名约定的标准化等规范体系消除了阅读代码时的视觉和认知障碍,保障了代码的清晰度和一致性;通过自动化静态分析工具 (Linters) 强制执行代码规范,以及将代码审查工具整合进开发流程,确保了在代码进入动态测试之前,必须经过人类和机器的双重阅读和验证。

在 11 月上旬和朋友讨论 Scheme 的 case-lambda (SRFI-16) 实现时,他给了我一段几乎看不下去的代码(大量使用宏且不卫生),这一实现的不可读也许是我写这一小节的动力来源。

2.2. 人工测试技术在查找错误方面非常有效

在典型的程序中,这些方法通常会有效地查找出 30%~70% 的逻辑设计和编码错误。但是,这些方法不能有效地查找出高层次的设计错误,例如在软件需求分析阶段的错误。请注意,所谓 30%~70% 的错误发现率,并不是说所有错误中多达 70% 可能会被找出来,而是讲​这些方法在测试过程结束时可以有效地查找出多达 70% 的已知错误。​请记住,第 2 章告诉我们,程序中的错误总数始终是未知的。

当然,可能存在对这些统计数字的批评,即人工方法只能发现“简单”的错误(即与基于计算机的测试方法相比,所发现的问题显得微不足道),而困难的、不明显的或微妙的错误只能用基于计算机的测试方法才能找到。然而,一些测试人员在使用了人工方法之后发现,​对于某些特定类型的错误,人工方法比基于计算机的方法更有效,而对于其他错误类型,基于计算机的方法更有效。​这就意味着,代码检查/走查与基于计算机的测试是互补的。缺少其中任何一种,错误检查的效率都会降低。

不但这些测试过程对于测试新开发的程序有着不可估量的作用,而且对于测试更改后的程序,这些测试过程具有相同的作用,甚至更大。根据我们的经验,修改一个现存的程序比编写一个新程序更容易产生错误(以每写一行代码的错误数量计)。因此,除了回归测试方法之外,更改后的程序还要进行这些人工方法的测试。

目前为止,我们尚未深入探讨自动测试的具体细节,因此还无法​直接​对比两者优缺点,或讨论它们如何互补配合。然而,基于前文的论述,即使我们不能完全忽略直接的人力成本投入,但从软件生命周期的总成本效益来看,人工测试(代码检查/走查)仍是一种极具经济性的行为,它能够实现缺陷的早期发现,并在特定错误类型上达到高发现效率。那么,我们究竟该如何理解和论证人工测试的这种有效性呢?

TAST 同样没有比较具体地说明这一点,我尝试补充一下。本节的开头我们已经提到人工测试就是广义上的由人脑来「执行」代码,人脑相比呆板的计算机来说,简直可以说是一种​高维存在​了。这种「高维」认知能力可能就是人工测试具有极高有效性的原因,人脑在处理信息时,超越了计算机测试的线性、单维执行模式。

执行与理解
机器测试只能根据给定的输入集,沿着预定的、有限的路径进行执行和验证,但人看到代码时能进行​符号化和逻辑抽象​。以循环边界检查为例,人不会停留于循环变量的单个测量值,而是将它抽象为一个逻辑符号并推导出边界集合;例如,我们看到 for(i=0;i<N;i++) 便能立即推断其逻辑意图(循环 N 次),并迅速推导出完整的边界集合(​i=0, i=N-1 等)。这种无运行的符号驱动的逻辑推演能力是机器测试的​穷举式验证​无法比拟的。
定量与定性
机器测试只能判断功能是否正确,但人可以对代码进行​定性、美学和工程学​的判断。例如,代码是否过于复杂、冗余、耦合度高;变量命名是否清晰、符合语义;逻辑是否可以重构以提高性能。这些是影响软件长期维护成本的关键因素,但它们可能并不会导致功能测试失败。
局部与整体
机器测试局限于程序的外部行为和可量化的最终输出,缺乏对系统全貌和业务全景的宏观理解。人工测试具有​跨越模块边界和文档类型​进行关联思考的优势。虽然代码检查这类人工方法本身难以发现需求分析阶段的根本性错误(如引文所述),但其价值在于我们不仅检查代码是否正确实现了既定的设计,更会批判性地反思这个设计本身是否最有效地满足了更高层次的业务意图。这种跨越实现和设计层面的关联判断,使得人工测试能够发现「在技术上正确,但在结构或集成上不兼容」的结构性缺陷,这是机器测试在局部验证中无法企及的。
经验与预见
机器测试只能验证​确定且可重复​的行为,难以应对软件固有的不确定性。人工测试则能引入​基于实践经验的启发式思维​,实现缺陷的预防。具备领域知识的评审者能够凭借​模式识别​和​风险直觉​,预见到自动化测试难以稳定复现或想不到覆盖的隐患,例如:潜在的并发竞争、资源泄漏、或特定的安全漏洞模式。同时,这种经验赋予的弹性,使得评审者能够识别并消除代码中的语义歧义和非预期交互,从而在缺陷尚未产生危害之前就将其根除。这种经验驱动的风险预见能力,是超越可测试性限制,实现高价值缺陷预防的关键。

人工测试的有效性源于人类独有的​认知效率​和​工程审美​:它超越了机器测试具象、穷举式的执行限制,依靠符号化推演和基于经验的直觉,在不运行代码的情况下就能高效识别逻辑边界、并发隐患等复杂缺陷。同时,这种人工干预引入了对代码的定性、美学和工程学判断,以实践孕育出的审美追求代码的简洁、可读和结构一致性。最终,人工测试不仅是发现当前缺陷的高效手段,更是通过预见性风险评估和强制执行高质量标准,保障软件长期维护性和可持续性的关键,是实现高维质量目标不可或缺的一环。代码的缺陷最终只能由人来理解,而人之所以能理解,就是因为​人是人​(笑)

TAST 的 3.3 节给出了一份错误列表以检查程序是否存在常见错误。具体内容可以看看书,这里我给出总结截图:

2.png 3.png

2.3. 人工测试团队的组成与流程

在 TAST 第三章中,对检查 (Inspection) 和走查 (Walkthrough) 的流程已有详细阐述,故此处不再赘述原文细节,建议读者直接参考原文。本节将侧重于对该流程的核心理念进行概括,并分享我个人的理解和解读。

从团队组成上来说,代码检查小组由协调者代码作者设计人员测试专家组成。这个组合非常讲究,缺一不可,每个角色都提供了一个独特的审查视角。协调者负责安排进程,确保评审流程的中立和高效,记录发现的所有错误并确保错误随后得到改正;代码作者负责讲解代码,澄清代码逻辑和回答疑问;设计人员从宏观视角关注代码是否遵守设计规范和架构;测试专家凭借较高的软件测试造诣和对常见编码错误的熟悉,系统性地对照错误列表,运用专业知识找出潜在的错误和漏洞。为了凸显每个人在团队中的作用,也许我们可以假想抽调某个人:

  • 若缺乏协调者: 评审团队将失去中立的流程管理者。讨论极易失控,评审活动将偏离发现错误的核心目标,陷入对纠正错误的无休止争论,甚至引发效率低下的争吵。更严重的是,没有协调者的记录与追踪,缺陷信息将不完整、不规范,导致大量发现的缺陷最终被遗漏,使得评审投入功亏一篑。
  • 若缺乏代码作者: 评审活动将失去上下文信息的唯一来源。当评审员对复杂逻辑产生疑问时,无人能提供第一手的实现意图和设计背景,这极大地提高了理解成本和误判风险。作者不在场,也使得发现问题后无法在现场快速高效地定位和确认问题根源,严重拖慢了评审周期。
  • 若缺乏设计人员: 评审团队将失去宏观架构的守护者。无人能将评审代码与高层级架构和设计规范进行系统性对照。这直接导致团队无法发现结构性缺陷,例如模块职责错位或接口不兼容等问题,最终可能通过一段技术正确但宏观上错误的代码。
  • 若缺乏测试专家: 评审团队将失去缺陷模式的预见者。由于缺乏对常见编码错误列表的专业对照和启发式风险评估能力,团队对边界条件、并发竞争或资源管理等难以用肉眼直接发现的高风险缺陷的敏感度将大大降低,从而严重削弱缺陷发现的深度和有效性。

代码检查活动遵循一个清晰的三段式流程:首先是准备阶段,协调者需在会议前几天将程序清单和相关的设计规范分发给所有小组成员,所有成员必须在检查会议开始前认真熟悉并预习这些材料。其次是检查执行阶段,在严格控制在 90 至 120 分钟的理想会议时间内,由代码作者主导,逐条语句地向小组讲述程序的逻辑结构和实现意图,而其他小组成员则负责积极提问,并根据作者的讲解和预习所得来判断和指出代码中潜在的错误。最后是会后收尾阶段,会议结束后,协调者会向程序员提供一份已发现错误的正式清单,供其后续进行改正和修复,从而确保缺陷得到追踪和解决。

和代码检查相比,走查的流程也差不多,但在会议过程中会使用一些简单的书面测试用例来进行实际测试以验证或质疑逻辑思路。作者建议的小组成员组成如下:

4.png

不管是检查还是走查,团队测试通过引入中立的、多专业的视角来系统性地消除了个人开发者难以克服的「作者偏差」和「认知盲区」。代码检查同样也是一个学习的过程,程序员会得到编程风格、算法选择及编程技术方面的反馈信息。然而,TAST 也提到检查结果应仅限于内部技术改进,一旦管理层试图将代码检查结果作为 KPI ,它作为质量保障工具的价值将彻底瓦解。

TAST 在本章的最后才提到「桌面检查」:由一个人阅读程序,对照错误列表检查程序,对程序推演测试数据。虽说测试不应该仅由开发者本身来做,但它还是胜于完全没有检查的情况。在存在团队协作的情况下这通常被认为是最低效的人工方法,但对我们个人开发者或独立项目来说,这是不可避免且必要的。考虑到社区提问和 LLM 工具的存在,也许我们可以利用它们来提高检查的有效性。

3. 中场休息

到这里,我们对 TAST 第二、三章的核心内容 —— 「发现缺陷的测试理念」和「人工代码检查的有效性与实践」 —— 进行了一些整理和论述。原本我计划在这篇文章中涵盖 TAST 的全部内容,但写到这里我已经感觉不对劲了,虽然 TAST 篇幅不长,它的内容却极其丰富且富含洞察。因此,为了保证后面内容的质量,我还是先到这里打住,把更多内容留待后续文章展开。在接下来的系列中,我们将继续学习如何设计高效的测试用例、黑白盒测试的实践技巧、模块测试的策略、以及可用性测试等关键知识。

老一辈程序员似乎喜欢通过一次性完成代码且无错来彰显个人水平,这可能是因为在早期开发环境下,编辑器易用性差、调试测试成本极高。能够减少返工和节省宝贵的计算资源,是开发经验丰富和工程效率极高的体现。然而,正如我们所知,我们无法证明代码是无错的。随着现代开发工具的易用性提高和资源的极大丰富,编写可读、可测试、并能有效融入团队协作与持续改进流程的代码,才成为衡量当代专业水平的核心标准。

在本文的开头我用了一张反馈控制系统的框图试图说明控制框图与软件测试之间的相似性,但这一模型可能更适合能够频繁发生的机器测试,而不是检查或走查,毕竟人工测试是一个结构化、分阶段的过程,而不是连续的调整。对于人工测试直接使用流程图是更好的选择。

TAST 中描述的理想代码检查流程,其四角色团队组成对资源受限的中小企业或非职业程序员而言难以实现。在这种缺乏团队协作的现实困境下,人工测试的价值必须依赖于可行的、廉价的替代方案。这正是现代技术进步带来的机遇:通过将 AI 辅助与精益化的人工干预相结合,我们可以把理想检查流程中的专业角色职责,「外包」给 LLM。

3.1. 人工智能前提下的检查测试

在 2022 年 11 月 30 日,基于 GPT-3.5 的 ChatGPT 的公开发布成为了全球生成式 AI 浪潮的标志性事件。自此之后,编程工作不可避免地与 AI 深度融合。LLM 在代码生成和辅助方面通常能给出不错的结果,特别是在主流编程语言中效率斐然。有人乐观地预言未来所有的开发工作将由 AI 完成(对此我持保留意见),但至少目前来看,AI(或者说 LLM)仍然在面对缺乏全局上下文、训练数据偏差和无法理解业务意图等问题,以及臭名昭著的「幻觉」现象:模型生成了看似合理、实则错误或虚构的信息。

这种不确定性,使得人工检查不仅没有过时,反而变得更为关键。我们必须聚焦于一个核心问题:在 AI 大规模生成代码的前提下,人类检查员应如何与 LLM 协作,才能最大限度地发挥人脑的高维优势,并系统性地捕获和修正 LLM 可能引入的隐性缺陷。一种解决方案可能是利用 AI 承担低级检查的繁重工作,而让人的精力聚集于捕获和修正 LLM 可能引入的系统性高风险缺陷。

就我所知,Code Agent 已经能够根据源代码和文档生成单元测试代码了,而且质量还不低。但我还没怎么系统用过这类产品,而且本文侧重于人工测试而不是机器测试,还是让我们回到 LLM 的基本用法:论语式对话上来。就「读代码找错」这个需求来说,最简单的用法就是把代码丢给 LLM,然后在输入末尾加上一句「这段代码可能有什么问题?」,就比如下面这段我叫 Gemini 帮我生成的 Python 代码:

import os

def process_data_and_save(file_path, threshold):
    """
    读取指定文件的整数数据,计算平均值,并过滤掉低于阈值的数据。
    最后,将过滤后的数据总数写入一个临时日志文件。
    """
    data_list = []
    
    # --- 缺陷点 1: 资源管理和文件处理 ---
    try:
        f = open(file_path, 'r')
        for line in f.readlines():
            # 假设文件中的每行都是可以转换为整数的字符串
            data_list.append(int(line.strip()))
            
    except FileNotFoundError:
        print(f"Error: 文件 {file_path} 未找到!")
        return -1
    except ValueError:
        print("Error: 文件中包含非整数数据,无法处理。")
        return -1
    
    # --- 缺陷点 2: 边界条件和除零风险 ---
    total_sum = sum(data_list)
    average = total_sum / len(data_list)
    
    # --- 缺陷点 3: 逻辑错误 (比较运算符) ---
    filtered_data = [x for x in data_list if x > threshold]

    # 将结果写入临时日志文件
    log_file_path = "temp_process.log"
    # --- 缺陷点 4: 潜在的逻辑错误/安全隐患 ---
    with open(log_file_path, 'w') as log_f:
        log_f.write(f"Processed {file_path}. Average: {average:.2f}. Filtered count: {len(filtered_data)}")
    
    return len(filtered_data)

去掉代码中的注释,在一个新打开的 LLM 对话中粘贴代码并加上上面那句提示词,我在 Gemini 2.5 Pro 中得到了如下输出:

这段代码的功能基本清晰,但在健壮性(Robustness)、资源管理和效率方面存在几个比较典型的问题。

我来帮你逐一分析一下:

🔍 主要问题分析

  1. (高优先级) 潜在的 ZeroDivisionError 错误
    • 问题:如果输入文件 file_path 是一个空文件(或者只包含空行),​data_list 将会是一个空列表 []​。
    • 后果:执行 average = total_sum / len(data_list) 时,​len(data_list) 为 0,这将立即导致程序崩溃并抛出 ZeroDivisionError​。
  2. (高优先级) 文件资源未关闭
    • 问题:代码使用了 f = open(file_path, 'r') 来打开文件,但在 try 块成功执行完毕后,并没有调用 f.close() 来关闭文件。
    • 后果:这会导致“资源泄露”(Resource Leak)。如果这个函数被频繁调用,程序最终可能会因为打开了过多的文件句柄而耗尽系统资源导致崩溃。
  3. 内存效率问题(处理大文件时)
    • 问题:​f.readlines() 会一次性读取文件中的所有行,并将它们全部加载到内存中的 data_list 里。
    • 后果:如果 file_path 是一个非常大的文件(例如几个 GB),这会消耗巨量的内存,甚至可能导致 MemoryError​。
  4. 硬编码的日志文件路径
    • 问题:日志文件的路径 log_file_path = "temp_process.log" 被硬编码在函数内部。
    • 后果:
      • 可重用性差:调用者无法自定义日志文件的位置。
      • 并发问题:如果多个进程或线程同时运行此函数,它们会同时尝试写入同一个文件,造成内容混乱或写入失败(竞态条件)。
      • 覆盖问题:每次调用函数都会用 'w' (写入模式) 覆盖掉旧的日志内容。
  5. 未使用的导入
    • 问题:代码顶部 import os​,但 os 模块在整个函数中并未使用。
    • 后果:这是个小问题,但属于不良的编码风格,会给阅读代码的人带来困惑。

💡 改进后的代码示例

这里是一个修改后的版本,解决了上述所有问题:

# 'import os' 已被移除,因为未使用

def process_data_and_save(file_path, threshold, log_file_path="temp_process.log"):
    """
    读取指定文件的整数数据,计算平均值,并过滤掉低于阈值的数据。
    最后,将过滤后的数据总数写入一个指定的日志文件。
    """
    data_list = []
    
    # 1. 使用 'with' 语句自动管理文件开关 (解决问题 #2)
    # 2. 捕获所有潜在的读取错误
    try:
        with open(file_path, 'r') as f:
            # 3. 逐行读取,而不是一次性加载 (解决问题 #3)
            for line in f:
                stripped_line = line.strip()
                if stripped_line:  # 避免空行导致 ValueError
                    data_list.append(int(stripped_line))
                    
    except FileNotFoundError:
        print(f"Error: 文件 {file_path} 未找到!")
        return -1
    except ValueError:
        print(f"Error: 文件 {file_path} 中包含非整数数据,无法处理。")
        return -1
    except IOError as e:
        print(f"Error: 读取文件 {file_path} 时出错: {e}")
        return -1

    # 4. 检查空列表 (解决问题 #1: ZeroDivisionError)
    if not data_list:
        print(f"Warning: 文件 {file_path} 为空或不含有效数据。")
        average = 0.0
        filtered_data = []
    else:
        total_sum = sum(data_list)
        average = total_sum / len(data_list)
        filtered_data = [x for x in data_list if x > threshold]

    # 5. 将 log_file_path 作为参数传入 (解决问题 #4)
    #    并且也对日志写入进行异常处理
    try:
        with open(log_file_path, 'w') as log_f:
            log_f.write(f"Processed {file_path}. Average: {average:.2f}. Filtered count: {len(filtered_data)}")
    except IOError as e:
        print(f"Error: 无法写入日志文件 {log_file_path}。错误: {e}")
        return -2  # 返回不同的错误码以示区分

    return len(filtered_data)

# --- 如何调用 ---
# 假设有一个 data.txt 文件
# with open("data.txt", "w") as f:
#     f.write("10\n")
#     f.write("5\n")
#     f.write("30\n")
#     f.write("2\n")

# count = process_data_and_save("data.txt", 8, log_file_path="my_custom_log.log")
# if count >= 0:
#     print(f"处理完成,过滤后的数据有 {count} 条。")

# # 测试空文件
# with open("empty.txt", "w") as f:
#     pass
# process_data_and_save("empty.txt", 10)

总结改进点:

  1. 使用 with open(...)​:这是 Python 中处理文件的标准方式。它能确保无论代码块是否发生异常,文件最终都会被正确关闭。
  2. 增加空列表检查:在计算平均值前,通过 if not data_list: 检查列表是否为空,避免了除零错误。
  3. 逐行迭代文件:使用 for line in f: 代替 f.readlines()​,实现了“流式处理”,内存占用极低,可以处理任意大小的文件。
  4. 日志路径参数化:将 log_file_path 变为函数的可选参数,提高了灵活性和可重用性。
  5. 移除了未使用的 import os​。
  6. 增加了日志写入的 try...except​:写入日志文件也可能失败(例如磁盘已满、权限不足),也应该被捕获。

嗯…效果还是非常不错的,不过这只是小规模的代码。对于较长代码或者一整个代码库,我们可能需要具有较长上下文的 LLM,比如 Gemini。就检查代码这项工作,也许我们可以写出更加具体的 prompt,这里我就不展开了,我也没有怎么研究过。

另:读者可以试试让 LLM 检查一下我在上面提到的朋友给我的 Lisp 代码。

4. 后记

总的来说这篇文章结束的算是比较仓促,不过平时我也没有太多时间来看 TAST,只能先到这里了。​ox-w3ctr 的重构真是遥遥无期啊(笑)。

感谢阅读。