<!-- title: 构建测试骨架生成Skill -->
# 从零构建一个"测试骨架生成 Skill"：让 Claude 自动产出单测样板

## 概述

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

## 前置条件

- 已安装 Claude Code（v1.0+），能正常对话与读写文件；
- 本机有 Python 3.10+（脚本用标准库 `ast`，无需第三方依赖）；
- 准备一个包含若干 `.py` 文件的目录用于实战（没有也无妨，文末会给出可运行的样例）；
- 了解 Markdown 与 YAML 基础语法。

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

## 第一步：搭建目录骨架

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

```bash
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`：

```markdown
---
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` 提取函数与方法签名（零依赖）：

```python
#!/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` 文件运行，输出形如：

```json
{
  "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` 示例：

```markdown
# 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>
```

- 方法：构造所属类实例后调用，例如 `cart = Cart(); cart.apply_discount("X")`。
- 未知默认值：用 `# TODO: 填入合理入参` 标注，绝不编造业务数值。
- 模块路径：以仓库根为基准，用点号分隔（如 `src.pricing`）。
```

`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`：

```python
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 的成本优势。

## 工作流程全景图

```mermaid
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 ✓]
```

## 关键设计原则回顾

- **description 是触发开关**：同时写清"做什么"与"何时做"，含用户真实词汇；这是新手最常踩的坑。
- **确定性交给脚本**：提取、计算、格式化用代码，模型只负责语义判断与渲染；脚本源码不耗上下文。
- **渐进式披露省 token**：核心说明留在 `SKILL.md`，大段模板/多语言分支放 `references/` 按需读。
- **改完必重启**：发现扫描只在初始化，这是"改了没生效"的头号原因。
- **显式优于隐式**：工作流每一步写成可执行指令，必要时引导模型读引用文件、跑验证命令。

## 延伸阅读

- Claude Code 官方 Skills 文档（创建、管理、分享）：`https://code.claude.com/docs/en/skills`
- Claude Help Center：How to create custom skills：`https://support.claude.com/en/articles/12512198`
- Claude Code Skills 技术深度解读（渐进式披露三阶段）：`https://github.com/gregpriday/claude-code-docs/blob/main/claude-code-skills.md`
- SKILL.md frontmatter 全字段参考：`https://claudskills.com/learn/claude-code-skill-frontmatter-fields/`
- 社区 Skill 开发模板与最佳实践：`https://mcpmarket.com/tools/skills/claude-skill-development-template-7`

## 训练营适用性评估

- **适合类型**：面向"已经在用 Claude Code、想从使用者进阶为定制者"的中级训练营；也适合企业内部"AI 研发效能"专题，作为 Skill 化的第一个团队交付物。
- **预计教学时长**：讲授 30 分钟 + 动手 60 分钟 + 触发调试 30 分钟，合计约 2 小时。
- **可裁剪**：若学员只关心触发与目录约定，可跳过第三步脚本细节，直接套用现成脚本；若强调工程化，可加做"`allowed-tools` 权限收紧 + 多语言模板 + CI 中调用"进阶环节。
- **验收标准**：学员能独立产出一个可被自然语言触发、生成可 collect 通过的测试样板的 Skill，并能口述"渐进式披露为何省钱"。

## 参考来源

1. [Extend Claude with skills — Claude Code Docs](https://code.claude.com/docs/en/skills) — 官方 Skill 创建、管理与分享指南，目录约定与 frontmatter 权威来源。
2. [How to create custom skills — Claude Help Center](https://support.claude.com/en/articles/12512198-how-to-create-custom-skills) — 官方帮助中心对 skill.md 最小结构与触发机制的说明。
3. [Claude Code Skills: Complete Technical Deep-Dive](https://github.com/gregpriday/claude-code-docs/blob/main/claude-code-skills.md) — 渐进式披露三阶段、token 占用、发现与匹配机制的社区技术深度解读。
4. [Claude Code SKILL.md Frontmatter Fields: The Complete Reference](https://claudskills.com/learn/claude-code-skill-frontmatter-fields/) — name/description/allowed-tools/disable-model-invocation 等字段语义与验证规则全表。
5. [Claude Code Skill Frontmatter: Every YAML Option Explained](https://allahabadi.dev/blogs/ai/claude-code-skills-frontmatter-complete-guide/) — 实战视角的 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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
