作者通过 PyO3 桥接原始测试套件,故意引入错误验证测试有效性,揭示了「测试通过≠移植正确」的核心命题。
一个能编译通过且测试全绿的移植,并不证明移植是正确的。它只能证明写移植的人同时控制了测试用例。现在生成一个移植版本几乎零成本,但证明它站得住脚才是几乎没人做的部分。
所以我做了这些事来尝试证伪自己的 croniter → Rust 移植,以及它在哪些地方做得不够。
不是翻译后的测试套件。是实际的上游文件,在第一行 Rust 代码出现之前就做了 SHA-256 指纹校验,再通过 PyO3 桥接接到 Rust。git log -- tests/original/ 显示一次提交:vendoring(内嵌)。
然后我认为必不可少的一步:先用一个故意写错的 stub 构建桥接,确认测试失败。
222 failed, 6 passed
这才是成功——它证明了测试套件在任何正确逻辑存在之前就已经在导入并评判 Rust 了。注意那个 6:我的 stub 从 is_valid 返回 False,满足了所有断言表达式无效的测试。连我那个坏掉的基线都有假阳性。
228/228 有两种解释,但从绿 suites 上你无法区分:移植是对的,或者 suite 无法失败。所以我故意把它弄坏——在两个不相关的文件里各改了一个 token:
consts.rs hour range (0,23) -> (0,22) -> 32 failed, 196 passed
expand.rs wrap length +1 -> +2 -> 2 failed, 226 passed
reverted -> 228 passed
十分钟,这就是"测量"和"装饰"的差别。我对基准测试的校验和做了同样的事。
这是我从这个项目里偷来的技术。croniter 暴露了三个回答重叠问题的 API——get_next、get_prev、match——它们必须一致。如果 get_next(start) 返回 N,那么严格介于两者之间的任何值都不应该匹配,而 N 必须匹配。违反这条规则意味着库自相矛盾,在任何对 cron 语义的理解下都有一个答案是错的。
这是一个没有外部参照的 oracle。不需要第二个实现,不需要 spec,不需要人。库自己给自己打分。
我的第一个 oracle 一文不值。它只检查了一个属性,只在 naive datetime 上测试,从不调用 get_prev。19,440 个 case,零发现——我一度把零当作正确。说明问题太简单了。一个无法失败的 invariant 不是 oracle。
重写后检查了五个属性,调用了 get_prev,并将一半的起始时间偏向距离真实 DST 转换四小时以内的地方——包括 Australia/Lord_Howe,地球上唯一有 30 分钟偏移的时区。它在 croniter 里发现了两个真正的 bug,两个都已提交给上游(#258, #259):
tz = zoneinfo.ZoneInfo("Australia/Lord_Howe")
start = datetime(2019, 10, 6, 1, 43, tzinfo=tz)
croniter("0 * * * *", start).get_next(datetime) # 03:00+11:00
croniter("0 * * * *", nxt).get_prev(datetime) # 02:30+11:00 <- AFTER start
croniter.match("0 * * * *", <02:30+11:00>) # True
match 对一个每分钟 0 执行的调度在 minute 30 返回 True。在普通 1 小时时区里相同的代码路径落在 03:00,这是有效的——所以 bug 在除了 30 分钟偏移之外的所有地方都不可见。这正是为什么生成器被指向那里。
第二个:croniter_range 的 stop 测试是 v < stop,而 CPython 当两个操作数共享 tzinfo 时会忽略它。跨 DST 转换时它比较的是墙钟时间而非经过的时间——返回 1 个结果而实际有 6 个,或者返回超出你请求的区间的结果。静默的,没有异常。
我的移植有意识地复现了这两个 bug。移植的职责是行为与被移植对象一致,包括后者出错的地方。
差分模糊测试:同一个探针在两个解释器下运行,比较返回值和异常类型。加入时区感知的输入后在 164,500 次测试中出现了 221 个分歧——都是同一个原因,而且是类型不是值。croniter 抛出裸 ValueError;我的移植抛出 CroniterError。
CroniterError 是 ValueError 的子类。每个 except ValueError 都能捕获它。Suite 在修改前后都是 228/228 绿。它无论跑多久都不可能发现这个。
这就是差分模糊测试的全部论据。零值分歧——日期运算是对的,只是标签错了。最后一次运行:160,500 个输入,0 分歧。
第一个 oracle 花了一天,什么都没教会。我应该在跑了一小时之前就问:"什么样的输入能证伪这个?"
分类比打猎更费时。一个 harness 给出 927 个原始发现;其中 750 个是已记录在案的行为。另一个更早的给出 1,408 个发现,全是我自己 checker 里的 bug。
模糊测试测的是桥,不是交付的二进制。两边都跑在 Python 下,所以核心逻辑是通过 PyO3 调用来验证的。评委收到的 artifact 距离证据隔了一层。
228/228 隐藏了交付物里的一个洞。每个时区测试都提供了 tzinfo,所以都走了桥——而独立二进制根本做不了 DST。Suite 测量的是测试所走的路径。我的 suite 绕过三分之一的产物然后报告满分。
我在自己的 README 里写了一个未经证实的声明(一个从未运行过的 Docker build)。很晚才发现,标记为 unverified 而不是删掉。
没人打印的那个数字:suite 在 Python 上跑 1.54 秒,在我的 25 倍速 Rust 上跑约 1.8 秒。每次调用都跨 FFI。两个事实同时为真。
228/228,未修改测试 · 160,500 模糊输入,0 分歧 · 2 个上游 bug 已提交 · 0 个 unsafe(编译器强制)· 平均 25.3x,p99 26.1x,RSS 小 3.2x · 0 个测试文件被修改。
每个数字都在一台机器上观察,跑完就记下来。唯一无法验证的声明在 repo 里标记为 unverified 而不是删掉。