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

失败了,退出码却是 0

把失败固定成测试

在 TT Lab 中继续学习

目标

用 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 却删除了。

步骤

  1. 用 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 通过。
  2. 在 tmp_path 中创建 5 个 .log 文件,编写测试,确认在执行 rotate.main([str(tmp_path), "--keep", "3"]) 之后留下 3 个。函数名称中必须包含 keep。这个测试在现在的代码上必须失败(复现)。
  3. 修改 rotate.py 的 plan(),使其只留下 keep 个。pytest -q 全部通过。
  4. 在 tmp_path 中创建 2 个文件,编写测试,确认在 --keep 0 --dry-run 之后仍然是原来的 2 个(函数名称中包含 dry)。它在原始代码上必须失败,修改 rotate.py 使它通过。
  5. 用 @pytest.mark.parametrize 编写测试,把 keep 依次换成 0、1、3、10,确认留下的文件数是 min(5, keep)。在 pytest --collect-only -q 中,必须至少能看到 4 个带有 [ 的 ID。
  6. 编写测试,用 capsys 确认 delete <파일이름>(占位符为文件名)会被打印到标准输出(函数名称中包含 output,使用 capsys)。
  7. 用 monkeypatch 固定 rotate.time.time 之后,编写测试,确认 --older-than-days 7 只删除 8 天前的文件,而保留 1 天前的文件(函数名称中包含 older,使用 monkeypatch)。文件的修改时间用 os.utime 来设定。
  8. 用 pytest -q --junitxml=/root/pyops/test/report.xml 留下报告。测试必须有 7 个以上,并且 failures 和 errors 必须为 0。

参考

先测试纯函数

把 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。