跳到正文

实验 04 / 04 · 40 分钟

回归测试与第一次贡献

让每一次修改都保留证据,把实验成果整理成可评审的 PR。

学习目标

  • 区分单元测试、命令行集成测试与回归测试。
  • 用固定样例和不变量保护公共接口。
  • 运行完整测试并按 Issue 范围提交贡献。

前置知识

  • 完成前三个实验,能运行最终 CLI。
  • 了解 Git commit、Fork 和 Pull Request 的基本含义。
回顾实验 03:命令行工具与文件输出

环境配置与运行

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
运行耗时因设备而异。

实验步骤

  1. 01

    建立测试基线

    运行 discover 命令,应看到 Ran 24 tests 和 OK。test_regression 额外覆盖基线输出、打乱顺序、总和与不修改输入。

  2. 02

    验证测试真的会失败

    临时把 summarize 的 total_minutes 加 1,再运行测试并观察失败。还原这个改动,确认全部测试恢复通过。

  3. 03

    提交一次明确的改进

    Fork 仓库并创建分支,在本页 Issue 说明方案。修改 public/labs/python-engineering 中的文件,提交 PR 并引用 Issue;附上 Python 版本、执行命令和实际测试结果。

回归测试保护已经做对的事情

expected.json 保存基线契约;测试比较解析后的 JSON,不依赖缩进。顺序不变性和项目总和检查验证的是性质,能捕捉样例之外的逻辑回退。

小范围修改更容易验证

先运行全部测试得到基线,再为新需求增加失败测试,实现改动后重跑。课程中的本地完成勾选是学习记录,不是自动测试成绩,也不会在 GitHub 自动认领任务。

完整代码

以下为课程包中的实际源码。后续实验复用前序模块,首次运行请下载完整课程包。

test_regression.py
"""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()
expected.json
{
  "session_count": 4,
  "total_minutes": 150,
  "projects": {
    "git": 30,
    "python": 90,
    "web": 30
  }
}

测试用例

执行上面的测试命令,应看到 24 个测试通过和 OK。先理解断言,再尝试引入错误验证测试有效。

test_lab01.py
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
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
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。
查看并认领 Issue #4

下一步