AI 写的 Python 代码能跑就够了?用金额计算补上单元测试
摘要:AI 生成的 Python 函数能正常运行,却可能在舍入、输入类型和边界条件上偏离需求。通过一个金额计算案例,演示如何先定义规则,再让 AI 找反例,最后用 Decimal 和 unittest 验证修复。附完整代码、测试用例和可复制提示词。
输入单价 2.675、数量 1,希望保留两位小数,程序却返回 2.67。
这类问题很适合拿来检查 AI 生成的代码:函数很短,没有网络请求,也没有复杂框架,错误却不一定能靠“看起来没问题”发现。
例如,把“写一个计算总价的 Python 函数,保留两位小数”交给 AI,可能得到这样的实现:
defcalculate_total(unit_price,quantity):returnround(float(unit_price)*quantity,2)print(calculate_total("2.675",1))# 2.67代码能够执行,结果也确实有两位小数。但如果业务要求这一项输出 2.68,它就没有完成任务。
这里的代码是教学样例,不是对某个模型真实回答的测评。关键在于:先把“正确”写成明确约定,测试才有判断依据。
1. 不要让 AI 替你决定业务规则
“保留两位小数”还不足以定义金额计算。至少需要说清输入格式、数值范围、舍入方式,以及在哪一步舍入。
为了让例子可复现,采用下面这份约定:
| 项目 | 本例规则 |
|---|---|
| 单价类型 | 十进制字符串,不接受 float |
| 单价格式 | 1~6 位整数,可带 1~3 位小数;只接受 ASCII 数字 |
| 单价范围 | 非负,最大 999999.999 |
| 数量 | 1~10000 的整数,不接受布尔值 |
| 运算顺序 | 单价乘数量后,对总价舍入一次 |
| 舍入规则 | ROUND_HALF_UP;本例非负数在中点时向上舍入 |
| 返回值 | 固定两位小数的字符串 |
| 非法输入 | 类型不符抛 TypeError,格式或范围不符抛 ValueError |
这是教学场景的选择,不是所有金额系统的统一标准。真实项目如果要求逐项舍入、不同货币精度或退款负数,应该先修改约定,再修改实现。
例如,单价为 0.005、数量为 2:
- 先乘再舍入:0.005 × 2 = 0.010,结果是 0.01。
- 先把单价舍入为 0.01,再乘 2,结果是 0.02。
两条计算路线都能写成合法代码,但不能在需求不清楚时随意替换。
图 1:预期结果来自规则。旧代码的输出只能作为待检查对象。
2. 第一轮提示词:先找反例,暂时别改代码
直接说“帮我优化一下”,模型可能优先调整命名、补注释,却保留关键行为。更具体的提问方式是:
请审查下面的 Python 金额计算函数,先不要重写。 业务约定: 1. 单价只能是非负十进制字符串,1~6 位整数,可带 1~3 位小数。 2. 不接受科学计数法、空格、NaN、Infinity 或 float。 3. 数量只能是 1~10000 的整数,bool 不算合法整数。 4. 先用单价乘数量,再按 ROUND_HALF_UP 将总价舍入到两位小数。 5. 返回固定两位小数的字符串。 6. 类型错误抛 TypeError,格式或范围错误抛 ValueError。 请输出: - 违反约定的具体行为; - 每个问题对应的最小反例; - 根据约定手工推导的预期结果; - 需要运行验证的地方。 不要把旧函数的输出直接当作测试期望值。 没有运行工具时,请明确说“尚未执行”,不要声称测试已通过。 待审查代码: def calculate_total(unit_price, quantity): return round(float(unit_price) * quantity, 2)一个有价值的审查应该指出:原函数返回 float;没有限制数量;接受了某些约定之外的输入;转换为二进制浮点数后,结果可能不符合指定的十进制舍入语义。
这些发现都可以落实到测试。反过来,“代码不够健壮”没有给出输入和预期,就还不能指导修复。
3. 修复实现:从字符串构造 Decimal
Python 的 Decimal 支持十进制运算,也能显式指定舍入方式。不要先转成 float,再期待 Decimal 恢复原始十进制输入。具体构造和舍入行为可查阅 Python Decimal 官方文档。
在一个空目录中创建pricing.py,使用 Python 3.10 或更新版本即可运行,无需额外安装依赖。
importrefromdecimalimportContext,Decimal,ROUND_HALF_UP,localcontextdefcalculate_total(unit_price:str,quantity:int)->str:"""Multiply first, then round once to two decimal places."""ifnotisinstance(unit_price,str):raiseTypeError("unit_price must be a decimal string")ifnotre.fullmatch(r"[0-9]{1,6}(?:\.[0-9]{1,3})?",unit_price):raiseValueError("price needs 1-6 integer digits and 0-3 decimal digits")iftype(quantity)isnotint:raiseTypeError("quantity must be an integer, not bool")ifnot1<=quantity<=10000:raiseValueError("quantity must be between 1 and 10000")withlocalcontext(Context(prec=28,rounding=ROUND_HALF_UP)):total=Decimal(unit_price)*quantityreturnformat(total.quantize(Decimal("0.01")),".2f")if__name__=="__main__":print(calculate_total("2.675",1))print(calculate_total("0.005",2))实现里有几个决定值得保留:
入口接受字符串。调用方传入"2.675",而不是已经转换过的浮点值。遇到 float 直接拒绝,让边界清晰。
格式检查与数值计算分开。正则表达式约束位数、字符和小数格式,顺带排除了 NaN、Infinity、科学计数法、负号和空白。前导零允许存在,但仍占整数位数。
布尔值单独排除。Python 中 bool 是 int 的子类,使用isinstance(True, int)会得到 True。因此这里使用严格类型判断,落实“不接受布尔值”的约定。
舍入只发生在乘法之后。如果提前对单价调用 quantize,就改变了计算规则。
使用独立的计算上下文。为这个函数设置精度和舍入规则,避免依赖调用方当前的 Decimal 上下文。本例输入上限对应的乘积位数远低于 28 位精度;扩大输入范围时,应重新检查这一假设。
执行:
python pricing.py输出:
2.68 0.01这里返回的是用于明确接口约定的字符串。若下一步还要继续运算,可以在系统设计中约定返回 Decimal;不要为了显示方便又转回 float。
4. 测试要覆盖规则,而不只是正常路径
在同一目录创建test_pricing.py:
importunittestfrompricingimportcalculate_totalclassPricingTests(unittest.TestCase):deftest_normal(self):self.assertEqual(calculate_total("19.90",3),"59.70")deftest_half_up(self):self.assertEqual(calculate_total("2.675",1),"2.68")self.assertEqual(calculate_total("2.665",1),"2.67")deftest_round_after_multiplication(self):self.assertEqual(calculate_total("0.005",2),"0.01")deftest_zero(self):self.assertEqual(calculate_total("0",1),"0.00")deftest_upper_bound(self):self.assertEqual(calculate_total("999999.999",10000),"9999999990.00")deftest_bad_price_format(self):forpricein["-1","NaN","Infinity","1e2"," 1","1.0001","1000000",""]:withself.subTest(price=price):withself.assertRaises(ValueError):calculate_total(price,1)deftest_bad_price_type(self):withself.assertRaises(TypeError):calculate_total(2.675,1)deftest_quantity_contract(self):forqtyin[True,1.5,"2"]:withself.subTest(qty=qty):withself.assertRaises(TypeError):calculate_total("1",qty)forqtyin[0,-1,10001]:withself.subTest(qty=qty):withself.assertRaises(ValueError):calculate_total("1",qty)if__name__=="__main__":unittest.main()在文件所在目录执行:
python-munittest-v如果本机的 Python 命令是python3,替换命令开头即可。unittest 是 Python 标准库中的测试框架,命令行用法见 官方说明。
这组代码已在本地执行,8 个测试方法通过。输出末尾如下,运行耗时因环境而异:
Ran 8 tests in ... OK其中,2.665 的案例用来检查指定的中点舍入行为;0.005 × 2 的案例检查舍入时机;非法输入用例检查函数有没有把约定落实到入口。一个测试方法中可能含有多个子用例,因此“8 个测试方法”不等于“仅检查了 8 个输入”。
测试结果能说明这些已覆盖行为符合约定,不能证明函数在所有系统环境里都没有问题。
5. 第二轮提示词:让修改范围保持可检查
确认反例之后,再让 AI 修改代码:
请根据已经确认的业务约定进行最小修复。 要求: - 保留 calculate_total(unit_price, quantity) 的调用形式。 - 只使用 Python 标准库。 - 使用十进制字符串构造 Decimal。 - 先乘法,再对总价按 ROUND_HALF_UP 保留两位小数。 - 为类型、格式和范围错误抛出约定的异常。 - 不增加网络请求、文件读写或第三方依赖。 请交付: 1. 修复后的完整函数; 2. unittest 测试; 3. 每个测试对应的业务规则; 4. 实际运行结果;无法运行时明确说明; 5. 未覆盖的场景。 不要修改预期值来迁就实现。拿到结果后,先检查测试里的预期值,再执行测试。假如 AI 同时生成实现和测试,它可能在两处重复同一个理解错误;绿色的测试结果并不能自动纠正需求误解。
例如,把“先舍入单价”同时写进代码和测试,测试会通过,计算方式却违反了本例约定。因此,关键反例的结果需要依据规则独立推导。
6. 接入真实项目,还要验证函数之外的边界
这份样例只验证单价和数量的计算,不包含税费、优惠分摊、退款、汇率和多币种,也不决定真实业务应采用哪一种舍入制度。
接入接口时,检查前端是否已经把单价转成浮点数;写入数据库时,检查字段精度和转换方式;跨服务传递时,检查金额是否保持约定的表示形式。函数内部正确,不代表整个数据链路没有精度变化。
提供上下文给 AI 时,优先使用这种最小复现和虚构样例。没有必要为了解释一个舍入问题,上传真实订单、客户信息或完整业务仓库。
下一次拿到 AI 生成的函数,可以先问一句:哪一个输入最容易证明它不符合需求?找到这个输入,再把对应的业务预期写成测试,修改结果就有了具体的验收依据。