Python 23:如何替换时间而不替换真实行为
同一段 "One two one" 在两组可控时间读数下走出不同的可检查结果:开始前读到 5、deadline 为 5,任务仍为 queued;开始读到 1、计算后读到 6,任务记为 failed,result 保持 null,原因里保留 TimeoutError。没有在测试里 sleep 等待时间到来,也没有把 SQLite 换成一份声称“差不多”的字典。
第22 篇的测试分层强调的是断言真实状态,而非统计 mock 被调用的次数。本篇只替换时间读数,用真实任务函数和真实临时数据库检验超时后的结果;还保留 16 的实际文件句柄关闭路径。
让时钟可控,工作仍真实
app.run 增加可选的 clock 和绝对时间 deadline 参数,默认时钟是 time.monotonic。当前顺序执行在开始前与计算后各检查一次:若开始前已到 deadline,抛 TimeoutError 且保留排队状态;若计算后才超时,由原来捕获执行故障的路径设置 failed、记录错误并抛出带原因的 TaskExecutionError。SQLite 仓储把同一时钟与 deadline 传入原业务函数,在执行失败时提交失败状态,绝不提交一个成功结果。
测试用 lambda: 5 或 iter((1, 6)) 提供确定的读数,不改系统时间。它能稳定证明这两个检查点的决策,不能证明计算会在截止时刻被抢占:长时间纯 CPU 任务在两次检查之间照样会运行,外部副作用也不会被自动撤销。monotonic 的刻度只用来比较差值/截止条件,不应把数字 5 当日历时间或跨机器共同时间;真实进程的计时策略与事件循环中的取消留到并发篇。
保留不可替换的边界
演示用临时目录写真实 UTF-8 文件,消费第一个词就退出 open_word_file,检查 source.closed;另用真实 SQLite 文件记录任务。可控时钟在计算后给出 6,重新创建仓储查询到 failed 和 null,异常原因是 TimeoutError: task deadline exceeded after compute。换成固定未过期的读数 1,再提交相同文本,新任务成功,重开连接读到 {"one": 2, "two": 1}。如果只断言 clock 被调用两次,就无法证明文件关闭、事务提交和任务未记成 succeeded。
1 | |
本章的演示和累计 71 项测试通过,原始时钟输入、异常原因、数据库重开后的状态和文件关闭结果保存在 examples/python-language/evidence/23/20261007-clock/。此前真实文件、跨进程 SQLite 与 CLI 的测试继续运行,没有用替身替换它们。注入的是本进程的测试时钟;网络服务超时、系统时钟跃迁、运行中强制终止和外部写入的一致性均未测试。
预测题
时钟先返回 1,计算后返回 6,deadline 为 5。业务运算也许已算出词频,最终查询到的是 succeeded 吗?
答案:不是。计算后检查发现超时,app.run 走失败分支且不写成功结果;SQLite 层记录 failed 与错误,重开查询的 result 是 null。这只约束本应用内结果提交,不能用来推断此前没有外部副作用。
变更题
将计算后的检查删掉,只在计算之前看 deadline,再让真实计算阻塞超过截止时间。哪项本章断言会先失效?
答案:注入 iter((1, 6)) 时程序只取到首次的 1,直接返回成功,原来 failed、result is None、错误原因是 TimeoutError 的断言都会失败;不必靠不稳定的睡眠时间推断。若希望执行中更快响应,还需在工作单元内部定义检查点或采用可取消的执行策略,而不是称当前两个检查点能抢占 CPU。
24 将验证环境为何在当前目录能导入、换成新解释器却不能运行。系列导航只链接已通过页面检查的正文。


