把失败固定成测试
目标
用 pytest 复现并修复有缺陷的日志轮转工具——纯函数测试、tmp_path、先写会失败的测试、parametrize、capsys、monkeypatch、JUnit 报告。
为什么重要
说“修好了”并不是证据。复现 bug 的测试,在修复之前会失败,修复之后通过,此后还能防止同样的 bug 再回来。对于依赖文件、时间和输出的运维工具,用 tmp_path、monkeypatch、capsys 把这些依赖限制在测试之内。本实验的评分器会把你的测试文件也对着原始缺陷代码运行,从两个方向确认测试是否真的能抓出 bug。
素材:/opt/fixtures/pyops/buggy/rotate.py——rotate.py DIR --keep N [--dry-run] [--older-than-days D]。plan(files, keep) 是返回要删除的文件列表的纯函数,main(argv) 返回整数。两个缺陷:(1)比 keep 多留了一个,(2)明明是 --dry-run 却删除了。
步骤
- 用
mkdir -p /root/pyops/test && cp /opt/fixtures/pyops/buggy/rotate.py /root/pyops/test/复制,并在/root/pyops/test/test_rotate.py中写一个直接调用plan()的测试。keep 大于文件数时,必须得到空列表。pytest -q通过。 - 在 tmp_path 中创建 5 个 .log 文件,编写测试,确认在执行
rotate.main([str(tmp_path), "--keep", "3"])之后留下 3 个。函数名称中必须包含keep。这个测试在现在的代码上必须失败(复现)。 - 修改 rotate.py 的 plan(),使其只留下 keep 个。
pytest -q全部通过。 - 在 tmp_path 中创建 2 个文件,编写测试,确认在
--keep 0 --dry-run之后仍然是原来的 2 个(函数名称中包含dry)。它在原始代码上必须失败,修改 rotate.py 使它通过。 - 用
@pytest.mark.parametrize编写测试,把 keep 依次换成 0、1、3、10,确认留下的文件数是 min(5, keep)。在pytest --collect-only -q中,必须至少能看到 4 个带有[的 ID。 - 编写测试,用 capsys 确认
delete <파일이름>(占位符为文件名)会被打印到标准输出(函数名称中包含output,使用capsys)。 - 用 monkeypatch 固定
rotate.time.time之后,编写测试,确认--older-than-days 7只删除 8 天前的文件,而保留 1 天前的文件(函数名称中包含older,使用monkeypatch)。文件的修改时间用os.utime来设定。 - 用
pytest -q --junitxml=/root/pyops/test/report.xml留下报告。测试必须有 7 个以上,并且 failures 和 errors 必须为 0。
参考
- 测试文件在
/root/pyops/test/内用pytest -q运行。如果与 rotate.py 在同一个目录,import rotate就可以成功。 - 8 天前的时间:给
os.utime(path, (t, t))传入t = FIXED - 8 * 86400。FIXED 是通过 monkeypatch 让 time.time 返回的值。 - 常见错误:真的等待实际时间的 sleep,使用固定路径 /tmp,以及在修复之前不确认它是否失败。
先测试纯函数
把 rotate.py 复制到 /root/pyops/test/,并在 /root/pyops/test/test_rotate.py 中编写一个直接调用 plan() 的测试。keep 大于文件数时,必须得到空列表。pytest -q 通过。
import rotate 之后,assert rotate.plan(["a.log", "b.log"], keep=10) == [] 就行。测试函数名称必须以 test_ 开头,pytest 才能找到它。
用会失败的测试来复现
在 tmp_path 中创建 5 个 .log,编写测试(名称中包含 keep),确认在 rotate.main([str(tmp_path), "--keep", "3"]) 之后留下 3 个。在现在的代码上它必须失败。
用 (tmp_path / f"app-{i}.log").write_text("x") 创建,并查看 list(tmp_path.glob("*.log")) 的长度。由于缺陷会留下 4 个,测试变红才是正常的。
修复并确认变绿
修改 rotate.py 的 plan(),使其只留下 keep 个。pytest -q 全部通过。
切片的末尾应当是 len(files) - keep。修复之后,第 2 步的测试必须通过,第 1 步的测试也必须仍然通过。
第二个缺陷也按同样的顺序处理
在 tmp_path 中创建 2 个文件,编写测试(名称中包含 dry),确认在 --keep 0 --dry-run 之后仍然是原来的 2 个。它在原始代码上必须失败,修改 rotate.py 使它通过。
main() 在调用 os.remove 之前,必须先看 args.dry_run。输出(delete ...)在 dry-run 中也照常打印,这是惯例。
用一个测试覆盖多组输入
用 @pytest.mark.parametrize 编写测试,把 keep 换成 0、1、3、10,确认留下的文件数是 min(5, keep)。pytest --collect-only -q 中会出现至少 4 个带有 [ 的 ID。
把 @pytest.mark.parametrize("keep", [0, 1, 3, 10]) 加在函数上方,并把 keep 作为参数接收。ID 会像 test_名称[0]、test_名称[1] 那样生成。
输出也是契约
编写测试(名称中包含 output,使用 capsys),用 capsys 确认 delete <文件名> 会被打印到标准输出。
把 capsys 放进函数参数,在 rotate.main(...) 之后,assert capsys.readouterr().out 中有 "delete app-0.log"。保留下来的文件名不应出现在其中。
替换时间
用 monkeypatch 固定 rotate.time.time 之后,编写测试(名称中包含 older,使用 monkeypatch),确认 --older-than-days 7 只删除 8 天前的文件,而保留 1 天前的文件。
用 monkeypatch.setattr(rotate.time, "time", lambda: FIXED) 固定,并用 os.utime(path, (t, t)) 把文件时间设为 FIXED - 886400、FIXED - 186400。
供机器读取的报告
用 pytest -q --junitxml=/root/pyops/test/report.xml 留下报告。测试必须有 7 个以上,并且 failures 和 errors 必须为 0。
--junitxml 会在 testsuite 元素中留下 tests、failures、errors 属性。如果有失败,退出码也是 1。