TT Lab
开始
学习 学习路径 课程

失败了,退出码却是 0

用测试把 bug 钉住

在 TT Lab 中继续学习

一句话总结

运维工具的 bug,要先用会失败的测试复现,再去修复。对于依赖文件、时间、环境变量和输出的工具,用 pytest 的 tmp_path、monkeypatch、capsys 把这些依赖限制在测试之内。

为什么需要它

日志轮转工具说好“只保留 3 个”,结果留下了 4 个。负责人修改了代码,下周同样的 bug 换了一种形态又回来了——这次是 --dry-run 却把文件删了。两起事故的共同点是,修复的人没有留下已经修好的证据。证据就是测试。复现 bug 的测试,在修复之前是红的,修复之后是绿的,此后它就成为防止同样的 bug 再回来的守卫。

工作原理

pytest 会查找 test_*.py 文件中的 test_* 函数并执行,assert 为假就记录为失败。要测试工具,工具就必须可导入——前一个模块之所以坚持 main(argv) -> int 的形态,原因在这里又回来了。像 import rotate; rotate.main([str(d), "--keep", "3"]) 这样调用,就不必重新启动进程,也能测试整个工具。

文件。 tmp_path fixture 会为每个测试函数提供一个唯一的临时目录,类型是 pathlib.Path。在其中创建文件、运行工具、查看结果就行,测试结束后 pytest 会自行清理。会动到真实 /var/log 的测试,不是测试,而是事故。

时间。 “比 7 天更旧的文件”这样的条件依赖 time.time()。我们不可能真的去等时间,所以用 monkeypatch 来替换。像 monkeypatch.setattr(rotate.time, "time", lambda: 1_700_000_000) 这样替换属性,测试结束时就会恢复原样。 文档写道,它提供 setattr、delattr、setitem、setenv、delenv、chdir,全都是同一个原则——只在测试之内改变全局状态。

输出。 如果工具用 print 告知删除了什么,那句话也是契约。capsys fixture 的 readouterr() 会返回测试期间打印的 stdout 和 stderr。像 assert "delete old.log" in captured.out 这样确认。

多组输入。 如果想用 keep=0、1、3、10 把同样的逻辑各跑一遍,不要把函数复制四份。parametrize 装饰器会为参数列表中的每一项各生成一个测试。失败时,在哪个值上失败,会留在测试 ID(test_keep[3])中。

import pytest, rotate

@pytest.mark.parametrize("keep", [0, 1, 3, 10])
def test_keep_leaves_exactly_keep_files(tmp_path, keep):
    for i in range(5):
        (tmp_path / f"app-{i}.log").write_text("x")
    assert rotate.main([str(tmp_path), "--keep", str(keep)]) == 0
    assert len(list(tmp_path.glob("*.log"))) == min(5, keep)

def test_dry_run_deletes_nothing(tmp_path, capsys):
    (tmp_path / "a.log").write_text("x")
    (tmp_path / "b.log").write_text("x")
    rotate.main([str(tmp_path), "--keep", "0", "--dry-run"])
    assert sorted(p.name for p in tmp_path.glob("*.log")) == ["a.log", "b.log"]
    assert "a.log" in capsys.readouterr().out

顺序。 收到 bug 报告之后:(1)编写复现的测试——用现在的代码运行时必须失败。如果不失败,说明复现是错的,在这种状态下修改代码,就不知道修了什么。(2)修改代码。(3)测试通过。(4)其他测试也全部通过——这是修改没有破坏别的东西的证据。这四步中跳过(1),就是“下周同样的 bug 又回来”的原因。

留下结果。 CI 读的不是人的眼睛,而是文件。pytest --junitxml=report.xml 会把测试数、失败数、耗时留成 XML,它就成为流水线的通过条件。还有退出码——全部通过为 0,只要有一个失败就是 1。

在现场相遇的样子

最常见的是“测试看的是真实目录”。用了 os.chdir,或者用了 /tmp/test 这样的固定路径,导致两个测试相互干扰,结果因顺序而异。tmp_path 每个测试的路径都不同,所以没有这个问题。第二种是用 time.sleep(2) 来制造时间的测试——既慢,又会在 CI 繁忙时失败。时间是用来替换的。第三种是“修好了,测试以后再说”。以后是不会来的。先写会失败的测试,反而会缩短修复的时间——因为复现已经在手里了。

下一项实验要做什么

把埋了两个缺陷的日志轮转工具 /opt/fixtures/pyops/buggy/rotate.py 复制到工作目录,按这个顺序进行:纯函数测试 → 用 tmp_path 复现(失败)→ 修复 → 复现 --dry-run → 修复 → parametrize → capsys → 用 monkeypatch 固定时间 → --junitxml 报告。评分器还会把你的测试对着原始缺陷代码运行,确认这些测试是否真的能抓出 bug。