Python TDD 学习笔记 Day 06:让代码更容易测试
前几天主要学习怎样写测试。今天换个角度:代码怎样组织,才能让测试更简单、结果更稳定? 我们用“判断订单是否过期”练习,把获取时间、业务判断和打印结果分开。不过,学到最后,我的实际感受是:把打印的内容捕获下来,方便了文字对比,但对这么小的例子,拆开函数的好处并不明显。这也是今天需要说清楚的一点。 ## 先明确过期规则 本次约定:当前时间达到或超过截止时间,订单就算过期。 | 截止时间 | 当前时间 | 预期 | | --- | --- | --- | | 10:00 | 09:59 | 未过期 | | 10:00 | 10:00 | 已过期 | | 10:00 | 10:01 | 已过期 | 如果函数内部直接读系统时间,测试结果就会随运行时间改变。我们也不希望为了测试过期逻辑,真的等待一分钟,或修改电脑时间。 简单的办法是把“当前时间”作为输入: ```python def is_order_expired(expires_at, now): return now >= expires_at ``` 函数只根据传入的时间判断。`>=` 的结果本身就是布尔值,可以直接返回。 ## 用固定时间测试,不必等待 第一个测试检查超过截止时间的情况: ```python from datetime import datetime from order_expiry import is_order_expired def test_order_is_expired_after_deadline(): expires_at = datetime(2026, 10, 17, 10, 0) now = datetime(2026, 10, 17, 10, 1) assert is_order_expired(expires_at, now) is True ``` `datetime(年, 月, 日, 时, 分)` 创建一个具体时间。本节所有时间按同一时区理解,先不展开时区处理。 函数最初暂时返回 `False`,测试如预期失败;改成时间比较后通过。随后补上 09:59 和恰好 10:00 两个案例。 恰好到期的测试能防止以后有人把 `>=` 误改成 `>`;未到期的测试则防止“一直返回 True”这种实现蒙混过关。相同输入始终得到相同结果,测试不再依赖运行时的真实时间。 ## 实际程序从哪里取得时间 测试使用固定时间,实际运行依然使用真实时间,只是把获取动作放到调用处: ```python expires_at = datetime(2026, 10, 17, 10, 0) now = datetime.now() expired = is_order_expired(expires_at, now) ``` 业务规则保持一份,输入来源可以不同。这是今天最实用的经验:把会随环境变化的数据从外部传入,让判断函数只处理规则。 ## 返回结果与打印文字怎么分别测试 判断得到的是 `True` 或 `False`,向用户展示的则是“订单状态:已过期”等文字。练习中把显示写成一个函数: ```python def print_order_status(expired): status = "已过期" if expired else "未过期" print(f"订单状态:{status}") ``` `"已过期" if expired else "未过期"` 表示根据布尔值选择文字。实际程序先判断,再显示: ```python expired = is_order_expired(expires_at, now) print_order_status(expired) ``` 判断结果可以直接断言,但打印文字需要先捕获,才能自动对比。 ## capsys 接住打印内容 `capsys` 是 pytest 自带的 fixture,在测试参数中写上名字就能使用,不需要自己定义: ```python def test_print_expired_order_status(capsys): print_order_status(True) captured = capsys.readouterr() assert captured.out == "订单状态:已过期\n" ``` 执行显示函数后,`readouterr()` 读取捕获的内容;`captured.out` 是标准输出中的文字。末尾的 `\n` 来自 `print()` 默认添加的换行。 显示函数最初只有 `pass`,没有输出,测试因此失败。加入打印实现后通过,再补充传入 `False` 时应显示“未过期”的测试。 这解决了我提出的问题:“capsys 有什么作用,在什么地方用?”它常用于检查命令行程序的成功提示、状态文字或使用说明。若需求只关心计算结果,直接检查返回值即可,不必检查打印文字。 注意,`print()` 自身返回 `None`。不能用 `assert print("你好") == "你好"` 来检查它打印了什么。 ## 小程序一定要拆成多个函数吗 我在总结时说,感觉主要是方便文本对比,整体上没觉得有什么好处。 对当前例子,这个感受有道理:判断只有一行,打印也很简单,拆分反而增加了函数和文件。今天这样安排主要是为了分别演示返回值测试和输出测试,不能据此认为每句打印都必须抽成函数。 当同一条过期规则需要同时用于终端提示、网页显示和支付判断时,单独返回布尔值的函数更容易复用。修改显示文字时,也不必触动时间判断。简单脚本把打印保留在 `main()` 中同样合理。 实际项目中,先把业务规则与外部数据获取分清楚,再根据复用和维护需要决定拆分程度。 ## 本次验证 在项目根目录运行: ```bash uv run pytest lessons/day06 -v uv run python lessons/day06/demo.py ``` 第一条执行三个时间判断测试和两个输出测试;第二条读取本机真实时间并展示订单状态。演示脚本使用固定截止日期,状态随运行时间变化是正常的。 本篇整理时,Day 06 的 5 个测试和全项目 33 个测试均已通过。 ## 今日收获 今天我理解了,`capsys` 可以捕获程序打印的内容,方便与预期文字对比。对当前的小例子,拆分判断与打印的收益并不明显;当判断结果需要用于终端、网页或其他业务操作时,分开才更方便复用。把当前时间作为参数传入,也让测试可以直接检查固定场景。实际写代码时,应根据需要拆分,避免为了测试而把简单程序变复杂。