上一篇,我们把默认后缀少一个点的故障复现出来,只修了一行。接下来再改脚本时,总不能靠记忆把这些命令重新敲一遍。
这一篇把约定写成能反复执行的测试:有数据时检查CSV内容,没有数据时检查表头,输入不合法时检查退出码和旧结果是否保留。最后把同一套测试交给故障版,看看它们能不能报出问题。
示例已编写并验证。创作说明:AI 辅助创作。
本篇目标与前置条件
准备第08篇修好后的file_list.py,保留.txt默认后缀和--suffix参数。新建一个独立练习目录,把脚本副本放进去;不要直接用真实资料目录测试。
本次实际环境为Windows、已有Python 3.10.6,使用标准库unittest、tempfile和subprocess,没有安装测试依赖。这是环境记录,不是建议专门安装这个旧版本。下面命令里的python应替换为你确认过的解释器;本次实际执行使用了该解释器的绝对路径。
最终目录只有两个需要手动准备的文件:file_list.py和新建的test_file_list.py。每条测试会在练习目录的_test_runs/下创建自己的临时子目录,复制被测脚本、生成合成输入,完成后清理该临时子目录。它不读取你的真实资料。
第一步:先写出什么结果才算对
不要先让Codex"随便加几条测试"。先确认这些约定是不是你要的:
| 场景 | 应当发生什么 | 如何判断 |
|---|---|---|
| 第一层有两个txt,还有md和嵌套txt | 只输出第一层两个txt,按路径排序 | 比较CSV每个单元格和字节数 |
| input目录存在但为空 | 正常结束,CSV只有表头 | 退出码0,CSV恰好一行 |
| input目录不存在 | 明确失败,不生成CSV | 退出码2,错误提示指向input |
| 后缀传成txt,没有点 | 参数失败,原有CSV不被覆盖 | 退出码2,旧CSV逐字节一致 |
| 分别传.md和.MD | 按第07篇约定区分大小写 | 每次只列出对应文件 |
这里的"空目录成功、缺目录失败"是本项目约定。其他产品可以另定,但应先说清楚,不能让测试替你决定需求。
第二步:把约定交给Codex
可直接使用下面这段任务说明,路径按你的练习目录调整:
text
请先读取本练习目录的file_list.py,只新增test_file_list.py,不改业务脚本。
使用Python标准库unittest,以真实CLI子进程调用脚本。
按上面的五种场景验证:退出码、错误提示、CSV单元格和失败后旧CSV保持不变。
每条测试使用练习目录内独立临时子目录,明确dir,不使用真实资料或系统默认临时目录。
不通过导入业务函数并照抄它的筛选逻辑来计算期望结果。
给出实际运行命令、通过/失败记录;如有失败先报告,不修改断言迎合当前输出。
OpenAI官方的测试提示示例强调给出测试范围,并覆盖正常路径与边界情形。这里进一步明确命令行行为和文件副作用,属于本案例的设计选择,不是某个按钮的固定功能,也不保证同一提示词每次生成相同代码。
第三步:保存完整测试文件
把以下内容保存为与file_list.py同级的test_file_list.py。它测试的是用户运行脚本时看到的行为;这是一组CLI回归测试,不是只调用一个函数的单元测试。
python
import csv
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
BASE = Path(__file__).resolve().parent
TARGET = (BASE / os.environ.get("FILE_LIST_TARGET", "file_list.py")).resolve()
if TARGET.parent != BASE:
raise ValueError("被测脚本必须放在本练习目录")
TEMP = BASE / "_test_runs"
TEMP.mkdir(exist_ok=True)
if TEMP.resolve().parent != BASE:
raise ValueError("测试临时目录不能指向练习目录外")
class FileListTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=TEMP, prefix="case-")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
if self.root.parent != TEMP.resolve():
raise ValueError("测试目录越界")
shutil.copyfile(TARGET, self.root / "file_list.py")
def run_cli(self, *args):
return subprocess.run(
[sys.executable, "-I", "-B", "-X", "utf8",
str(self.root / "file_list.py"), *args],
cwd=self.root, capture_output=True, text=True,
encoding="utf-8", timeout=10,
)
def write_input(self, name, text):
target = self.root / "input" / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
def rows(self):
with (self.root / "output/file-list.csv").open(
encoding="utf-8", newline="") as stream:
return list(csv.reader(stream))
def test_default_rows(self):
self.write_input("b.txt", "hi")
self.write_input("a.txt", "中")
self.write_input("note.md", "ignored")
self.write_input("sub/deep.txt", "ignored")
result = self.run_cli()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.rows(), [
["relative_path", "size_bytes"],
["input/a.txt", "3"], ["input/b.txt", "2"],
])
def test_empty_input(self):
(self.root / "input").mkdir()
result = self.run_cli()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.rows(), [["relative_path", "size_bytes"]])
def test_missing_input(self):
result = self.run_cli()
self.assertEqual(result.returncode, 2)
self.assertIn("input 必须是存在的普通目录", result.stderr)
self.assertFalse((self.root / "output/file-list.csv").exists())
def test_bad_suffix_keeps_old_output(self):
(self.root / "output").mkdir()
old = self.root / "output/file-list.csv"
old.write_bytes(b"previous-result\n")
result = self.run_cli("--suffix", "txt")
self.assertEqual(result.returncode, 2)
self.assertIn("argument --suffix", result.stderr)
self.assertEqual(old.read_bytes(), b"previous-result\n")
def test_suffix_case_sensitive(self):
self.write_input("lower.md", "x")
self.write_input("upper.MD", "yz")
for suffix, name, size in [
(".md", "lower.md", "1"), (".MD", "upper.MD", "2")
]:
with self.subTest(suffix=suffix):
result = self.run_cli("--suffix", suffix)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.rows(), [
["relative_path", "size_bytes"], ["input/" + name, size]
])
if __name__ == "__main__":
unittest.main(verbosity=2)
有几个地方值得自己看懂:
- 每条测试都有新目录,前一条生成的CSV不会让后一条误通过。
- 期望数据直接写在断言里。例如"中"的UTF-8文件为3字节,"hi"为2字节;没有调用被测函数来推导正确答案。
test_bad_suffix_keeps_old_output先放一个明确的旧文件,再执行坏参数,确认它没有被覆盖。仅仅检查"文件还存在"不够。subTest分别检查.md和.MD,但它们仍属于一个测试方法,因此本例报告5条测试,不把两个子场景另凑数量。timeout=10限制单个子进程等待时间;它不是脚本性能基准。
第四步:运行,并读懂真实结果
在练习目录里执行:
text
python -I -B -X utf8 test_file_list.py
-B避免生成字节码缓存,-X utf8统一本例子进程的文本编码,-I使用解释器隔离模式。测试文件直接运行,在末尾通过unittest.main启动,不依赖当前目录的模块导入搜索。
本次实际运行结果,省略机器相关的耗时:
text
test_bad_suffix_keeps_old_output ... ok
test_default_rows ... ok
test_empty_input ... ok
test_missing_input ... ok
test_suffix_case_sensitive ... ok
Ran 5 tests
OK
这表示本例五种约定在该环境通过,不代表已经检查完所有文件系统情况。运行结束后_test_runs/可以保留空目录,临时子目录已被清理。
第五步:确认测试真的能抓住旧故障
全绿之后,还要回答:如果默认后缀再次写错,测试能发现吗?
在同一练习目录另复制一份脚本,命名broken_file_list.py,只把默认值.txt改为txt。原正确脚本与测试文件不改。这个故障是主动构造的教学样例。
在PowerShell里切换被测副本后运行同一套测试:
powershell
$env:FILE_LIST_TARGET = 'broken_file_list.py'
python -I -B -X utf8 test_file_list.py
Remove-Item Env:FILE_LIST_TARGET
python -I -B -X utf8 test_file_list.py
本次实际使用相同测试文件,故障版5条中3条失败,正确版5条全部通过。故障版影响了默认命令,因此正常txt、空目录和缺输入检查都报失败;显式后缀的两个测试仍通过。
这三个失败不是三个独立的业务缺陷,它们可能都来自同一默认值错误。测试的作用是指出哪些约定被破坏,还需要读错误信息定位根因。
运行记录同时核对了第08篇原脚本和第09篇正确副本的哈希,两者保持一致。我们新增测试,没有为追求全绿修改业务脚本。
验收与常见错误
你应该能做到:在正确脚本上得到5条通过;在教学故障版上看到失败;切回原脚本后恢复通过。还能解释为什么"空目录"和"缺目录"期望不同,以及为什么失败时要检查旧CSV内容。
如果提示找不到file_list.py,先核对两个文件是否同级以及是否保存成了.py.txt。如果全绿但你改坏默认后缀仍全绿,先确认被测文件路径,检查是否只断言"没抛异常"或总是显式传了后缀。如果每次结果不同,检查是否共用了上一次的输出文件,而不是立即放宽断言。
本例没有测试链接/目录联接、并发替换、权限拒绝、大目录性能、磁盘写满或所有操作系统。它也不是隔离不可信程序的安全沙箱。后续增加这些需求时,应补相应的输入与验收;不要拿5条通过宣称"脚本绝对安全"。
下一篇进入第10篇:先规划再动手,把一项需求拆成几个可交付步骤。现在你已有一组可以反复运行的检查,它们会成为后续功能改动的验收基础。
资料与前后篇
资料核对日期:2026年11月6日。产品资料说明与本地测试结果分别记录;本篇没有比较模型或测试不同入口的界面。
前篇:第08篇"报错时怎样提问:先复现,再做最小修复"。后篇:第10篇"先规划再动手:把一项需求拆成几个可交付步骤"。