小叶请AI帮忙做一个社团活动的报名小工具。第一步很简单:给出剩余名额和本次申请人数,判断这一笔申请能不能接收。AI交来了函数,又附上几个运行结果。剩5个名额,申请1人,允许;申请2人,允许;申请4人,也允许。小叶看了看,似乎没有问题。

直到一位同学申请5人时,工具拒绝了。活动明明还有5个名额,这一组人恰好可以坐满,为什么不能报名?

此前的运行结果都是真的,也都符合要求。问题在于,那几次运行没有触及两种规则发生分歧的地方:“申请人数少于剩余名额”和“申请人数不超过剩余名额”,在申请1人、2人、4人时给出相同答案,在恰好申请5人时才显出区别。

本章讨论的就是这件事:怎样选择能看出问题的资料,怎样确定一个可信的预期,再把这些判断保留下来,让每次修改都能重新接受检查。这里的社团、人数和运行场景均为虚构教学案例。代码采用Python标准库,可以在本地练习;检查对象从单个判断函数逐步扩大到一个简单的组合函数。

一、先写出正确的意思,再寻找正确的输出

测试要有可比较的依据。只把程序启动起来,看到它输出一个值,还没有完成“结果对不对”的判断。小叶需要先说清:哪些输入属于这个功能要处理的范围,在这些条件下,什么表现算符合要求。

本例约定如下。剩余名额是非负整数,可以为0;本次申请人数是正整数,至少为1。人数不超过剩余名额时允许,超过时拒绝。两种正常判断分别用Python布尔值True和False表示。输入类型不对,抛出TypeError;类型正确但数值越界,抛出ValueError。这里直接传给函数的是Python内建整数,不接受文本、浮点数或布尔值,函数也不负责把表单文本转换成人数。

这样规定以后,三种看起来都像“没报上名”的情况就能分开:剩5个名额申请6人,是合法申请遇到了名额不足;申请0人,没有形成符合约定的申请;把字符串"2"直接送进人数参数,则是输入类型没有准备好。第一种应得到正常的拒绝结果,后两种应明确报告不同的输入错误。

这并不是说所有报名工具都应采用同样约定。如果一个界面允许输入文字形式的数字,可以由界面处理函数先转换,再调用判断函数。如果活动支持候补,超额后的流程也可以另有安排。小叶需要确定眼前这一层功能做什么,才能知道该给它什么输入,以及应该检查哪一种结果。

还有一个容易漏掉的范围:本例的can_accept只判断能否接收。它不扣减名额,不写入报名表,也不向申请者发送消息。因此,连续调用两次can_accept(5, 4)都会返回True,因为两次输入都说还有5个名额。要检查第二笔申请是否被拒绝,必须先有负责更新剩余名额的流程,把第一次处理后的名额交给第二次。不能把本来没有交给函数的职责,事后当成它没有完成的错误。

测试用例可以理解为一份具体的检查安排:准备什么条件,做什么操作,期待观察到什么。本例可以写成:在剩余名额为5、本次申请人数为5时调用判断函数,期待返回布尔值True。输入、操作和期望连在一起,这个用例才有确定的含义。

期望应当先有理由。这里“恰好坐满可以接收”来自活动规则,不是因为程序已经输出了True,我们才把True写进答案。程序是被检查的一方,活动约定提供判断依据。把这个方向保持住,后面AI无论换成怎样的代码,用例都还可以检查同一项行为。

二、三个正确的例子,为什么没发现一个很近的错误

先把下面这个故意保留比较错误的版本保存为capacity.py。输入检查符合前述约定,最后一行却使用了严格小于:

可复制模板
def can_accept(remaining, requested):
    if type(remaining) is not int or type(requested) is not int:
        raise TypeError("counts must be integers")
    if remaining < 0 or requested <= 0:
        raise ValueError("remaining >= 0 and requested > 0 required")
    return requested < remaining

remaining表示剩余名额,requested表示本次申请人数。前两个if依次检查类型和数值范围,raise在不符合约定时发出异常。合法输入到达最后一行,比较结果就是返回值。

这里用type(value) is int明确限定内建整数,是本例输入约定的直接表达。Python中的布尔类型与整数有继承关系,True在一些数值运算中会表现得像1,但报名人数不能因此接受“真”作为一个人。这个区别也会进入后面的测试。相关语言行为见Python布尔类型说明。

在同一目录新建demo_capacity.py,用它重现开头的三个例子:

可复制模板
from capacity import can_accept

for requested in [1, 2, 4]:
    print(requested, can_accept(5, requested))

运行python demo_capacity.py,三行结果分别是1 True、2 True和4 True。这些运行没有暴露严格小于的问题,因为所有申请都明显小于剩余名额。若AI再补出几十个“名额很多、申请很少”的例子,错误规则仍然可能一路通过。

小叶可以暂时把三种实现摆在一起比较。第一种按约定判断“不超过”,第二种用了“小于”,第三种在检查输入后直接返回True,也就是任何合法申请都允许。三种实现面对以下输入,会出现不同表现:

剩余名额、本次人数 按约定“不超过” 错用“小于” 合法输入一律允许
5、2 允许 允许 允许
5、5 允许 拒绝 允许
5、6 拒绝 拒绝 允许

申请2人的例子说明普通接收路径有一次符合要求的表现;申请5人的例子能区分是否错误排除了相等;申请6人的例子能揭示对合法输入一律放行的错误。每一行都有不同的辨别作用,不能只看一共运行了多少行。

选择用例时,一个很有用的问题是:**如果代码犯了某种合理想象得到的错误,这个例子会不会仍然通过?**如果答案是会,就继续寻找能把正确行为与那种错误分开的条件。这个问题不要求我们猜到所有错误,它只是让样例选择有明确的目的。

测试也不等于必须先看代码再猜。小叶在不知道实现细节时,只要认真读“可以恰好占满”这条规则,就能选出申请5人的用例;看到实现后,又能进一步检查比较符号是否对上。依据任务选条件与阅读程序找薄弱处,可以相互补充。

三、先分情况,再在分界附近检查

面对许多可能的输入,逐个乱试很费力。小叶先按照处理方式把它们分开:合法且有足够名额、合法但名额不足、类型不合要求、类型合适但数值范围不合要求。这样的分组能让他看到,已有的三个演示都挤在第一组,其他处理方式还没有出现。

按预期处理方式把资料分成若干组,再从各组选代表,是等价类划分的基本思路。“等价”在这里服务于选择用例,不是断言程序对同组中的每个值都绝不会犯不同错误。比如申请1人和申请4人本应走同类处理,但实现也可能错误处理申请4人的情形。分组给出了有根据的起点,不能代替对具体实现和失败记录的继续观察。

代表值选好以后,还应看看处理方式在哪里发生变化。剩余名额固定为5时,申请4人可以接收,申请5人仍然可以,申请6人开始拒绝。把这一带放在一起,比继续测试申请1人更容易看清临界位置是否放对。

剩余5个名额时的申请边界
图 C10-01|剩余5个名额时的申请边界

图中每个点是一种整数人数。相邻人数的差别很小,处理结果却可能刚好在这里改变。边界值分析就是把注意力放到这类分界及附近的值上。前面代码少写了等号,影响集中在恰好占满这一种关系,远离边界的例子不容易发现它。等价类与边界术语可参照ISTQB基础大纲第4.2节。

还要把名额边界与输入合法性的边界分开。申请0人和申请1人,关系是“无效申请”与“有效的最小申请”;剩余名额0与1,关系是“没有位置”与“至少有一个位置”。它们和“申请5人是否恰好坐满”是不同的检查角度。

检查条件 示例输入:剩余、本次 预期 主要想检查的关系
普通接收 5、2 True 有足够位置时可以接收
恰好占满 5、5 True 相等没有被错误排除
超出一个名额 5、6 False 不足时不能接收
没有剩余名额 0、1 False 合法申请遇到空余为零
最小的一笔刚好满足 1、1 True 两个小边界同时出现
零人申请 5、0 ValueError 申请人数的下限
负的剩余名额 -1、1 ValueError 剩余名额的下限
文本人数 5、"2" TypeError 转换职责没有混入此函数
浮点人数 5、2.0 TypeError 值看似整数不等于类型已合约定
布尔人数 5、True TypeError 真值不能被当成人数

这个表没有罗列所有组合。一笔输入可能同时有类型和取值问题。例如剩余名额传来的是文本、申请人数又是0,本章函数会先报告类型错误。设计测试时先让一笔资料突出一个条件,失败会更容易解释;之后再根据需要检查条件相遇的情形,例如表中的1、1。

有些输入没有自然的“相邻一个值”,例如文本和整数之间,就不必勉强套成加1减1。合理的做法是直接检查代表类型。方法应当顺着资料的含义使用,而不是看到“边界”就对每个字段都编三组数字。

小叶还可以问一遍遗漏来自哪里:剩余名额是否可能由旧数据计算而来,申请人数是否可能从表单直接传入,过去最常出现什么错误。如果真实使用中已经出现负数,那么负数例子的重要性来自实际记录。分组、边界和经验各自补充线索,最终仍要落到可明确判定的用例。

四、预期答案怎样避免和程序一起错

AI可以很快生成测试,但测试也可能复制实现的错误。假设它根据当前的严格小于版本写出下面这段检查,放在一个已经导入can_accept的脚本里:

可复制模板
remaining, requested = 5, 5
expected = requested < remaining
actual = can_accept(remaining, requested)
assert actual is expected

这段检查会通过。原因很具体:预期算成了False,实际也返回False。它证明了函数在这一输入上与另一份相同的比较表达式一致,却没有检查“恰好占满应允许”这条规则。如果实现和测试都漏掉等号,两者可以互相符合,同时违反任务要求。

因此,写预期时应当能说出独立于当前代码的理由。对于这个小例子,最好的依据不复杂:摆出5个空位,让5个人各占一个,所有人都有位置,预期为True。这段生活关系足以确定答案,没有必要再复制一遍被检查的比较语句。

“独立”不是要求测试必须由另一个人写,更不是认为换一个AI就能自动保证答案可靠。同一个人可以先依据规则写用例,再实现功能;另一个AI也可能接受了同样的错误前提。关键在于,两边的判断是否都源于同一个未经核对的实现。如果小叶把错误代码和“请证明它正确”一同交给另一个模型,增加了一次回答,却没有增加足够不同的依据。

预期也不一定总能手算。整理文件时,可以保留一个规模很小、内容已逐项核对的输入文件,明确预期的输出记录;转换数据格式时,可以检查往返以后编号和数值是否保留;完成统计时,可以用另一种容易核对的办法计算少量样本。选择哪种参照,要看任务中什么最容易被可靠确认。

参照本身同样需要检查。已有软件的输出可能使用另一套筛选规则;人工表格也可能漏行;旧版本程序如果已有错误,就不适合直接承担所有新结果的标准。小叶可以保留参照的来源与适用条件,让以后的人知道这个数为什么被认为正确。这样做的实际作用,是在程序和预期冲突时有地方回查,而不必凭谁的表达更自信作决定。

对精确人数,比较应当精确;对有计算误差的浮点结果,可能需要事先确定允许偏差。允许偏差应来自任务的精度要求和计算方式,而不是看到失败后逐步放宽,直到程序通过。比如需要精确到分的账目,与用于示意的图上坐标,不能随意共用一种“差不多就行”的规则。本章人数都是整数,尚无设置误差容限的需要。

到了这里,小叶的测试表已经不只是输入清单。每个预期背后都有一条任务理由。AI能帮助补全代码、寻找遗漏,但这些理由不会因为AI生成了更多测试就自动成立。

五、把手工判断写成自动核对

小叶现在已经知道,can_accept(5, 5)应返回True。他可以在同目录新建check_exact.py,写入:

可复制模板
from capacity import can_accept

actual = can_accept(5, 5)
assert actual is True, "恰好占满应允许"
print("恰好占满检查通过")

运行python check_exact.py。用前面的错误版本时,程序会在断言处停止,出现AssertionError及“恰好占满应允许”的说明,最后的成功提示不会出现。这里第一次把一个已确定的规则变成了可重复执行的检查。

assert表达“此处应该满足这个条件”。条件成立,继续执行;条件不成立,抛出断言异常。本例用is True检查返回的确实是布尔值True,而不仅是某个在条件判断中被当成真的东西。因为任务约定了返回类型,检查这层关系有实际意义。

这个小脚本让测试的两部分清楚分开:调用函数取得实际值,再拿实际值和已有预期比较。后面的文字说明用于失败时定位,不参与决定应该允许还是拒绝。即使把提示改成“测试成功”,错误返回值也不会因此变成正确;反过来,单独打印一句“检查通过”,不代表已经做过任何比较。

Python的assert适合这类本地检查,但使用-O等优化方式时,断言语句可能不被执行。因此,处理实际输入的TypeError和ValueError仍由普通条件与raise完成,不能依赖可能被关闭的断言来保护输入约定。这里运行检查脚本使用普通命令。相关行为见Python的assert语句说明。

用例增多以后,逐个脚本手动运行会变得麻烦。Python标准库中的unittest可以收集一组检查,分别记录结果。把下面内容保存为与capacity.py同目录的test_capacity.py:

可复制模板
import unittest
from capacity import can_accept


class TestCapacity(unittest.TestCase):
    def test_ordinary(self):
        self.assertIs(can_accept(5, 2), True)

    def test_exact(self):
        self.assertIs(can_accept(5, 5), True)

    def test_excess(self):
        self.assertIs(can_accept(5, 6), False)

    def test_empty(self):
        self.assertIs(can_accept(0, 1), False)

    def test_zero_request(self):
        with self.assertRaises(ValueError):
            can_accept(5, 0)

    def test_negative_remaining(self):
        with self.assertRaises(ValueError):
            can_accept(-1, 1)

    def test_text_request(self):
        with self.assertRaises(TypeError):
            can_accept(5, "2")

    def test_boolean_request(self):
        with self.assertRaises(TypeError):
            can_accept(5, True)


if __name__ == "__main__":
    unittest.main(verbosity=2)

这份文件把八个检查放在TestCapacity中;每个以test_开头的方法对应一项检查。self.assertIs把实际返回与指定布尔值比较,with self.assertRaises(...)检查其中的调用是否发出预期类型的异常。最后一段让直接运行文件时启动这些检查。这里先使用这个小骨架即可,不需要提前掌握整个面向对象体系。标准库的相应接口见unittest基本示例与断言方法。

在两份文件所在目录运行python test_capacity.py。对当前严格小于版本,八项中恰好占满这一项失败,其余七项通过。终端会列出检查名称,汇总中包含Ran 8 tests和FAILED (failures=1);完整路径和耗时可以不同,它们不是本例要比较的业务结果。

这八项是便于入门的自动检查集合,前面表格中的1、1和浮点输入尚未逐项写入这份文件。表里存在一个用例,与测试文件实际执行了它,是两件不同的事。要把它们也纳入自动检查,就需要继续增加对应方法,或者以后学习按表逐项运行的写法。

六、看到失败,先看哪一个约定没有满足

测试失败带来了一个明确的新事实:在保存的输入与当前运行条件下,实际表现没有达到测试所写的要求。接下来的工作是解释这个差别。可能是功能实现错了,可能是期望写错,也可能实际运行的程序和小叶正在查看的版本不是同一份。

本例的test_exact失败,说明检查实际拿到了False,期待却是True。回到规则,5个位置允许5个人恰好坐满;回到实现,最后一行确实把相等排除了。两处证据相互接上,才有理由修正比较,而不是仅仅因为终端出现了红字。

输入异常测试需要反过来理解。在test_zero_request里,出现ValueError正是约定要求的表现,因此该用例通过。如果函数把0人申请当成普通申请,安静返回False,测试反而应该失败。我们检查的是它有没有按规定处理无效输入,不是要求每一次函数调用都正常返回。

unittest还会区分断言不成立与其他未被预期处理的异常。比如原本要比较布尔结果,导入函数时却找不到模块,功能检查根本没有到达;又比如本来要求数值越界报ValueError,实际产生另一种异常,也不能按“反正报错了”算通过。失败报告的名称、异常和位置,应一起阅读。

一个常见的退步是把失败处理成“测试太严格”。如果test_exact失败就把预期改成False,文件会更容易通过,活动规则却被悄悄改掉。只有规则经过确认发生变化,或原测试确实误写,才应据此修改预期。修改理由应能由任务解释,而不能只是“这样能过”。

另一种混淆是把没运行当成通过。如果终端显示Ran 0 tests,就没有任何用例得到执行。本章在Python 3.13.13中把八个测试方法名的test_前缀全部改成check_后,得到的提示是NO TESTS RAN,退出码为5。无论工具怎样显示汇总,都应先确认计划中的检查实际发生了。测试方法命名不合规则、执行了另一份文件,都可能使检查没有运行;小叶应该能把报告中的用例名称与自己期待的内容对上。

检查运行条件不需要一开始就建立庞大的记录系统。对本例,保存两份文件,记下所用Python版本、工作目录、运行命令及实际报告,就能支持很多必要的回查。如果使用交互式环境,修改文件以后还要确认重新载入了新函数,避免一直检查内存里的旧定义。

此时的调试才接上上一章:有了明确的失败输入、实际与预期,便可以追到负责比较的那一步。测试负责把差别暴露出来,调试负责查明差别怎样产生。修复之后,还要回到测试,看看原失败是否真的消失。

七、修好这一处,还要保住已经成立的行为

小叶把capacity.py替换为下面的完整修复版。类型检查和数值范围保持,最后的比较允许相等:

可复制模板
def can_accept(remaining, requested):
    if type(remaining) is not int or type(requested) is not int:
        raise TypeError("counts must be integers")
    if remaining < 0 or requested <= 0:
        raise ValueError("remaining >= 0 and requested > 0 required")
    return requested <= remaining

先重新运行check_exact.py,原来失败的条件现在通过,出现“恰好占满检查通过”。再运行原封不动的test_capacity.py,八项都通过,报告汇总为Ran 8 tests和OK。修改前后的输入与期望没有变,这次比较才能支持“相等被错误排除的问题已修正”。

为什么还要重新跑另外七项?因为修改可能带来新的损坏。假如小叶只要求AI“让申请5人的例子通过”,AI把合法输入的返回结果改成一律True,恰好占满确实通过了,超额和零名额却会错误放行。只盯着刚修的那一例,看不出它牺牲了什么。

重新检查原失败是否消失,通常称为确认测试;修改后检查已有行为有没有受到不良影响,称为回归测试。在本例中,再跑恰好占满是在确认这次修复,再跑超额、普通接收和输入异常等用例,是在保护其他约定。相关术语见ISTQB基础大纲第2.2.3节。

同一个用例在以后的修改中也会发挥不同作用。今天的5、5是刚发现的失败例;下一次有人优化函数时,它就是保护“恰好占满允许”的回归用例。把它保存下来,发现过的问题便不必每次靠同一个人重新想起来。

保存用例时,名称和预期应表达行为,而不是锁住不必要的实现细节。test_exact关心相等时允许,并不要求函数内部一定使用一行比较。以后AI先计算人数差,再判断是否足够,只要依旧满足约定,这项检查仍应通过。如果测试反而要求代码必须有某个局部变量,正常重构也会被挡住。

这并不意味着测试永远不能改变。假设活动新增规则:必须留下1个备用名额,普通报名不能占满。此时原本的5、5预期就不再适用。正确过程是先记录规则变化,确定受影响的边界,再改实现与测试;例如剩5个名额时申请4人允许、申请5人拒绝。这里变化的是任务,不是为了遮住一个失败而改变答案。

回归集合也应随现实问题增长。若新出现的输入类型确实进入了支持范围,就补相关条件;若某种旧功能明确退出使用,则调整对应检查和说明。小叶不需要把每个临时演示永久留下,但那些保护重要约定、曾经揭示错误、运行成本又低的用例,很适合成为日常修改后的固定检查。

八、测试本身有没有辨别力,也能检查

现在八项全部通过,小叶还可以做一个小实验:保留这份测试,把被测试的实现换成几种已知错误的版本,看看用例能不能把它们拦下来。这里的改坏操作只在教学副本中进行,正式修复版另行保留。

第一种仍是原来的严格小于。第二种保留输入检查,但对合法输入一律允许。第三种保留输入检查,把返回值改成整数1或0,用它们代替约定中的布尔值。第三种在很多if里看似能用,却改变了接口约定;后面若有人要把结果作为JSON布尔值交给其他程序,就可能出现不同的数据类型。

教学副本的改动 八项检查中首先应关注什么 用例暴露的差别
使用严格小于 恰好占满 该允许的一笔被拒绝
合法输入一律允许 超额、没有名额 该拒绝的申请被放行
正常返回整数1或0 四项正常返回检查 真值判断可以相同,但返回类型不合约定

这里用assertIs(..., True/False)就体现了作用。若只写assertTrue(result),整数1也会被当成真;如果用assertEqual(result, True),Python的数值比较会把1和True视为相等。选择比较方式时,应先问任务要求精确到哪一层,而不是哪个断言更容易通过。布尔身份、相等和真值检查的区别见unittest断言方法说明。

这个实验不必做成复杂工具,目的也不是把所有可能的坏程序都列完。小叶只需要用几种具体误改,检查已有用例是否真的保护了他在意的约定。如果把函数改成总返回True后仍全绿,就应回头看测试是否缺少拒绝情形,或根本没有把返回结果拿来比较。

“测试能失败”仍不是最终目标。导入路径写错,也能让每个版本都失败,但这不能辨别实现好坏。理想的对照是:符合约定的版本通过,对某项约定的明确破坏引发相应失败。这样,失败才说明用例对那种变化有反应。

测试覆盖也需要说明覆盖了什么。跑过某行代码,只说明执行到那里;同一条比较语句可以处理申请2人、5人、6人,而三个输入的业务作用不同。即使所有正常路径中的语句都已执行,恰好占满的关系仍可能没被检查。因此,“代码都跑到过”和“需求中的关键情况已经核对”应当分开记录。

用例数量也有相同问题。一百个都在测试宽裕名额的用例,未必能代替一个等于边界的用例。小叶需要能说明新增用例补了哪种条件,而不是以报告里的总数决定可信程度。程序规模变大时,覆盖工具能帮助发现没有触及的部分,但报告里的一个百分比仍然需要结合输入与预期理解。

九、不逐个写答案,也能检查一组结果之间的关系

固定输入、固定答案很清楚,但我们还可以从任务规则推出一些应当保持的关系。小叶发现:同一笔合法申请,在5个名额时已经允许,增加到6个名额后不应反而拒绝;剩余名额不变时,某笔申请已经超额,人数再增加也不应变成允许。

这两条关系来自“其他条件不变时,同一批人有更多位置可用,不会变得更难容纳;位置不变而申请人数增加,也不会变得更容易容纳”。它们说明两个输入之间怎样联系。检查这种联系,可以帮助发现比较方向写反、某段人数处理跳变等问题,而不必为每一组都先手写完整结果表。

先保持capacity.py为修复版,再在同目录把下面内容保存为check_relations.py:

可复制模板
from capacity import can_accept

for remaining in range(0, 7):
    for requested in range(1, 8):
        current = can_accept(remaining, requested)
        if current is True:
            assert can_accept(remaining + 1, requested) is True
        if current is False:
            assert can_accept(remaining, requested + 1) is False

print("49组起始输入的两类关系检查完成")

这里先取7种剩余名额和7种正人数,共49组起始输入。每组根据当前结果进入相应检查:允许时把剩余名额增加1个,拒绝时把申请人数增加1人。相邻调用会用到剩余7个名额或申请8人,所以“49组”指起始组合,不是声称所有调用都限制在起始范围内。每个调用仍满足本例的合法输入约定。

关系检查给出了新的观察角度,却不能独自确定功能正确。合法输入一律允许的坏版也能通过上述脚本:每组都进入第一个分支,增加位置后仍然允许;一律拒绝也可以通过,因为每组只检查增加人数后仍然拒绝。甚至原来的严格小于版本也保持这两种方向关系,却仍在恰好占满时出错。

因此,小叶把它和具体答案配合使用。5、5必须允许,5、6必须拒绝,这些用例给出了明确的判断位置;成组变化则检查相邻条件之间是否保持合理关系。两者补充的是不同信息,不能用“检查了49组”覆盖已经出现的一个反例。

还有些关系只在特定任务里成立。如果活动新增年龄门槛,或不同场次名额不能互换,比较名额变化时就需要保持年龄条件或场次不变。对输入作变化之前,要说明保持了什么。把其他条件一起变掉后,结果改变未必是程序违反关系。

随机生成资料也应遵循这个原则。随机性能让输入更丰富,却不会替我们决定正确答案,也可能很长时间没有碰到恰好相等。对于已经知道的重要边界,直接加入确定用例更有把握;随机生成得到的新失败,则可以把那份具体输入保存成回归例子。保存实际输入比只说“随机测的时候错了”更利于重现。

若支持范围本来很小,也可以穷举所有合法组合。例如临时演示明确只支持剩余名额0到6、申请人数1到7,就可以检查这49组各自的精确答案。但前面的关系脚本没有逐项检查精确答案,不能称作同一件事;而本章函数也没有把支持范围限制为这49组。枚举的范围、检查的关系和最终声称的能力,应该保持一致。

十、单个函数对了,交接仍然可能接反

小叶继续做下一层功能:处理一笔申请,返回是否受理以及处理后的剩余名额。这里另设一个本地组合函数register。它接收两个整数,调用can_accept判断;允许时返回accepted=True及扣减后的名额,拒绝时返回accepted=False及原名额。它不写文件或数据库,只把结果字典交给调用者。

AI写出下面这个含参数顺序错误的版本,保存为与修复版capacity.py同目录的registration.py:

可复制模板
from capacity import can_accept


def register(remaining, requested):
    if can_accept(requested, remaining):
        return {"accepted": True, "remaining": remaining - requested}
    return {"accepted": False, "remaining": remaining}

单独运行那八项can_accept检查,依旧全部通过,因为判断函数本身没有变。但调用register(5, 2)时,新函数把申请人数2放进了剩余名额的位置,把名额5放进了申请人数的位置。判断函数忠实地回答“2个位置放不下5个人”,组合函数于是拒绝了一笔本来可以受理的申请。

如果只选5、5来试这个组合函数,两数交换以后仍相等,错误会藏住。前面很有用的恰好占满用例,在这里未必能揭示参数接反。用例的作用取决于当前要检查的关系,同一组数字不会天然适合所有缺陷。

从判断函数到组合处理的测试范围
图 C10-02|从判断函数到组合处理的测试范围

图中内层检查直接调用判断函数,外层检查把参数交接、判断和新名额一起纳入。测试调用者与被调用者怎样配合,就比只测单个函数多检查了一段真实连接。这是从单元测试走向集成检查的一种小规模情形。

把下面内容保存为test_registration.py,与前两份功能文件放在同一目录:

可复制模板
import unittest
from registration import register


class TestRegistration(unittest.TestCase):
    def test_accept_and_reduce(self):
        self.assertEqual(
            register(5, 2),
            {"accepted": True, "remaining": 3},
        )

    def test_reject_and_keep(self):
        self.assertEqual(
            register(5, 6),
            {"accepted": False, "remaining": 5},
        )


if __name__ == "__main__":
    unittest.main(verbosity=2)

在同一目录运行python test_registration.py。这两项既检查受理结果,也检查新名额。对参数接反版,第一项实际得到拒绝与剩余5,第二项却得到允许与剩余-1,两项都失败。失败不需要再发明一套人数规则,只需要把本来已经说清的规则应用到完整返回对象上。

修复时,将registration.py中的调用改为can_accept(remaining, requested),其余语句保持。再运行这两项,结果分别为允许并剩3、拒绝并剩5,符合约定。随后重跑判断函数的八项,以确认组合修改没有顺手改变底层规则。这些都是本地函数检查,尚不涉及真实报名系统。

这份组合测试使用字典相等比较,能够检查键及对应值,但不能据此说返回中所有类型细节都已逐项验证。比如Python中1与True有数值相等关系;若完整接口要求严格的布尔类型,还应对result["accepted"]另作身份检查。这里两项用例首先针对参数交接和名额更新,并没有替代前面讨论的全部类型检查。

后续如果加入文件保存,检查范围就要继续向实际交付延伸:允许时报名是否写到正确位置,拒绝时名额是否保持,调用者下一次是否使用了更新后的结果。到那时,光看函数返回字典便不够了。范围扩大意味着要取得新的观察,不是给原来的八项检查换一个更大的名字。

十一、让AI参加测试,需要给它什么工作

小叶现在可以把与AI的协作分得更具体。他先给出输入约定和几个有理由的预期,请AI据此生成测试;再让AI检查哪些条件尚未覆盖,要求说明每个新增例子能区分什么。这样,AI的速度用在实现和寻找遗漏上,人仍能看懂测试为何存在。

例如可以交代:“剩余名额为非负整数,人数为正整数,恰好占满允许,超额返回False,类型及取值错误分别抛异常。请写一份标准库测试文件,覆盖普通、相等、超额和明确的非法输入;每个预期都要回指规则。保留已有期望,发现矛盾先指出理由。”结合前面已经说明的输入和输出约定,这段任务能让生成结果更容易逐项核对。

如果AI具备本地执行工具,可以让它交出实际命令、运行版本和失败报告;如果没有执行条件,它可以给出代码推导和预计结果,但需要按推导来理解。模型写出一段长得像终端日志的文字,并不代表程序真的运行过。小叶在意的是证据来自哪里,不是报告排版是否熟悉。

AI同时生成实现和测试并非天然无用。它可以完成许多直接工作,问题在于不能把两份相互吻合当成两份独立依据。小叶仍应核对任务规则与关键预期,并保留能揭示具体误改的例子。找另一位同学或另一个模型复查,也应让对方据规则说明理由,而不是只询问“这份代码是不是没问题”。

测试较复杂功能时,拆出可以明确判定的小部分尤其有用。假设AI先从社团申请文本中提取人数,再调用本章函数,最后生成回复。小叶可以分别检查:提取结果是不是实际申请人数,类型是否转换正确,名额判断是否符合规则,回复是否准确表达受理情况。判断函数通过,不会自动证明文本提取正确;生成的回复很客气,也不会自动证明名额计算正确。

这种拆分允许不同部分采用不同依据。结构字段可以检查是否存在、类型是否合约定;数字可以对照已核实资料;生成文字则需要阅读它有没有遗漏关键条件或误导用户。比如报名被拒绝,回复却写“本次已报名成功,期待您的到来”,文件格式可能完全正确,实际表达仍然出了问题。把可自动核对的部分和必须理解含义的部分分别看清,能够减少空泛的“整体评分很好”。

如果生成文字本来允许多种说法,就不宜把唯一的一句话设为标准答案。可以明确它必须表达的事实,再用几个具体反例检查:不能把拒绝说成允许,不能编造已保存报名,不能漏掉需要补充人数的原因。这些事实要求可以和人工阅读配合,措辞自然度则需要实际看内容,不能用字段齐全替代。

测试结果也应反过来改善任务说明。若各方对“零人申请”有不同理解,就先决定它在产品里意味着什么,再编码;若测试发现输入总是字符串,就检查表单转换的职责在哪里。这里测试带来的价值不只在于找到一行错误,还在于让模糊的交接变成可共同核对的约定。

十二、这次可以交出什么,还有什么尚未证明

回到小叶开头的问题,经过这一轮,他不再只有三个看起来正确的演示。他有一份明确的名额约定,能说明为什么相等允许、超额拒绝、零和文本应以不同方式报错;有一组实际运行的检查,曾经揭示严格小于的错误;也有组合层面的两个用例,发现并修正了参数顺序问题。

当前本地教学功能的交付可以包括修复后的capacity.py和registration.py、两份测试文件,以及说明运行方式的简短记录。记录应区分八项判断测试、两项组合测试与49组起始输入的关系检查,不能合成“验证了59种独立业务情况”。各自检查的对象、条件和比较方式并不相同。

前面表中还有两项未放入八项测试文件:can_accept(1, 1)应返回严格的True,can_accept(5, 2.0)应抛出TypeError。小叶可以在TestCapacity中各加一个测试方法,前者沿用assertIs比较,后者沿用assertRaises观察异常,再运行文件。这样,表格中计划的条件才都落实为这份文件里的检查;未经补充的原文件仍只执行其中写出的八个方法。

对于眼前这个确定的纯判断函数,有限测试与代码阅读已经能提供具体的信心:比较关系符合规则,所选普通、边界和异常输入符合各自预期。它们不能保证我们没有漏看任何情况,但也不需要因为这种一般限制就停止使用所有小工具。应当把信心放在已经取得证据的范围内,并让新的失败能被保存、复现和修复。

更大的“报名系统已经可以投入使用”则需要其他证据。两个同学同时看到同一份剩余名额时如何处理,报名记录是否真正保存,重复点击是否重复扣减,界面能否把拒绝原因说清,都还没有进入本章两个本地函数的职责。若目标只是提供一个判断组件,可以按这个范围交付;若目标是直接运行完整报名服务,就继续检查那些实际组成部分。

软件工程课程会进一步讨论怎样从使用场景确定验收条件、组织系统接口,以及安排不同层次的质量检查。本章已经提供了最基本的动作:拿出一条规则,构造一个能区分行为的条件,取得实际结果,再说明它为什么符合或不符合。这个动作不依赖一个庞大的工具体系,几行脚本就可以开始。

小叶最后交给同学的说明可以写得很具体:“判断函数允许恰好占满,超额拒绝,非法输入按约定抛异常;组合函数受理后返回扣减后的剩余名额,拒绝时保留原名额。已按保存的测试文件核对,曾发现的两个问题都有对应回归例。当前没有连接真实报名数据库。”这段话让使用者知道功能做到了哪里,也知道后续接入时该继续检查什么。

练习与参考分析

练习一:十次都成功,却没有检查相等

小叶给一个剩余名额固定为20的函数安排了十次检查,申请人数分别为1到10,全部应允许,也全部通过。有人说:“测试已经很多了,没有必要再测20人。”你怎样判断?再增加两个例子时,应优先选择什么?

**参考分析:**这十次都没有触及恰好占满或超额。按约定判断不超过、错误地判断小于、合法输入一律允许,都会在这十次给出同样结果。因此它们不能区分本章已经遇到的两种具体错误。优先增加申请20人应允许、申请21人应拒绝,分别检查相等和不足。这个选择不是说1到10的运行毫无价值,而是指出接下来什么条件能提供不同的信息。输入合法性仍需另外检查,不能把两个新增例子称为全部完成。

练习二:报错是否说明功能失败

用例规定can_accept(3, 0)应抛出ValueError。运行时它确实抛了这个异常,同学却把这一项记成失败,理由是“用户想报名,程序报错了”。这个判断混淆了什么?如果函数返回False,这一项是否算通过?

**参考分析:**它混淆了调用没有正常返回与处理不符合约定。当前函数只接受正整数人数,0是无效输入,按规定报告错误就是该用例的成功表现。返回False表示合法申请遇到名额不足,不能准确表达这里的输入问题,因此不满足这份用例。若产品希望界面显示友好说明,可以由调用者接收异常再展示,但那属于另一个需要检查的处理环节,不能据此改写当前函数的约定。

练习三:新规则到来以后,要不要保留旧预期

活动负责人确认新增要求:普通申请只有在受理后仍能留下1个备用名额时才允许。旧用例“剩4个名额、申请4人,应允许”开始失败。小叶该保留这个旧预期要求AI修复,还是修改测试?请说明修改依据,并给出新的相邻用例。

**参考分析:**任务规则已经明确改变,旧预期不再适合新的功能,应记录规则变更后调整实现与测试。剩4个名额时申请3人允许,申请4人拒绝;剩1个名额时申请1人也拒绝,能检查备用名额已经占去全部可用余量。相应判断可以理解为申请之后仍至少留下一个位置。这里改测试有任务依据,与原规则未变却为了消除红字而把True改成False不同。类型和正整数限制若没有变化,应继续保留对应检查。

练习四:关系检查全过的坏程序

一份函数对所有合法输入都返回False。有人运行本章“已允许则增加名额仍允许、已拒绝则增加人数仍拒绝”的关系检查,发现没有失败,便认为拒绝规则可靠。为什么这些关系不足以揭示错误?补哪一个具体例子就能否定这份实现?

**参考分析:**函数从来不返回True,第一个分支没有进入;每次返回False后,再增加申请人数仍然返回False,第二个关系总能满足。它具有表面一致的变化方向,却没有在任何位置作出正确的允许判断。加入can_accept(2, 1)必须返回True,便能直接否定它;2、2也应允许。关系检查约束的是输入之间的变化,固定答案用例则确定具体条件下应处于哪一侧,两者不可互相代替。

练习五:两个参数相等,接反也能通过

组合函数把参数顺序接反,底层判断函数是本章修复版。小叶只检查register(3, 3),看到允许且剩0,认为两个函数配合正确。请另选一个合法且不相等的例子,说明实际会得到什么,正确预期又是什么。

**参考分析:**例如register(6, 2)。接反以后,底层收到的是剩余2、申请6,返回False,组合函数返回拒绝且剩6。按实际任务应允许2人报名,处理后剩4。原用例两个参数都为3,交换以后不变,因而无法揭示顺序错误。还可以用剩2、申请3检查另一方向:正确应拒绝且保留2,接反版却会允许并返回剩-1。测试应比较受理与剩余名额,不能只看函数没有报错。

练习六:一份绿色报告,能支持多大结论

AI交来报告:“8项测试全部通过,报名系统已完整验证。”实际测试文件只直接调用can_accept,没有调用组合函数,也没有连接存储和界面。小叶应怎样改写结论,并安排下一步检查?

**参考分析:**可以说在指定版本和运行条件下,测试文件列出的八项判断检查通过;还应核对报告确实执行了这八项,而非只显示笼统的OK。现有证据没有覆盖参数交接、名额更新、持久化和展示,不能称整个系统已验证。若下一步准备接入本章组合函数,就用不同的剩余名额与人数,检查允许后扣减、拒绝时保持;若随后接入保存和界面,再检查实际保存内容及用户看到的结果。扩大结论需要扩大实际观察范围,单纯把原八项再运行很多遍不能补齐这些缺口。