实验 04 / 04 · 40 分钟
回归测试与第一次贡献
让每一次修改都保留证据,把实验成果整理成可评审的 PR。
学习目标
- 区分单元测试、命令行集成测试与回归测试。
- 用固定样例和不变量保护公共接口。
- 运行完整测试并按 Issue 范围提交贡献。
前置知识
- 完成前三个实验,能运行最终 CLI。
- 了解 Git commit、Fork 和 Pull Request 的基本含义。
环境配置与运行
Python 3.11+ · 仅标准库 · 在本机终端运行
下载并解压课程包,打开其中的 python-engineering 目录。以下命令均在包含 lab01.py 的目录执行。首次学习时创建环境,后续实验复用同一个 .venv。
py -3 --version
py -3 -m venv .venv
.\.venv\Scripts\python.exe --version.\.venv\Scripts\python.exe -X utf8 lab03.py sessions.csv --output report.json.\.venv\Scripts\python.exe -X utf8 -m unittest discover -s . -p "test_*.py" -v写入 report.json;成功时 stdout 为空。
全部测试:Ran 24 tests ... OK
运行耗时因设备而异。实验步骤
- 01
建立测试基线
运行 discover 命令,应看到 Ran 24 tests 和 OK。test_regression 额外覆盖基线输出、打乱顺序、总和与不修改输入。
- 02
验证测试真的会失败
临时把 summarize 的 total_minutes 加 1,再运行测试并观察失败。还原这个改动,确认全部测试恢复通过。
- 03
提交一次明确的改进
Fork 仓库并创建分支,在本页 Issue 说明方案。修改 public/labs/python-engineering 中的文件,提交 PR 并引用 Issue;附上 Python 版本、执行命令和实际测试结果。
回归测试保护已经做对的事情
expected.json 保存基线契约;测试比较解析后的 JSON,不依赖缩进。顺序不变性和项目总和检查验证的是性质,能捕捉样例之外的逻辑回退。
小范围修改更容易验证
先运行全部测试得到基线,再为新需求增加失败测试,实现改动后重跑。课程中的本地完成勾选是学习记录,不是自动测试成绩,也不会在 GitHub 自动认领任务。
完整代码
以下为课程包中的实际源码。后续实验复用前序模块,首次运行请下载完整课程包。
"""Lab 04: lock down the public contract before contributing."""
import json
import random
import unittest
from pathlib import Path
from lab02 import load_sessions, summarize
from test_lab03 import run_cli
ROOT = Path(__file__).resolve().parent
class RegressionTests(unittest.TestCase):
def test_cli_matches_golden_report(self):
expected = json.loads((ROOT / "expected.json").read_text(encoding="utf-8"))
result = run_cli(ROOT / "sessions.csv")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), expected)
def test_summary_is_order_independent(self):
sessions = load_sessions(ROOT / "sessions.csv")
expected = summarize(sessions)
for seed in range(10):
shuffled = sessions.copy()
random.Random(seed).shuffle(shuffled)
with self.subTest(seed=seed):
self.assertEqual(summarize(shuffled), expected)
def test_total_equals_sum_of_projects(self):
report = summarize(load_sessions(ROOT / "sessions.csv"))
self.assertEqual(report["total_minutes"], sum(report["projects"].values()))
def test_summary_does_not_modify_input(self):
sessions = load_sessions(ROOT / "sessions.csv")
before = sessions.copy()
summarize(sessions)
self.assertEqual(sessions, before)
if __name__ == "__main__":
unittest.main()
测试用例
执行上面的测试命令,应看到 24 个测试通过和 OK。先理解断言,再尝试引入错误验证测试有效。
test_lab01.py
import unittest
from datetime import date
from lab01 import Session, parse_row
class ParseRowTests(unittest.TestCase):
def test_valid_row(self):
self.assertEqual(
parse_row({"date": "2026-09-28", "project": "python", "minutes": "45"}),
Session(date(2026, 9, 28), "python", 45),
)
def test_surrounding_whitespace(self):
row = {"date": " 2026-09-28 ", "project": " python ", "minutes": " 15 "}
self.assertEqual(parse_row(row).project, "python")
self.assertEqual(parse_row(row).minutes, 15)
def test_invalid_minutes(self):
for value in ["0", "-1", "1.5", "", "abc", "+2", "1_000"]:
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "minutes"):
parse_row({"date": "2026-09-28", "project": "python", "minutes": value})
def test_missing_field(self):
with self.assertRaisesRegex(ValueError, "required fields"):
parse_row({"date": "2026-09-28", "project": "python"})
def test_invalid_date(self):
for value in ["2026-02-30", "20260928", "28/09/2026"]:
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "date"):
parse_row({"date": value, "project": "python", "minutes": "30"})
def test_empty_project(self):
with self.assertRaisesRegex(ValueError, "project"):
parse_row({"date": "2026-09-28", "project": " ", "minutes": "30"})
if __name__ == "__main__":
unittest.main()
test_lab02.py
import tempfile
import unittest
from pathlib import Path
from lab02 import load_sessions, summarize
class CsvTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "sessions.csv"
def write(self, content, encoding="utf-8"):
self.path.write_text(content, encoding=encoding)
def test_sample_summary(self):
sample = Path(__file__).with_name("sessions.csv")
self.assertEqual(summarize(load_sessions(sample)), {
"session_count": 4,
"total_minutes": 150,
"projects": {"git": 30, "python": 90, "web": 30},
})
def test_header_only(self):
self.write("date,project,minutes\n")
self.assertEqual(summarize(load_sessions(self.path)), {
"session_count": 0, "total_minutes": 0, "projects": {},
})
def test_utf8_bom_and_quoted_comma(self):
self.write('date,project,minutes\n2026-09-28,"docs, tests",20\n', "utf-8-sig")
self.assertEqual(load_sessions(self.path)[0].project, "docs, tests")
def test_wrong_header(self):
self.write("day,project,minutes\n")
with self.assertRaisesRegex(ValueError, "header"):
load_sessions(self.path)
def test_error_includes_line(self):
self.write("date,project,minutes\n2026-09-28,python,-5\n")
with self.assertRaisesRegex(ValueError, "line 2: minutes"):
load_sessions(self.path)
def test_extra_column(self):
self.write("date,project,minutes\n2026-09-28,python,10,extra\n")
with self.assertRaisesRegex(ValueError, "extra columns"):
load_sessions(self.path)
def test_missing_cell(self):
self.write("date,project,minutes\n2026-09-28,python\n")
with self.assertRaisesRegex(ValueError, "required fields"):
load_sessions(self.path)
def test_unclosed_quote(self):
self.write('date,project,minutes\n2026-09-28,"python,10\n')
with self.assertRaisesRegex(ValueError, "line"):
load_sessions(self.path)
if __name__ == "__main__":
unittest.main()
test_lab03.py
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run_cli(*args):
return subprocess.run(
[sys.executable, "-X", "utf8", str(ROOT / "lab03.py"), *map(str, args)],
capture_output=True, text=True, encoding="utf-8",
)
class CliTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
def test_stdout_json(self):
result = run_cli(ROOT / "sessions.csv")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["total_minutes"], 150)
self.assertEqual(result.stderr, "")
def test_output_file(self):
output = self.directory / "report.json"
result = run_cli(ROOT / "sessions.csv", "--output", output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["session_count"], 4)
self.assertEqual(result.stdout, "")
def test_missing_input(self):
result = run_cli(self.directory / "missing.csv")
self.assertEqual(result.returncode, 2)
self.assertIn("error:", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_invalid_input_preserves_existing_report(self):
source = self.directory / "invalid.csv"
source.write_text("date,project,minutes\n2026-09-28,python,-1\n", encoding="utf-8")
output = self.directory / "report.json"
output.write_text("keep me", encoding="utf-8")
result = run_cli(source, "--output", output)
self.assertEqual(result.returncode, 2)
self.assertEqual(output.read_text(encoding="utf-8"), "keep me")
def test_cannot_overwrite_source(self):
source = self.directory / "input.csv"
content = (ROOT / "sessions.csv").read_text(encoding="utf-8")
source.write_text(content, encoding="utf-8")
result = run_cli(source, "--output", source)
self.assertEqual(result.returncode, 2)
self.assertEqual(source.read_text(encoding="utf-8"), content)
def test_help(self):
result = run_cli("--help")
self.assertEqual(result.returncode, 0)
self.assertIn("--output", result.stdout)
if __name__ == "__main__":
unittest.main()
常见错误
Ran 0 tests
确认在课程目录执行命令,且完整下载了 test_*.py。文件名和测试函数名都必须保留 test 前缀。
本地通过,CI 没通过
比较 Python 版本、编码和文件路径大小写。测试不要依赖桌面绝对路径、系统语言或上次运行留下的 report.json。
PR 包含 .venv 或 __pycache__
这些是本地环境产物,仓库已将它们加入忽略规则。检查提交范围,只提交源码、测试和必要文档。
验收清单
自评记录仅保存在当前浏览器,不代表自动测试结果或 Issue 认领状态。
从实验到贡献
补充命令行可移植性回归测试
这是完成基线后的扩展任务。先查看 Issue 当前状态和留言,与维护者确认认领范围。
- 增加输入路径包含空格的子进程测试。
- 增加从另一个工作目录通过绝对路径调用 CLI 的测试。
- 只在临时目录产生文件,测试适用于 Windows 与 Linux。