给 AI 生成的 Python 代码加一道最小验证环:用 unittest 固定输入和输出
AI 可以很快写出一个函数,但“看起来合理”不是可交付标准。真正省时间的做法,是在接收代码时同时建立一条短而确定的验证环:先写清输入和输出,再用 unittest 把正常路径、边界和异常固定下来,最后重复运行确认结果一致。
本文不讨论云端服务,也不把一次本机测试包装成平台验证。示例只使用 Python 标准库,在本机计算一个购物车报价,金额全部用整数分表示。目标不是展示复杂业务,而是提供一份可以交给 AI、也可以交给人工审查的最小验收模板。
一、先把“正确”写成可比较的结果
假设需求是:给定若干商品的单价和数量,计算小计;再按整数百分比计算折扣;折扣金额向下取整到分;返回小计、折扣和应付总额。
这段需求有几个容易被生成代码忽略的决定:
- 单价用整数分,不用二进制浮点表示人民币金额。
- 数量必须是正整数,单价必须是非负整数。
- 折扣必须是 0 到 100 的整数,
True也不能偷偷当成 1 接受。 - 空商品列表是合法输入,三个结果都应为 0。
- 计算函数不能通过排序、删除或回写字段改变调用方的输入。
- 例如两件 1299 分的笔记本和一支 250 分的笔,小计应精确为 2848 分;999 分打 15 折时,折扣是
999 * 15 // 100 = 149分,应付 850 分。
这些不是测试框架替我们决定的业务规则,而是测试前必须先写下的验收契约。没有明确的期望值,测试很可能只是重复执行代码本身,无法发现代码与需求的差异。
二、最小循环:生成、固定、运行、解释
一个实用的最小循环可以压缩成四步:
- 生成:让 AI 先给出函数和测试所需的输入输出表,不要只要实现代码。
- 固定:把关键例子写成
unittest断言,把边界和错误输入单独列出。 - 运行:用固定命令执行同一个文件,记录 Python 版本、测试数量和退出码。
- 解释:测试通过只说明这些断言在当前环境成立;仍要检查需求、依赖、性能、权限和外部系统行为是否超出了测试范围。
这里的“固定”很重要。随机数、当前时间、网络响应和未排序的集合都会让失败难以重现。最小验证环先用字面量输入和精确字典输出建立稳定基线,再按需要增加参数化测试、模拟对象或属性测试。
三、一个可运行的完整例子
下面的文件保存为 ai-python-unittest-loop-demo.py。实现和测试使用同一个文件,便于复制到干净目录后直接运行;真实项目可以再把实现和测试拆成不同模块。示例不安装第三方包,也不读写文件、数据库或网络。
"""A small deterministic example for verifying generated Python code."""
from __future__ import annotations
import unittest
from typing import Iterable, Mapping
Item = Mapping[str, object]
def quote_total(items: Iterable[Item], discount_percent: int = 0) -> dict[str, int]:
"""Return a deterministic integer-cent total for a sequence of items."""
if type(discount_percent) is not int or not 0 <= discount_percent <= 100:
raise ValueError("discount_percent must be an integer from 0 to 100")
subtotal_cents = 0
for item in items:
unit_cents = item.get("unit_cents")
quantity = item.get("quantity")
if type(unit_cents) is not int or unit_cents < 0:
raise ValueError("unit_cents must be a non-negative integer")
if type(quantity) is not int or quantity <= 0:
raise ValueError("quantity must be a positive integer")
subtotal_cents += unit_cents * quantity
discount_cents = subtotal_cents * discount_percent // 100
return {
"subtotal_cents": subtotal_cents,
"discount_cents": discount_cents,
"total_cents": subtotal_cents - discount_cents,
}
class QuoteTotalTests(unittest.TestCase):
def test_basic_total_is_deterministic(self) -> None:
items = [
{"name": "notebook", "unit_cents": 1299, "quantity": 2},
{"name": "pen", "unit_cents": 250, "quantity": 1},
]
expected = {
"subtotal_cents": 2848,
"discount_cents": 0,
"total_cents": 2848,
}
self.assertEqual(quote_total(items), expected)
def test_discount_rounds_down_to_a_cent(self) -> None:
self.assertEqual(
quote_total([{"unit_cents": 999, "quantity": 1}], 15),
{
"subtotal_cents": 999,
"discount_cents": 149,
"total_cents": 850,
},
)
def test_empty_input_has_zero_totals(self) -> None:
self.assertEqual(
quote_total([]),
{"subtotal_cents": 0, "discount_cents": 0, "total_cents": 0},
)
def test_input_is_not_mutated(self) -> None:
items = [{"name": "book", "unit_cents": 1000, "quantity": 2}]
before = [item.copy() for item in items]
quote_total(items, 10)
self.assertEqual(items, before)
def test_invalid_discount_is_rejected(self) -> None:
for value in (-1, 101, True, 10.5):
with self.subTest(value=value):
with self.assertRaisesRegex(
ValueError, "discount_percent must be an integer"
):
quote_total([], value) # type: ignore[arg-type]
def test_invalid_item_boundaries_are_rejected(self) -> None:
cases = [
({"unit_cents": -1, "quantity": 1}, "unit_cents"),
({"unit_cents": 100, "quantity": 0}, "quantity"),
({"unit_cents": 100, "quantity": True}, "quantity"),
({"unit_cents": 10.5, "quantity": 1}, "unit_cents"),
]
for item, message in cases:
with self.subTest(item=item):
with self.assertRaisesRegex(ValueError, message):
quote_total([item])
if __name__ == "__main__":
unittest.main(verbosity=2)
运行命令:
python ai-python-unittest-loop-demo.py
unittest 的每个方法名都在说明一个可复核的行为。第一个测试锁定普通计算;第二个锁定舍入规则;第三个锁定空输入;第四个锁定不修改输入;最后两组测试锁定非法折扣、负单价、零数量、布尔数量和浮点单价等边界。assertRaisesRegex 同时检查异常类型和错误信息的一部分,避免实现用完全不同的错误掩盖输入契约变化。
四、如何读一次通过结果
本机运行这份示例时,使用的是 Python 3.13.14。输出中的关键事实是:共运行 6 个测试,耗时 0.001 秒,结果为 OK,进程退出码为 0。六个测试方法各出现一次 ok,没有依赖网络、当前时间或随机种子。
这能支持几个有限结论:在该次本机运行环境中,当前实现满足这 6 条断言;同一组固定输入产生了预期字典;列出的非法边界被拒绝。它不能支持“代码已经适合所有订单系统”这样的更大结论,也不能推导出云平台性能、并发安全、数据库一致性或真实用户输入覆盖率。
如果 AI 只返回“测试通过”而没有命令、版本、测试数量和退出码,可以要求它补齐验证记录。若要把结果放进持续集成,至少保留失败时的完整 traceback,并让非零退出码阻止后续交付步骤。
五、测试通过后仍要检查什么
最小验证环是入口,不是替代评审。把生成代码接入真实项目之前,还应按风险补充检查:
- 需求边界:税费、优惠券、退款、负数金额、超大整数和精度规则是否另有约定。
- 数据形状:实际输入是否可能缺字段、包含额外字段、来自 JSON,或使用
None。 - 副作用:函数是否应当写库、发消息或调用接口;这些副作用要用隔离的 fake 或集成环境测试。
- 安全性:是否把用户字符串拼进 SQL、命令、模板或路径;这不是几条金额断言能覆盖的。
- 可维护性:异常类型和错误信息是否符合项目约定,类型注解是否与运行时数据一致。
- 性能和并发:大批量数据、重复调用、超时、重试和并发交错需要专门的测试方法。
测试输入应该来自需求和风险,而不是为了让生成代码“看起来覆盖率很高”随意堆数字。每增加一个规则,就增加一个能失败的断言;每个断言都应能说明失败后应该改需求、改测试还是改实现。
六、明确边界:这条环验证了什么
这份例子验证的是一个无外部依赖、确定性较强的纯计算函数。它没有验证:
- Python 之外的运行时、操作系统差异或第三方库兼容性;
- 网络服务、云资源、身份权限、数据库事务或消息队列;
- 多线程、多进程、异步任务的时序与竞态;
- 生产数据规模下的内存、延迟和吞吐;
- 需求本身是否完整、金额规则是否符合法务或财务要求。
因此文章中的环境和输出只代表本机这一次运行记录,不是任何华为云服务的测量,也不是平台验证。边界写清楚,测试结果才不会被误读为超出证据范围的承诺。
总结
面对 AI 生成的 Python 代码,可以先要求一个小而完整的闭环:确定输入,写出精确输出,覆盖正常路径和边界,用 unittest 运行并保留版本、测试数量和退出码,然后明确没有验证的部分。整数金额、固定字面量和不依赖外部状态的断言让结果容易复现;错误输入和输入不变性测试则能快速暴露“能运行但不符合契约”的实现。
这不是把测试数量当质量分数,而是先建立一条可观察、可重复、可解释的最低反馈路径。代码复杂度和风险上升时,再沿着同一条路径扩展到模块测试、集成测试和并发实验。
- 点赞
- 收藏
- 关注作者
评论(0)