Codex 从零到能交付(09):让 Codex 写有用的测试,而不是只测成功路径

6 阅读8分钟

上一篇,我们把默认后缀少一个点的故障复现出来,只修了一行。接下来再改脚本时,总不能靠记忆把这些命令重新敲一遍。

这一篇把约定写成能反复执行的测试:有数据时检查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

可直接使用下面这段任务说明,路径按你的练习目录调整:

请先读取本练习目录的file_list.py,只新增test_file_list.py,不改业务脚本。
使用Python标准库unittest,以真实CLI子进程调用脚本。
按上面的五种场景验证:退出码、错误提示、CSV单元格和失败后旧CSV保持不变。
每条测试使用练习目录内独立临时子目录,明确dir,不使用真实资料或系统默认临时目录。
不通过导入业务函数并照抄它的筛选逻辑来计算期望结果。
给出实际运行命令、通过/失败记录;如有失败先报告,不修改断言迎合当前输出。

OpenAI官方的测试提示示例强调给出测试范围,并覆盖正常路径与边界情形。这里进一步明确命令行行为和文件副作用,属于本案例的设计选择,不是某个按钮的固定功能,也不保证同一提示词每次生成相同代码。

第三步:保存完整测试文件

把以下内容保存为与file_list.py同级的test_file_list.py。它测试的是用户运行脚本时看到的行为;这是一组CLI回归测试,不是只调用一个函数的单元测试。

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限制单个子进程等待时间;它不是脚本性能基准。

第四步:运行,并读懂真实结果

在练习目录里执行:

python -I -B -X utf8 test_file_list.py

-B避免生成字节码缓存,-X utf8统一本例子进程的文本编码,-I使用解释器隔离模式。测试文件直接运行,在末尾通过unittest.main启动,不依赖当前目录的模块导入搜索。

本次实际运行结果,省略机器相关的耗时:

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里切换被测副本后运行同一套测试:

$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篇“先规划再动手:把一项需求拆成几个可交付步骤”。