首页 › tutorials › 构建测试骨架生成Skill

从零构建一个"测试骨架生成 Skill":让 Claude 自动产出单测样板

概述

本教程面向已经能跑通 Claude Code、但还没亲手写过 Skill 的开发者。你将从零搭建一个测试骨架生成 Skill(test-scaffold):它会扫描项目里的源文件,提取函数与方法签名,并自动生成对应语言的单测样板文件。学完你将掌握 Skill 的目录约定、SKILL.md frontmatter 写法、脚本与引用文件的渐进式披露、触发调试技巧,以及一套可直接放进 .claude/skills/ 运行的完整示例。

前置条件

核心心智模型:Skill 不是插件、不是 API,而是一个带说明书的文件夹。Claude 在会话启动时扫描 ~/.claude/skills/、.claude/skills/ 与插件目录,只把每个 Skill 的 name+description(约 30–50 tokens)载入系统提示;真正用到时才读取正文与脚本。这套"渐进式披露"是后续所有设计的出发点。

第一步:搭建目录骨架

Skill 的最小可用形态只需一个 SKILL.md。但实战中,把"说明书、脚本、参考"分层存放,既利于复用也契合渐进式披露:

mkdir -p .claude/skills/test-scaffold/{scripts,references}
cd .claude/skills/test-scaffold

最终目录长这样:

.claude/skills/test-scaffold/
├── SKILL.md                 # 必需:核心说明书
├── scripts/
│   └── extract_symbols.py   # 用 AST 提取函数/方法签名
└── references/
    ├── jest_template.md     # JS 端测试模板(按需加载)
    └── pytest_template.md   # Python 端测试模板(按需加载)

预期结果:目录创建成功,tree .claude/skills/test-scaffold 能看到上述结构。

常见问题:放在 .claude/skills/(项目级)还是 ~/.claude/skills/(个人级)?答案取决于是否要团队共享——项目级会随 git 提交分发给同事,个人级只对你全局生效。本教程用项目级,便于团队复用。

第二步:写好 frontmatter(决定生死的 1024 字符)

整个 Skill 能否被触发,几乎全看 description 这一个字段。Claude 用语义匹配把用户意图与所有已索引 Skill 的描述比对:模糊的"分析数据文件"几乎不会触发,而"扫描 Python 源文件、提取函数与方法、生成 pytest 测试样板"则命中率极高。

name 的硬约束:全小写字母、数字、连字符,≤64 字符,且不能含 anthropic、claude 等保留词或 XML 标签。description 上限 1024 字符,实践中 20 字符以下基本无效。无效 YAML 会导致静默加载失败——Skill 直接不进索引,连报错都没有。

新建 .claude/skills/test-scaffold/SKILL.md:

---
name: test-scaffold
description: 为 Python/JavaScript 源文件生成单元测试骨架。扫描指定目录,提取函数与方法签名,输出对应语言的测试样板(pytest / Jest)。当用户要求"生成测试""补单测""写测试样板""为 xxx 模块补测试"或处理缺少测试覆盖的代码时触发。支持指定框架与目标目录。
---

# 测试骨架生成 Skill

## 何时使用
当用户希望为新写或已有的函数/类快速产出可运行的单测样板时使用。

## 工作流程
1. 运行 `python scripts/extract_symbols.py <源文件或目录> --lang python` 得到符号清单(JSON)。
2. 根据目标语言读取对应模板:Python 读 `references/pytest_template.md`,JS 读 `references/jest_template.md`。
3. 把每个公共函数/方法渲染成一个测试用例:一个正常路径、一个边界值、一个异常路径。
4. 写入 `tests/test_<模块名>.py`(或 `.test.<name>.js`),避免覆盖已有文件。
5. 运行生成的测试,确认能 collect 通过(即使断言待补)。

## 约定
- 跳过以 `_` 开头的私有符号,除非用户明确要求。
- 不修改被测源码,只新增测试文件。
- 若目标测试文件已存在,在其末尾追加,并打印提示。

预期结果:保存后,SKILL.md 的 frontmatter 合法,重启 Claude Code 后该 Skill 进入索引。

常见问题与解决:

现象 根因 解决
装好了但从不触发 description 只写"做什么"没写"何时用" 补上触发词与"当用户要求…时"句式
Skill 索引里消失 YAML 解析失败或 name 含非法字符 用在线 YAML 校验器检查;确认 --- 恰好出现
改了文件没生效 改动只在启动时扫描 重启 Claude Code
不想被自动触发,只想手动调用 默认语义匹配太活跃 加 disable-model-invocation: true,只能 /test-scaffold 显式调用

第三步:编写符号提取脚本(确定性优先)

Skill 的黄金法则:能用代码确定完成的,绝不靠模型生成。脚本代码本身不进入上下文,只有它的输出消耗 token——所以一个 300 行的提取脚本,最终只往对话里塞一份几十行的 JSON 清单。

新建 scripts/extract_symbols.py,用 Python 标准库 ast 提取函数与方法签名(零依赖):

#!/usr/bin/env python3
# extract_symbols.py — 从 Python 源文件提取公共函数/方法签名
import ast, json, sys, argparse
from pathlib import Path

def is_public(name: str) -> bool:
    return not name.startswith("_")

def extract(path: Path) -> list[dict]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    symbols = []
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if not is_public(node.name):
                continue
            args = [a.arg for a in node.args.args]
            ret = ast.unparse(node.returns) if node.returns else None
            symbols.append({
                "name": node.name,
                "qualname": node.name,
                "args": args,
                "returns": ret,
                "is_method": isinstance(getattr(node, "parent", None), ast.ClassDef),
                "line": node.lineno,
            })
        elif isinstance(node, ast.ClassDef) and is_public(node.name):
            for item in node.body:
                if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and is_public(item.name):
                    symbols.append({
                        "name": item.name,
                        "qualname": f"{node.name}.{item.name}",
                        "args": [a.arg for a in item.args.args],
                        "returns": ast.unparse(item.returns) if item.returns else None,
                        "is_method": True,
                        "class": node.name,
                        "line": item.lineno,
                    })
    return symbols

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("target", help="源文件或目录")
    ap.add_argument("--lang", default="python")
    args = ap.parse_args()

    root = Path(args.target)
    files = [root] if root.is_file() else list(root.rglob("*.py"))
    files = [f for f in files if "test_" not in f.name]  # 跳过已有测试
    out = {}
    for f in files:
        try:
            syms = extract(f)
            if syms:
                out[str(f)] = syms
        except SyntaxError as e:
            print(f"[skip] {f}: {e}", file=sys.stderr)
    print(json.dumps(out, indent=2, ensure_ascii=False))

if __name__ == "__main__":
    main()

预期结果:对一个含函数与类的 .py 文件运行,输出形如:

{
  "src/pricing.py": [
    {"name": "compute_total", "qualname": "compute_total", "args": ["items", "tax_rate"], "returns": "float", "is_method": false, "line": 12},
    {"name": "apply_discount", "qualname": "Cart.apply_discount", "args": ["self", "code"], "returns": null, "is_method": true, "class": "Cart", "line": 40}
  ]
}

常见问题: - 拿不到方法:ast.walk 会把类内方法当普通函数遍历,所以脚本额外在 ClassDef 内显式收集,并标注 is_method/class,方便模板渲染时正确加 self。 - 想支持 JS/TS:Python 的 ast 不解析 JS。两条路——要么调用项目已有的 ts-morph/@babel/parser(JS 脚本版),要么对 JS 退化为正则提取 function foo( 与 foo(...) =>。后者不精确,但作为"骨架起点"足够,并在 Skill 说明里标注其局限。

第四步:模板与渐进式披露

把两种语言的模板拆进 references/,而不是全塞进 SKILL.md。原因来自渐进式披露第三阶段:引用文件只在被读取时才进上下文。一个只做 Python 测试的调用,就不必为 Jest 模板付 token。

references/pytest_template.md 示例:

# pytest 测试样板渲染规则

对每个符号 `s`,生成:

```python
import pytest
from <module> import <qualname 的类/函数>

def test_<name>_happy_path():
    # 正常路径:给出合法输入,断言返回值
    result = <call with sensible defaults>
    assert result is not None

def test_<name>_boundary():
    # 边界值:空集合 / 0 / 极值
    pass

def test_<name>_error():
    # 异常路径:非法输入应抛出预期异常
    with pytest.raises(Exception):
        <call with bad input>
`references/jest_template.md` 同理,给出 `describe/it` 骨架。

**预期结果**:`SKILL.md` 保持精简(指南建议 < 500 行),大段模板按需加载,token 占用最小化。

**常见问题**:
- **模板要不要内联进 SKILL.md?** 短模板可以内联;超过几十行或存在多语言分支时,外置成引用文件更划算——这正是渐进式披露的价值。
- **模型不主动读 references/**:在 `SKILL.md` 的工作流里**显式写明**"读取 `references/pytest_template.md`",Claude 才会按指令加载。说明书要像给新人的操作手册,每一步可执行、可验证。

## 第五步:注册、触发与调试

1. **确认放置位置**:项目级 `.claude/skills/test-scaffold/SKILL.md`。
2. **重启 Claude Code**:发现扫描只在初始化进行,改完文件必须重启。
3. **验证索引**:用一句贴近触发词的自然语言试触发——

   > 帮我为 `src/pricing.py` 补一份 pytest 测试样板。

   预期 Claude 读取 Skill → 运行 `extract_symbols.py` → 读模板 → 生成 `tests/test_pricing.py` → 跑 `pytest --collect-only` 确认可收集。

4. **不触发时的排查清单**(生产环境高频坑):
   - `description` 是否同时含"做什么"和"何时用"?✓
   - 是否含用户真实会用到的词("补单测""测试样板")?✓
   - 改动后是否重启?✓
   - 实在不行,**显式点名**:`用 test-scaffold 这个 skill 给 pricing.py 生成测试`——这是最可靠的兜底。

5. **可选:收紧权限**。这个 Skill 只读源码、只写测试目录,可在 frontmatter 加:

   ```yaml
   allowed-tools: ["Read", "Write", "Bash", "Glob", "Grep"]
   ```
   作为纵深防御(但别只依赖它,仍需审阅脚本本身)。

## 实战案例:为一个订单计价模块生成测试

假设 `src/pricing.py`:

```python
def compute_total(items: list[float], tax_rate: float) -> float:
    return sum(items) * (1 + tax_rate)

class Cart:
    def __init__(self):
        self.items: list[float] = []
    def add(self, price: float) -> None:
        self.items.append(price)
    def apply_discount(self, code: str) -> float:
        # 业务规则略
        ...

在项目根目录对 Claude 说:"用 test-scaffold 给 src/pricing.py 补测试。" 期望产出 tests/test_pricing.py:

import pytest
from src.pricing import compute_total, Cart

def test_compute_total_happy_path():
    result = compute_total([10.0, 20.0], 0.1)
    assert result == pytest.approx(33.0)

def test_compute_total_boundary():
    assert compute_total([], 0.1) == 0.0   # 空集合

def test_compute_total_error():
    with pytest.raises(TypeError):
        compute_total(None, 0.1)

class TestCart:
    def setup_method(self):
        self.cart = Cart()

    def test_apply_discount_happy_path(self):
        # TODO: 填入合法折扣码与预期金额
        assert isinstance(self.cart.apply_discount("VIP10"), float)

随后 Claude 执行 pytest --collect-only,看到 7 tests collected 即说明骨架可运行、断言待补。整个过程中,提取脚本的 60 行源码没有占用任何上下文 token——只有那份 JSON 清单与最终测试文件进入对话。这就是脚本化 Skill 的成本优势。

工作流程全景图

flowchart TD
    A[会话启动] --> B[扫描 .claude/skills/*]
    B --> C[仅加载 name+description\n约30-50 tokens/Skill]
    C --> D{用户: 给 pricing.py 补测试}
    D --> E[语义匹配命中 test-scaffold]
    E --> F[加载 SKILL.md 正文]
    F --> G[运行 scripts/extract_symbols.py]
    G --> H[脚本输出 JSON 符号清单\n脚本源码不进上下文]
    H --> I[按需读取 references/pytest_template.md]
    I --> J[渲染并写入 tests/test_pricing.py]
    J --> K[pytest --collect-only 验证]
    K --> L[7 tests collected ✓]

关键设计原则回顾

延伸阅读

训练营适用性评估

参考来源

  1. Extend Claude with skills — Claude Code Docs — 官方 Skill 创建、管理与分享指南,目录约定与 frontmatter 权威来源。
  2. How to create custom skills — Claude Help Center — 官方帮助中心对 skill.md 最小结构与触发机制的说明。
  3. Claude Code Skills: Complete Technical Deep-Dive — 渐进式披露三阶段、token 占用、发现与匹配机制的社区技术深度解读。
  4. Claude Code SKILL.md Frontmatter Fields: The Complete Reference — name/description/allowed-tools/disable-model-invocation 等字段语义与验证规则全表。
  5. Claude Code Skill Frontmatter: Every YAML Option Explained — 实战视角的 frontmatter 各字段用法与常见踩坑。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入