# 用测试和决策记录支持代码演进

原文: https://yishto.com/learn/data-practice/testing-refactoring-and-decisions/

作者: [Yishto](https://yishto.com/about/)

以整数折扣规则说明行为契约、舍入边界与测试层级，用小步重构和决策记录保留代码演进的依据。

首次发布: 2026-11-10
内容更新: 2026-11-10

费用计算函数出现了越来越多的条件，修改一个折扣规则会影响其他分支。重构之前先定义对外行为：价格单位、折扣表达、舍入方法与异常。测试保护这些行为，决策记录解释结构为什么这样安排，两者配合才能让后来的修改有依据。

案例以整数最小货币单位表示教材价格，用 basis points 表示折扣。一万个基点代表全部折扣，1500 代表百分之十五。数值是教学设定，不涵盖真实支付平台、税费或地区币种规则。

## 行为契约先于代码结构

重构改变内部组织，并保留约定的可观察行为。输入校验、返回值、异常和副作用都可能属于行为，不能只比较正常结果。若把无效价格从抛异常改成默默返回零，就已经改变契约，需要作为功能变更单独说明。

先调查调用方怎样使用函数：是否允许零价格、折扣能否为一百、返回单位是否一致、错误是否用于提示。发现原来的行为不合理时，可提出新规则，但应区分保留行为的重构与修正行为的变更，避免在一次结构调整里悄悄改变计费。

测试应表达业务例子与边界，而不是逐行重复算法。一个已知价格与折扣的结果能保护核心规则；零、最大折扣、无折扣和错误类型保护边界。仅断言某个私有函数被调用几次，可能在合理重构后失败，却没有保护用户结果。

## 用精确单位控制计算含义

浮点数适合许多数值任务，但某些十进制金额不能被二进制浮点精确表示。使用最小单位整数可以避免这类表示误差，仍必须定义舍入。整数除法会丢掉余数，本例明确向下取整最终金额，不能把它写成所有商业场景的标准规则。

下面拒绝负价格、不合法折扣与非整数输入。`type(x) is int` 也排除了 Python 中作为 int 子类的 bool，避免 True 被当成一单位价格。

```python
def discounted(price, basis_points):
    if type(price) is not int or price < 0:
        raise ValueError("invalid price")
    if type(basis_points) is not int or not 0 <= basis_points <= 10000:
        raise ValueError("invalid rate")
    return price * (10000-basis_points)//10000
assert discounted(1000, 1500) == 850
assert discounted(0, 1500) == 0
assert discounted(999, 0) == 999
try:
    discounted(-1, 1000)
    raise AssertionError("invalid input accepted")
except ValueError:
    pass
print(discounted(1000, 1500))
```

实际输出是 `850`，表示原价 1000 最小单位折扣百分之十五后的金额。负价格触发 ValueError；正常零价格和无折扣也通过检查。示例仅运行标准库 Python，不使用 pytest 或 unittest 框架，也没有接真实计费系统。

还应加入有余数的例子，例如 999 与 1500 基点的结果为 849，表明这里向下取整。真实产品可能要求四舍五入、银行家舍入或分行计算后再汇总，应以业务与适用规则为准。更换规则时先写对照例子，再修改实现。

## 不同测试层覆盖不同边界

单元测试隔离纯规则，运行快且容易指出错误输入。集成测试核对数据库、外部接口与序列化契约。端到端检查从用户入口走到可观察结果，范围更广，也更受环境影响。根据风险选层级，不需要让每条规则都重复走完整浏览器流程。

mock 能控制依赖返回，适合制造超时与拒绝，但也可能隐藏接口不匹配。如果 mock 使用了真实服务从未返回的字段，测试通过仍不能说明集成正确。保留少量针对真实边界的契约验证，核对输入、输出、状态码和失败条件。

pytest 支持直接 assert 与 fixture，unittest 提供测试组织、准备和清理等标准库机制。框架帮助管理测试，不会自动选择正确用例。测试数据要可复现，失败记录应说明输入与预期，避免把不稳定的时间或随机输出当成无条件断言。

## 结构演进从变化点出发

如果只有一个折扣规则，简单函数可能足够。出现不同地区或套餐策略时，可以把稳定的输入检查与可变计算分开，通过组合或 strategy 方式注入规则。设计模式提供讨论变化与耦合的词汇，并不要求每个值都对应一个类。

逐步替换结构：先增加契约测试，再提取一个变化点，运行相关检查，最后更新调用方。小步变更容易定位失败，也便于撤回。重复代码可能有不同业务含义，合并前应确认它们是否应该共同变化，而不是只比较文本相似度。

代码注释适合解释不明显的原因，例如为什么此处向下取整。用注释重复赋值语句通常没有帮助。技术文档需要说明前置条件、单位、输入输出、异常、使用版本与恢复方式，让调用者能判断是否适合自己的路径。

## 决策记录保留当时的约束

ADR 可以记录上下文、候选方案、决定、状态和后果。案例决定用整数最小单位，原因是输入与结算规则已经采用整数；代价是必须明确舍入并限制单位转换。另一方案是 Decimal，可支持更直接的十进制计算，但仍要管理精度和舍入上下文。

把重新讨论的条件写进去：如果支持具有不同最小单位的币种，或需要税费分项，就重新核查这个规则。后来改变决定时，新增记录并标明旧决定被替代，便于理解历史。只写“选择整数因为更好”无法解释取舍。

开源库也属于技术选型。评估许可证、维护状态、兼容版本、行为范围与替换成本，避免仅凭热度引入依赖。测试通过是局部证据，仍需要确认许可证和运营条件；不应虚构某个库已经在本案例生产使用。

失败用例也需要检查具体异常类型。若测试捕获所有异常，语法错误或字段缺失也可能被误认成正确拒绝。为调用边界注明预期异常与错误信息范围，避免依赖不稳定的完整错误栈。清理测试资源时核对所有分支，防止上一个用例的状态污染下一个结果。

## 练习：拆开重构与规则修改

为折扣函数补齐边界测试，包括零、一单位、带余数、大金额、零折扣、全部折扣、负值、浮点与布尔输入。写一份行为说明，再把计算策略提取出来，要求所有既有行为保持一致。

验收时，相同有效输入的返回单位与舍入不变，无效输入仍按约定拒绝；新增结构不会让调用方绕过校验。然后提出另一种舍入规则，单独写出受影响例子与决策记录，不能把它混称为重构。保留变更前后检查结果与可恢复提交，若涉及数据迁移，再增加独立的兼容与恢复验证。

## 资料来源与延伸阅读

- [Writing tests with pytest](https://docs.pytest.org/en/stable/getting-started.html)
- [Pro Git](https://git-scm.com/book/en/v2)
- [Documenting architecture decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
- [Unit testing framework](https://docs.python.org/3/library/unittest.html)

资料核验：2026-11-10。教学示例不代表线上实测。

## 相关文章

- [把一次修改做成可审阅、可恢复的提交](https://yishto.com/learn/developer-workflow/git-ssh-and-review/)
- [让 AI 开发过程留下可检查的结果](https://yishto.com/learn/product-building/specifications-and-coding-agents/)
