AI 写代码很快,但「能跑」和「写对了」是两回事。一个成本很低的办法是:要求模型在写每个函数时顺手在 docstring 里留几行可执行的示例(>>> 交互)——也就是 doctest,然后用 Sphinx 的 doctest 构建器把这些示例当成测试真跑一遍。跑不过就不许提交,跑不过 CI 就变红。这样"自测"这件事就被左移到了提交之前,而且测试和文档是同一份内容,不会过期。
适用场景
这套方案适合已经在用 AI 辅助写 Python 代码、但缺少单元测试覆盖的团队或个人:库函数、脚本工具、数据清洗函数都属于典型场景。核心解决的问题是"AI 生成的代码看起来合理但边界没验证过",同时又不想额外维护一套与文档脱节的测试。只要你的项目用 Python、愿意维护一份 docs/ 目录,成本就不高。
环境与前置条件
- 操作系统:Linux / macOS / Windows 均可,命令以 bash 为准,Windows 下把
python3换成python即可。 - Python:3.9 及以上版本,具体支持范围以 Sphinx 官方文档当前版本为准。
- 内存与磁盘:纯文档构建很轻,512MB 内存、几百 MB 磁盘足够;如果同时跑大型项目导入,建议 2GB 以上内存。
- 依赖:
sphinx(含sphinx.ext.autodoc、sphinx.ext.doctest、sphinx.ext.napoleon三个内置扩展,安装 sphinx 即自带,无需单独装)、pre-commit(想接提交钩子时)。CI 侧用任意支持 Python 的流水线,本文以 GitHub Actions 为例,其他平台照搬命令即可。 - 前提知识:知道什么是虚拟环境、能在终端里切目录。
整体目录结构约定如下,后文所有命令都基于它:
```
demo/
├── src/demo/
│ ├── __init__.py
│ └── calc.py
├── docs/
│ ├── conf.py
│ ├── index.rst
│ └── api.rst
├── .pre-commit-config.yaml
└── requirements-docs.txt
```
分步骤部署
第 1 步:建虚拟环境与目录骨架
先隔离依赖,避免污染系统 Python。
```bash
cd demo
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -V
mkdir -p src/demo docs
```
成功标志:python -V 打印出版本号,且提示符前出现 (.venv)。之后所有命令都在这个已激活的环境里执行。
第 2 步:安装 Sphinx
把文档相关依赖单独写进一个文件,方便 CI 复用:
```bash
cat > requirements-docs.txt <<'EOF'
sphinx
pre-commit
EOF
pip install -r requirements-docs.txt
```
成功标志:pip list | grep -i sphinx 能看到 sphinx 及其版本。具体版本号以官方文档当前版本为准,不要钉死到某个数字上。
第 3 步:初始化 Sphinx 骨架
```bash
sphinx-quickstart -q -p "demo" -a "your-name" -v 0.1 --sep --ext-autodoc --ext-doctest docs
```
这一步生成 docs/conf.py、docs/index.rst 和 docs/Makefile。-q 表示不进入交互问答,--sep 表示源码与文档分目录。成功标志:docs/conf.py 文件存在,且里面已经有 extensions = [...] 这一行。
第 4 步:改写 conf.py,把 doctest 打开
这是整套方案的核心配置。打开 docs/conf.py,在文件靠前的位置补上模块搜索路径和扩展列表(保留 quickstart 生成的其他配置不动):
```python
import doctest
import os
import sys
把 src 加进搜索路径,autodoc 才找得到被测试的包
sys.path.insert(0, os.path.abspath("../src"))
extensions = [
"sphinx.ext.autodoc", # 从代码 docstring 生成文档
"sphinx.ext.doctest", # 把文档里的 >>> 示例当测试跑
"sphinx.ext.napoleon", # 支持 Google / NumPy 风格 docstring
]
输出比对放宽一点,减少无意义的假失败
doctest_default_flags = (
doctest.DONT_ACCEPT_TRUE_FOR_1
| doctest.ELLIPSIS # 允许 ... 通配
| doctest.NORMALIZE_WHITESPACE # 忽略空白差异
| doctest.IGNORE_EXCEPTION_DETAIL # 只比异常类型,不比消息正文
)
每个 doctest 执行前都会先跑这段代码,用于准备公共依赖
doctest_global_setup = """
import json
"""
```
成功标志:保存后执行 python -c "import ast,sys; ast.parse(open('docs/conf.py', encoding='utf-8').read())",没有任何输出即语法正确。
第 5 步:把模块接进文档
只有被 autodoc 拉进文档的 docstring,其中的 >>> 才会被 doctest 收集执行。新建 docs/api.rst:
```rst
API 参考
========
.. automodule:: demo.calc
:members:
:undoc-members:
```
再把它挂到 docs/index.rst 的 toctree 里,否则构建会报"文档未包含在 toctree 中"的警告:
```rst
.. toctree::
:maxdepth: 2
api
```
成功标志:api.rst 与 index.rst 都在 docs/ 下,且 toctree 中的 api 与文件名完全一致。
第 6 步:让 AI 按约定写可执行示例
新建 src/demo/calc.py,并把这套约定写进你给 AI 的提示词里:
```python
def parse_amount(text: str) -> int:
"""把「12.30」这类金额字符串解析成以分为单位的整数。
Args:
text: 金额字符串,允许带小数。
Example:
>>> parse_amount("12.30")
1230
>>> parse_amount("12")
1200
>>> parse_amount("abc")
Traceback (most recent call last):
...
ValueError: 无法解析金额
"""
text = text.strip()
if not text:
raise ValueError("无法解析金额")
try:
return round(float(text) * 100)
except ValueError as exc:
raise ValueError(f"无法解析金额: {text!r}") from exc
```
给 AI 的提示词里至少写清三条硬约束:每个公开函数必须有 Example: 段;示例里不能访问网络、不能读本地文件、不能依赖当前时间;输出必须是确定的(不要打印排序不稳定的结果)。这样模型产出的示例才具备"当作测试跑"的资格。Sphinx 在跑 docstring 里的示例时会带上该模块的命名空间,所以同模块的函数可以直接调用;跨模块的依赖放到 doctest_global_setup 里统一 import。
第 7 步:本地跑一次 doctest
```bash
sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
```
这一步在做的事情是:解析文档 → 抽出所有可执行示例 → 逐条执行并比对输出。正常输出类似:
```
Document: api
-------------
1 items passed all tests:
3 tests in demo.calc.parse_amount
3 tests in 1 items.
3 passed and 0 failed.
Test passed.
```
看到 Test passed. 且退出码为 0(echo $? 输出 0)即成功。想省事可以直接用 quickstart 生成的 Makefile:make -C docs doctest。
第 8 步:接成 pre-commit 钩子
把上面那条命令固定成提交前必跑的一关,新建 .pre-commit-config.yaml:
```yaml
repos:
- repo: local
hooks:
- id: sphinx-doctest
name: sphinx doctest
entry: python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest
language: system
files: '\.(py|rst)$'
pass_filenames: false
```
pass_filenames: false 是必须的,因为 Sphinx 不接受"只构建某个文件"的参数,它总是构建整个文档树。用 python -m sphinx 而不是 sphinx-build,能避开不同系统下可执行文件不在 PATH 里的问题。安装钩子:
```bash
pre-commit install
```
成功标志:终端输出 pre-commit installed at .git/hooks/pre-commit。之后每次 git commit 只要改到 .py 或 .rst 文件,就会先跑一遍 doctest。
第 9 步:接入 CI,挡住绕过钩子的人
本地钩子可以用 --no-verify 跳过,所以 CI 上要再挡一遍,配置放在 .github/workflows/doctest.yml:
```yaml
name: doctest
on:
pull_request:
push:
branches: [main]
jobs:
doctest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12' # 按项目实际支持的版本填写
- run: pip install -r requirements-docs.txt
- run: python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest
```
成功标志:PR 页面出现该检查项且为绿色;把分支保护规则里这项设为必需,AI 生成的改动就无法在示例失败的情况下被合并。
验证部署是否成功
分两步验证,第二步是关键——要确认它真的会拦住问题,而不只是"跑过了一次"。
1. 正常路径:
```bash
python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest; echo "exit=$?"
```
预期:输出 Test passed.,最后一行 exit=0。
2. 故障路径(故意制造一次失败):
```bash
临时把示例期望值改成错的
sed -i 's/^ 1230$/ 9999/' src/demo/calc.py
python -m sphinx -b doctest -d docs/_build/doctrees docs docs/_build/doctest; echo "exit=$?"
```
预期:输出里出现 Failed example:、Expected:、Got: 三行,退出码提示失败,docs/_build/doctest/output.txt 里有一份完整的失败明细。此时执行 git commit 应当被 pre-commit 拦下。验证完记得把 9999 改回 1230,再跑一次恢复绿色。
常见报错与解决
报错:WARNING: autodoc: failed to import module 'calc' from module 'demo'; No module named 'demo'
原因:conf.py 里的 sys.path 没指到源码目录,或者路径写成了相对当前工作目录而非相对 docs/。
解决:确认 conf.py 中写的是 os.path.abspath("../src"),并检查 src/demo/__init__.py 确实存在(空文件也可以)。
```bash
ls src/demo/__init__.py && grep -n "sys.path.insert" docs/conf.py
```
报错:Expected: 1230 / Got: 1230.0,或输出里出现 <object at 0x7f...>
原因:浮点与整数的 repr 不同;对象默认打印包含内存地址,每次运行都不一样,天然不稳定。
解决:示例里显式转换类型(如 int(parse_amount("12.30"))),对含地址的输出用 ... 省略,并确认 ELLIPSIS 已在 doctest_default_flags 中开启。
```python
>>> int(parse_amount("12.30"))
1230
>>> repr(obj)
'<demo.calc.Point object at ...>'
```
报错:构建成功但显示 0 items passed all tests,明明写了示例却没被执行
原因:模块没有被 automodule 拉进文档;或 docstring 中的 >>> 缩进不在同一个块内、示例前有空行打断了段落。
解决:确认 api.rst 里有 .. automodule:: demo.calc 且带 :members:,并确认 api.rst 已被 index.rst 的 toctree 引用。
```bash
grep -n "automodule" docs/api.rst && grep -n "api" docs/index.rst
```
报错:Executable 'python -m sphinx' not found 或 pre-commit 提示找不到 sphinx
原因:language: system 表示直接用当前环境里的可执行文件,如果提交时没有激活虚拟环境,就会找不到。
解决:要么保证提交前已 source .venv/bin/activate,要么把钩子改成自带环境的方式:
```yaml
- id: sphinx-doctest
name: sphinx doctest
entry: sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
language: python
additional_dependencies: ["sphinx"]
files: '\.(py|rst)$'
pass_filenames: false
```
报错:加上 -W 后构建直接失败,提示 document isn't included in any toctree
原因:-W 会把所有警告升级为错误,尚未挂进 toctree 的文档就会导致失败。
解决:把 api.rst 补进 index.rst 的 toctree;确认没有遗留的草稿 rst 文件。
后续维护
- 备份:
docs/conf.py、docs/*.rst和.pre-commit-config.yaml必须进版本库,它们就是测试配置本身。docs/_build/属于构建产物,加进.gitignore,不要提交。 - 升级:升级 Sphinx 前先跑一遍
make -C docs doctest留一个基线,升级后再跑一遍对比。新版本对 docstring 解析和警告策略可能有调整,具体行为以官方文档当前版本为准。升级后如果出现大量新警告,先处理警告再收紧密度。 - 日志:每次失败的完整明细都在
docs/_build/doctest/output.txt,CI 上把这个文件用 artifact 上传,出问题不用重新复现。日常可以只看退出码。 - 监控:在 CI 里统计一次构建执行了多少条示例(输出里的
N tests in M items),把这个数字记录下来。数字突然下降通常意味着某次 AI 改动把 docstring 示例删了或者模块没被automodule收进去——这本身就是一种有价值的信号。 - 节奏建议:先只对核心模块开强制关卡,稳定一段时间后再扩大
automodule的覆盖范围。示例数量增长后构建会变慢,可以在 pre-commit 里只保留快速检查,全量构建交给 CI。
