别再手写一百个边界用例了用 Property-based Testing 测透金额拆分算法很多 Python 开发者都有过这样的经历你写了一个看起来很简单的算法比如“把一笔金额拆分给 N 个人”一开始觉得逻辑不过几行代码测试也很容易。于是你写了几个例子assertsplit_amount(100,3)[34,33,33]assertsplit_amount(10,2)[5,5]assertsplit_amount(1,3)[1,0,0]测试全绿心里很稳。直到某天线上出现问题某个金额拆分后总和不等于原金额某些输入下有人拿到了负数某些极端情况下小数精度让系统多分了一分钱更麻烦的是这些输入你测试时根本没想到。这就是传统 example-based testing 的局限你只能测试你想到的例子。而 Property-based testing 的价值正在于此它不是让你手写大量具体样例而是让你定义算法必须永远满足的性质然后由测试框架自动生成大量输入帮你寻找反例。在 Python 生态中最常见的 Property-based testing 工具是Hypothesis。它特别适合金额拆分、排序算法、编码解码、状态机、数据转换、权限规则、库存扣减等“输入空间巨大但核心规律明确”的场景。一、什么是 Property-based Testing传统测试通常是这样deftest_split_amount():assertsplit_amount(100,3)[34,33,33]这是“给一个输入验证一个输出”。Property-based testing 则更像这样无论输入金额是多少、人数是多少 只要输入合法 拆分结果都必须满足 1. 总和等于原金额 2. 每一份都不能为负 3. 返回份数等于人数 4. 每两份之间差距不能超过 1 分它关注的不是某个具体结果而是算法永远不能违反的规则。这类规则就叫 property中文可以理解为“性质”“不变量”或“必须始终成立的约束”。举个简单例子。排序算法的测试不一定要写assertsorted([3,1,2])[1,2,3]你可以定义排序结果必须满足排序后长度不变 排序后元素集合不变 排序后相邻元素满足前者 后者然后让测试框架自动生成各种列表包括空列表、重复元素、负数、大数、已经有序的列表、完全逆序的列表。这就是 Property-based testing 的核心思想让机器帮你探索输入空间让规则帮你判断结果是否正确。二、为什么金额拆分算法特别适合这种测试金额拆分看起来简单实际上非常容易出错。比如一个红包拆分、账单分摊、佣金分配、退款拆分、优惠券抵扣分摊都可能涉及金额拆分。常见风险包括总金额丢失一分钱 小数精度误差 拆分结果出现负数 人数为 0 时崩溃 金额小于人数时分配异常 某些人多拿或少拿 边界值下结果不稳定如果你靠手写样例可能会写100 元拆 3 份 10 元拆 2 份 1 元拆 3 份 0 元拆 5 份但真实输入空间远不止这些。金额可能是 0、1、2、999999999人数可能是 1、2、3、10000金额可能不能整除人数某些业务还要求结果尽量均匀。这时候与其手写上百个边界样例不如先问这个算法无论遇到什么合法输入都应该满足哪些性质三、先写一个金额拆分算法假设我们以“分”为单位处理金额避免浮点数误差。需求如下输入 total_cents 表示总金额单位为分 输入 n 表示拆分人数 返回长度为 n 的列表 总和必须等于 total_cents 每份金额尽量平均 多出来的余数从前往后分配实现代码defsplit_amount(total_cents:int,n:int)-list[int]:iftotal_cents0:raiseValueError(total_cents must be non-negative)ifn0:raiseValueError(n must be positive)basetotal_cents//n remaindertotal_cents%n result[]foriinrange(n):ifiremainder:result.append(base1)else:result.append(base)returnresult例如print(split_amount(100,3))# [34, 33, 33]print(split_amount(2,5))# [1, 1, 0, 0, 0]这个算法看起来没问题但我们不应该只相信直觉。接下来用 Property-based testing 来验证它。四、用 Hypothesis 编写第一个性质测试安装pipinstallhypothesis pytest测试代码fromhypothesisimportgiven,strategiesasstgiven(total_centsst.integers(min_value0,max_value10_000_000),nst.integers(min_value1,max_value10_000),)deftest_split_amount_properties(total_cents,n):partssplit_amount(total_cents,n)assertlen(parts)nassertsum(parts)total_centsassertall(part0forpartinparts)ifparts:assertmax(parts)-min(parts)1这段测试非常有意思。它没有写死某个输入也没有写死某个具体输出但它比普通测试覆盖面更广。它自动生成大量合法输入例如total_cents 0, n 1 total_cents 1, n 100 total_cents 9999999, n 3 total_cents 37, n 37 total_cents 38, n 37每一组输入都必须满足我们定义的不变量。这就是 Property-based testing 的第一层价值用少量测试代码覆盖大量输入组合。五、它为什么能发现“你没想到的问题”原因有三个。1. 它不依赖你的直觉选样例人写测试时很容易写“正常样例”。比如你可能会写assertsplit_amount(100,4)[25,25,25,25]assertsplit_amount(100,3)[34,33,33]但你不一定会想到金额为 0 金额小于人数 人数为 1 金额非常大 人数非常大 金额刚好比人数少 1 金额刚好比人数多 1Hypothesis 会主动探索这些边界。它不会被你的经验限制也不会觉得“这个输入太奇怪应该没人这么用”。而真实生产环境最擅长的就是制造你觉得“不应该发生”的输入。2. 它会自动缩小失败样例Property-based testing 不只是随机生成输入。它还有一个非常强大的能力shrinking。也就是说如果它发现某个复杂输入导致测试失败它会继续尝试把这个输入缩小找到最小、最容易理解的反例。比如我们故意写一个有 bug 的版本defbad_split_amount(total_cents:int,n:int)-list[int]:basetotal_cents//nreturn[base]*n这个版本忽略了余数。如果测试失败Hypothesis 不会只告诉你total_cents9823741, n128它可能最终缩小成total_cents1, n2这个反例非常清楚bad_split_amount(1,2)# [0, 0]总和是 0不是 1。这就是它比普通随机测试更有用的地方它不只是发现 bug还努力给你一个最小可复现 bug。3. 它测试的是规则不是你以为的答案传统测试容易把错误写进测试里。比如你误以为split_amount(1,3)[0,0,1]但团队约定是“余数从前往后分配”正确结果应是[1,0,0]如果测试写死具体输出就会和实现细节强绑定。Property-based testing 更关注总和不能变 份数要正确 每份不能为负 差距不能超过 1这让测试不容易被局部实现细节绑架也更接近业务真实要求。六、金额拆分算法还应该测试哪些性质对金额拆分来说常见性质包括长度性质返回结果长度必须等于 n 守恒性质所有拆分金额之和必须等于原金额 非负性质每一份金额都不能小于 0 公平性质最大值和最小值差距不能超过 1 确定性性质相同输入多次调用结果一致 边界性质0 金额可以拆1 人拆分等于原金额 异常性质非法金额或非法人数必须抛错可以继续补充测试given(total_centsst.integers(min_value0,max_value1_000_000),nst.integers(min_value1,max_value1_000),)deftest_split_amount_is_deterministic(total_cents,n):firstsplit_amount(total_cents,n)secondsplit_amount(total_cents,n)assertfirstsecond非法输入测试given(total_centsst.integers(max_value-1),nst.integers(min_value1,max_value100),)deftest_split_amount_rejects_negative_amount(total_cents,n):importpytestwithpytest.raises(ValueError):split_amount(total_cents,n)人数非法测试given(total_centsst.integers(min_value0,max_value1000),nst.integers(max_value0),)deftest_split_amount_rejects_non_positive_n(total_cents,n):importpytestwithpytest.raises(ValueError):split_amount(total_cents,n)这些测试比手写几十个边界样例更简洁也更有穿透力。七、一个更真实的业务案例优惠金额按比例分摊金额拆分不一定总是平均拆分。更常见的业务是一笔优惠金额要按商品金额比例分摊到每个商品上。例如订单里有三件商品商品 A100 元 商品 B200 元 商品 C700 元 总优惠100 元分摊后每个商品应承担一部分优惠并满足所有商品分摊优惠之和 总优惠 每个商品优惠不能为负 每个商品优惠不能超过商品原价 商品金额越大通常分摊越多简化实现defallocate_discount(prices:list[int],discount:int)-list[int]:ifdiscount0:raiseValueError(discount must be non-negative)ifnotprices:raiseValueError(prices must not be empty)ifany(price0forpriceinprices):raiseValueError(price must be non-negative)ifdiscountsum(prices):raiseValueError(discount cannot exceed total price)totalsum(prices)iftotal0:return[0]*len(prices)raw_parts[price*discount//totalforpriceinprices]remainderdiscount-sum(raw_parts)# 将剩余优惠分给价格较高的商品indexessorted(range(len(prices)),keylambdai:prices[i],reverseTrue)foriinindexes[:remainder]:raw_parts[i]1returnraw_partsProperty-based testingfromhypothesisimportgiven,strategiesasstgiven(pricesst.lists(st.integers(min_value0,max_value100_000),min_size1,max_size100),discountst.integers(min_value0,max_value100_000),)deftest_allocate_discount_properties(prices,discount):totalsum(prices)ifdiscounttotal:returndiscountsallocate_discount(prices,discount)assertlen(discounts)len(prices)assertsum(discounts)discountassertall(d0fordindiscounts)assertall(dpford,pinzip(discounts,prices))这里最值得关注的是这一条assertall(dpford,pinzip(discounts,prices))很多优惠分摊算法都会忽略这一点导致某个商品分摊到的优惠金额超过商品自身价格。比如一个 1 分钱商品被分摊了 2 分钱优惠这在财务和退款场景里都可能引发问题。Property-based testing 很擅长挖出这类“你没想到但业务绝不允许”的问题。八、Property-based Testing 适合哪些场景它尤其适合这些场景1. 输入组合特别多比如金额、日期、列表、字符串、树结构、JSON、权限集合。手写样例覆盖不了所有组合时就适合用 property-based testing。2. 存在明确不变量例如金额拆分后总额不变 排序后元素不丢失 编码再解码应得到原数据 压缩再解压应得到原内容 序列化再反序列化对象不变只要能说出“不管输入怎么变结果都必须满足什么”就很适合。3. 边界条件复杂例如金额为 0 空列表 重复元素 极大值 极小值 Unicode 字符串 嵌套结构 时间跨月、跨年、闰年这些边界靠人脑枚举很容易遗漏。4. 算法类代码比如拆分算法 匹配算法 排序算法 路径规划 库存扣减 优惠分摊 风控规则 数据清洗算法越抽象越适合用性质测试补强。5. 你知道规则但不知道所有例子这是最经典的场景。例如你知道金额拆分必须守恒但你不想写一百个金额和人数的组合。那就把“守恒”写成 property把样例生成交给工具。九、不适合哪些场景Property-based testing 不是万能的。它不太适合纯 UI 展示测试 强依赖人工审美的输出 业务规则本身模糊不清 只有少量固定枚举输入 外部系统高度不可控的端到端流程如果你连性质都说不清楚就很难写 property-based testing。比如“推荐结果应该让用户满意”这很难直接写成稳定的 property。但如果你把它拆成不能推荐已下架商品 不能推荐用户屏蔽的内容 推荐列表不能重复 推荐数量不能超过上限这些就可以变成 property。十、最佳实践怎么写出高质量 Property-based Testing1. 先写不变量再写生成器不要一上来就纠结 Hypothesis 怎么生成数据。先写清楚这个函数永远不能违反什么对于金额拆分答案是总和守恒 份数正确 非负 尽量公平2. 控制输入范围不要一开始就生成无限复杂的数据。例如st.integers(min_value0,max_value10_000_000)比完全不限制更实用。输入范围太大可能导致测试慢或者生成一些业务上毫无意义的数据。3. 与普通样例测试结合Property-based testing 不应该完全替代 example-based testing。推荐组合普通样例测试表达关键业务案例 性质测试探索大量输入和边界 回归测试固定已经发现的 bug例如deftest_split_amount_example():assertsplit_amount(100,3)[34,33,33]这个例子仍然有价值因为它清楚表达了“余数从前往后分配”的业务约定。4. 让失败可复现Hypothesis 默认会记录失败样例。对于 CI 环境也应该确保失败信息被完整输出。一旦发现 bug要把最小失败样例转成普通回归测试deftest_split_amount_regression_for_one_cent_two_people():assertsum(split_amount(1,2))15. 不要写太宽泛的性质坏例子assertresultisnotNone这几乎没有意义。好性质应该真正约束业务正确性assertsum(parts)total_centsassertall(part0forpartinparts)assertmax(parts)-min(parts)1Property-based testing 的威力来自高质量性质而不是自动生成数据本身。十一、从工程角度看它带来的真正价值Property-based testing 最大的价值不是帮你少写几个测试而是改变你的思考方式。它会逼你从“这个例子对不对”转向这个系统的核心不变量是什么 什么情况永远不能发生 哪些边界我没有资格假装不存在对于初学者它能帮助你更深刻地理解函数行为。对于资深开发者它能帮助你建立更强的工程防线尤其是在金额、订单、库存、权限、风控等高风险领域。很多生产事故不是因为程序员不会写代码而是因为测试只覆盖了“我们想象中的世界”。Property-based testing 的意义就是让测试主动冲向那些你没有想到的角落。十二、总结让测试替你探索未知回到最初的问题Property-based testing 适合什么场景它适合输入空间巨大、边界复杂、业务不变量明确、手写样例成本高的场景。金额拆分算法就是非常典型的例子。为什么它能发现你没想到的问题因为它不依赖你手工挑选样例而是自动生成大量输入它不仅会探索普通情况还会逼近边界当发现失败时它还会缩小出最小反例让问题更容易定位。但请记住它不是魔法。它能否发挥价值取决于你是否真正理解业务规则是否能写出清晰的不变量。最后送给每个写测试的 Python 开发者一句话好的测试不是证明你想得对而是努力证明你哪里可能错。当你下次面对金额拆分、优惠分摊、库存扣减这类复杂规则时不妨问自己我是在测试几个熟悉的例子还是在验证这个算法面对未知输入时依然可靠