<!-- title: 打造单元测试生成Skill -->

## 概述

本教程带你从零打造一个名为 `gen-tests` 的自定义 Skill，它能在给定源文件后自动侦测测试框架、套用对应约定、生成可运行的单元测试并可选地跑一遍验证。教程覆盖 Skill 的目录结构、`SKILL.md` frontmatter 写法、渐进披露（progressive disclosure）分层、可执行脚本封装与触发调优。适合已跑通 Claude Code 首个项目、想把自己的重复工作沉淀为可复用能力的开发者，预计动手时长 90 分钟。

## 前置条件

- **环境**：Claude Code 已安装并能正常启动（`claude --version` 可输出版本号）。
- **基础**：会用命令行创建目录与文件，看得懂 YAML 与一段 shell 脚本。
- **示例工程**：准备一个带 `package.json`（Jest/Vitest）或 `go.mod` 的小工程，用于最后验证。没有也无妨，文中会给出最小可运行样例。
- **权限模式**：建议在 `acceptEdits` 或默认模式下操作，确认 Bash/Write 调用时按需放行。

## 一、先理解 Skill 是什么

Skill 是一个**目录**，而不是一个文件。Claude Code 启动时会扫描 `.claude/skills/`（项目级）与 `~/.claude/skills/`（个人级），把每个子目录视为一个技能包。其核心入口是 `SKILL.md`：

```
.claude/skills/gen-tests/
├── SKILL.md              # 入口：frontmatter + 指令正文
├── scripts/
│   └── detect-framework.sh   # 确定性逻辑交给脚本
└── references/
    ├── jest-conventions.md   # 框架约定，按需加载
    └── pytest-conventions.md
```

这里的关键设计叫**渐进披露**——三层内容、三种加载时机，对应三种 token 成本：

| 层 | 内容 | 何时进入上下文 | 成本 |
|---|---|---|---|
| L1 | `description`（frontmatter 里的一两句话） | 会话全程常驻 | 极低 |
| L2 | `SKILL.md` 正文 | Skill 被触发后加载 | 中 |
| L3 | `references/`、`scripts/` | 正文明确引用时才加载 | 高，但仅在需要时 |

一句话记住：**常驻的越短越好，重的细节丢到 L3**。本教程的全部技巧都围绕这条原则展开。

## 二、创建骨架目录

在项目根目录执行：

```bash
mkdir -p .claude/skills/gen-tests/scripts
mkdir -p .claude/skills/gen-tests/references
```

创建后用 `ls -R .claude/skills/gen-tests` 应能看到两个空子目录。Claude Code 会在下次会话启动（或执行 `/skills`）时自动发现它。项目级 Skill 会随仓库提交，团队成员 `git pull` 后即可共享；想做成全局能力，则放到 `~/.claude/skills/` 下。

## 三、写 SKILL.md 的 frontmatter（最关键的一步）

frontmatter 决定 Skill **是否被触发**，正文决定触发后**怎么做**。先看一个会失败的写法，再看正确写法，对比记忆最深。

### 反面教材：description 写成了"产品介绍"

```yaml
---
name: gen-tests
description: A powerful unit test generator for developers.
---
```

这句话对人类像介绍，对 Claude 几乎无用——它没有说明**什么时候该用**。结果是：你让 Claude "给 utils.js 写测试"，它大概率不会触发这个 Skill，而是凭默认能力硬写，质量取决于运气。

### 正确写法：写清"什么场景下、做什么"

```yaml
---
name: gen-tests
description: Generate or extend unit tests for a specific source file. Use when the user asks to write, add, create, or improve unit tests, cover a function/class/module with tests, raise test coverage for a file, or produce test scaffolding. Detects the project's test framework automatically.
---
```

写 description 的三条经验：

1. **动词前置**：用 `Generate`、`Add` 这类动作开头，而不是 `A tool that...`。
2. **列举触发词**：把用户真实会说的短语（write tests、add tests、improve coverage）埋进去，提高语义命中。
3. **点明边界**：强调 "for a specific source file"，避免它对所有跟测试沾边的请求都跳出来（比如你只想跑测试时它不该抢戏）。

### frontmatter 还能用哪些字段

`name` 与 `description` 是主力，其余按需：

```yaml
---
name: gen-tests
description: ...（同上）
allowed-tools: ["Read", "Write", "Edit", "Bash"]   # 收紧可用工具，防越权
disable-model-invocation: false                    # 设 true 则只能手动 /skill 调用
---
```

`allowed-tools` 是把 Skill 关进沙箱的好习惯——生成测试只需要读源码、写测试、跑命令，给这几样就够，没必要把全部工具敞开。

## 四、写正文：把工作流写成"给同事的操作手册"

正文是 Claude 触发后照着执行的剧本。写法的核心是**像写给一位聪明但不了解你项目的新同事**：交代清楚步骤、判断点、输出位置。下面是完整正文，可直接粘贴进 `SKILL.md`：

```markdown
# gen-tests — 单元测试生成

## 工作流

1. **确定目标文件**：从用户消息中取出待测源文件路径（绝对或相对仓库根）。若用户只给了函数名，先用 Read/Grep 定位文件。

2. **侦测框架**：在仓库根运行 `bash .claude/skills/gen-tests/scripts/detect-framework.sh .`，读取输出。可能值：`jest`、`vitest`、`pytest`、`go`、`unknown`。

3. **加载约定**：根据上一步结果，Read 对应的 `references/<framework>-conventions.md`。若为 `unknown`，向用户询问目标框架后再继续，不要瞎猜。

4. **生成测试**：把源文件与框架约定作为输入，生成测试文件：
   - 命名遵循约定（Jest: `foo.test.js`；pytest: `test_foo.py`；Go: 同目录 `foo_test.go`）。
   - 路径放在与源文件相邻或约定目录（Jest 常见 `__tests__/`，Go 必须同包同目录）。
   - 至少覆盖：正常路径、边界值、一个失败/异常分支。

5. **自检**：用 Bash 运行该测试文件（`npx jest path` / `pytest path` / `go test path`）。若失败，读取报错并修复，最多重试 2 次；仍失败则把失败原因报告给用户，不要静默吞掉。

## 输出

- 创建/更新后的测试文件路径。
- 测试运行结果（通过数/失败数）。
- 若跳过了某步（如用户拒绝自动运行），显式说明跳过了什么。
```

注意正文里我**没有**把各框架的命名规则、断言库写法全塞进来——那些进 `references/`。正文只写流程与判断点，保持精炼。

## 五、把确定性逻辑封装成脚本

"判断项目用什么测试框架"这种事，让 LLM 去读 `package.json` 再推理，既慢又容易出错。确定性逻辑交给脚本，又快又稳。创建 `scripts/detect-framework.sh`：

```bash
#!/usr/bin/env bash
# 侦测当前项目使用的测试框架。参数：仓库根目录，默认 .
set -euo pipefail
root="${1:-.}"

if [ -f "$root/package.json" ]; then
  if grep -q '"vitest"' "$root/package.json"; then
    echo "vitest"
  elif grep -q '"jest"' "$root/package.json"; then
    echo "jest"
  elif grep -q '"mocha"' "$root/package.json"; then
    echo "mocha"
  fi
elif [ -f "$root/go.mod" ]; then
  echo "go"
elif [ -f "$root/pytest.ini" ] || [ -f "$root/pyproject.toml" ]; then
  echo "pytest"
else
  echo "unknown"
fi
```

别忘了给执行权限，否则 Bash 调用会报 permission denied：

```bash
chmod +x .claude/skills/gen-tests/scripts/detect-framework.sh
```

本地先自测一遍，确认输出符合预期：

```bash
$ bash .claude/skills/gen-tests/scripts/detect-framework.sh .
jest
```

**预期结果**：在一个装了 Jest 的 Node 项目根目录运行，输出 `jest`；在 Go 项目输出 `go`；都没有则输出 `unknown`。这一步是后续流程的"地基"，务必先跑通。

**常见问题**：

- **报 `set: -u: unbound variable`**：在某些 sh 上 `set -u` 对未定义变量敏感，把 shebang 改成 `#!/usr/bin/env bash` 强制走 bash。
- **输出带了多余空行**：检查脚本末尾有没有残留 `echo`，或 `grep` 是否匹配到了无关键。
- **macOS 上 `grep -q` 行为异常**：BSD grep 对部分正则兼容性差，本例用纯字符串匹配可规避。

## 六、把框架约定拆进 references/

这部分是典型的"重内容、低频用"——Jest 约定只在 Jest 项目用到，塞进正文纯属浪费。创建 `references/jest-conventions.md`：

```markdown
# Jest 测试约定

- 命名：与源文件同目录的 `__tests__/` 下，或 `<name>.test.js`/`<name>.spec.js`。
- 用 `describe` 聚合同一模块的多组用例，每组用 `it`/`test`。
- 断言用 `expect(x).toBe(y)`、`toEqual`（深比较）、`toThrow`。
- 异步：`async/await`，必要时 `resolves`/`rejects` 修饰符。
- mock：`jest.fn()`、`jest.spyOn(obj, 'method')`，还原用 `afterEach(() => jest.restoreAllMocks())`。
- 一个文件至少 3 个用例：正常、边界、异常。
```

同理建 `references/pytest-conventions.md` 与 `references/go-conventions.md`。要点：每份约定控制在 20 行内，只放 Claude 容易写错的点（命名规则、断言 API、mock 还原时机），别抄官方文档。

## 七、触发验证：别等上线才发现不灵

Skill 写完，**最常翻车的是触发**——description 没写好，Claude 根本不调用它。用两种方式验证：

### 方式 A：看是否被发现

启动会话后输入 `/skills`，列表里应出现 `gen-tests` 及其 description。没出现说明目录路径或文件名不对（`SKILL.md` 必须全大写）。

### 方式 B：看是否被触发

对一个 JS 文件说："给 `src/format.js` 补几个单元测试。" 观察 Claude 的行为：

- **理想**：它先 Bash 跑 `detect-framework.sh`，再 Read 约定文件，最后写测试并运行——说明 Skill 被正确激活并按剧本走。
- **没触发**：它直接开始写 `test.js` 且没跑脚本。这时回到第三步调 description，把"format.js 补测试"这类说法更明确地写进触发词。
- **误触发**：你只是说"跑一下测试"，它却开始生成新测试。在 description 里强化边界，比如补一句 "Do not trigger when the user only wants to run existing tests."

### 方式 C：强制调用做对比

临时把 frontmatter 里 `disable-model-invocation: true`，改用 `/skill gen-tests` 手动触发，看手动与自动两种路径的输出差异，定位问题是在"触发判断"还是"执行剧本"。

## 八、实战案例：给一个工具函数生成测试

### 准备被测代码

在项目里建 `src/sum.js`：

```javascript
// src/sum.js
export function sum(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new TypeError('sum() expects two numbers');
  }
  return a + b;
}
```

`package.json` 里确保有 Jest：

```json
{
  "name": "gen-tests-demo",
  "scripts": { "test": "jest" },
  "devDependencies": { "jest": "^29.0.0" }
}
```

```bash
npm install
```

### 触发 Skill

在 Claude Code 里输入：

> 给 src/sum.js 写单元测试。

Claude（若 Skill 正常）会执行如下链路：

1. Bash: `bash .claude/skills/gen-tests/scripts/detect-framework.sh .` → `jest`
2. Read: `.claude/skills/gen-tests/references/jest-conventions.md`
3. Read: `src/sum.js`
4. Write: `src/__tests__/sum.test.js`
5. Bash: `npx jest src/__tests__/sum.test.js`

### 预期生成的测试

```javascript
// src/__tests__/sum.test.js
const { sum } = require('../sum');

describe('sum', () => {
  it('adds two positive numbers', () => {
    expect(sum(2, 3)).toBe(5);
  });

  it('handles boundary zero', () => {
    expect(sum(0, 0)).toBe(0);
    expect(sum(-1, 1)).toBe(0);
  });

  it('throws on non-number input', () => {
    expect(() => sum('1', 2)).toThrow(TypeError);
  });
});
```

### 预期运行输出

```
PASS  src/__tests__/sum.test.js
  sum
    ✓ adds two positive numbers (2 ms)
    ✓ handles boundary zero
    ✓ throws on non-number input (1 ms)

Tests:       3 passed, 3 total
```

**常见问题**：

- **`Cannot find module '../sum'`**：源文件用了 ESM `export`，但 Jest 默认 CommonJS。要么给测试用 `require` + 配 `babel`/`ts-jest`，要么在 `package.json` 加 `"type":"module"` 并用 `import`。约定文件里应写清这条坑。
- **Skill 跑了一遍但没运行测试**：检查 `allowed-tools` 是否漏了 `Bash`，或脚本路径写错。
- **生成到了错误目录**（如 Go 测试被放进 `__tests__/`）：约定文件里强调"Go 测试必须与源文件同目录同包"，并在正文流程中点名。

## 九、整体工作流图

```mermaid
flowchart TD
    A[用户：给某文件写测试] --> B{description 命中?}
    B -- 否 --> Z[Claude 用默认能力处理]
    B -- 是 --> C[加载 SKILL.md 正文]
    C --> D[Bash: detect-framework.sh]
    D --> E{框架}
    E -- jest/vitest/pytest/go --> F[Read references/约定]
    E -- unknown --> Q[询问用户目标框架]
    Q --> F
    F --> G[Read 源文件]
    G --> H[Write 测试文件]
    H --> I[Bash: 运行测试]
    I --> J{通过?}
    J -- 是 --> R[报告结果]
    J -- 否 --> K[读报错并修复, 最多重试2次]
    K --> I
```

## 十、进阶优化与避坑

1. **拆分子 Skill**：当约定文件膨胀，可把 `gen-tests` 拆成 `gen-tests-jest`、`gen-tests-pytest`，父 Skill 根据框架结果"转发"。这契合渐进披露，避免一个包过载。

2. **缓存侦测结果**：若工程很大、脚本较慢，可在仓库根写 `.claude/.framework-cache`，正文先读缓存、缺失再跑脚本。注意把缓存文件加进 `.gitignore`。

3. **描述别过度承诺**：不要在 description 写 "always produces perfect tests"。Skill 是辅助，措辞要留余地，否则失败时用户预期落差大。

4. **用 Evals 量化**：准备 10 个不同语言的源文件作为盲测集，记录生成测试的首次通过率，迭代 description 与约定文件，盯住这个指标上升。这是把 Skill 从"能用"推向"好用"的唯一可靠方法。

5. **安全收口**：`allowed-tools` 只给必要的四个（Read/Write/Edit/Bash），别给 `WebFetch` 之类。生成测试不需要联网，关掉它既省 token 也降风险。

## 延伸阅读

- **Claude Code 官方 Skills 文档**：`https://code.claude.com/docs/en/skills` —— 最权威的字段与行为定义，写新 Skill 前必读。
- **Anthropic 逐步披露指南**：官方对 L1/L2/L3 分层与正文长度的官方建议，直接指导"什么内容放哪一层"。
- **Agent Skills 规范（agentskills.io）**：跨运行时的 Skill 格式规范，了解 `SKILL.md` 在更广生态中的定位。
- **skill-creator 工具**：Claude Code 内置的脚手架 Skill，可用 `/skill skill-creator` 半自动生成骨架，适合快速起步。

## 训练营适用性评估

- **适用类型**：企业内部 Claude Code 实战训练营（中后段）、测试工程方向提升营、AI 辅助研发工作坊。不适合纯零基础营——学员需先掌握文件操作与一种测试框架。
- **教学时长**：讲授 30 分钟 + 动手 60 分钟，建议两课时连排。
- **最佳节奏**：先让学员"裸写"一个只有 frontmatter 的版本体验触发失败，再引入脚本与 references 对比效果，最后用 Evals 指标收尾，形成"痛点—方案—度量"闭环。
- **作业建议**：要求学员为本营的示例工程（Python 或 Go）各自实现一个变体，并提交首次通过率作为评分依据。

## 参考来源

1. [Extend Claude with skills — Claude Code 官方文档](https://code.claude.com/docs/en/skills) — Skills 目录结构、frontmatter 字段、触发与手动调用的权威定义。
2. [Agent Skills in the SDK — Claude Code Docs](https://code.claude.com/docs/en/agent-sdk/skills) — SKILL.md 作为"目录 + YAML frontmatter + Markdown"的格式说明，含渐进披露说明。
3. [Claude Code Skill Frontmatter: Every YAML Option Explained](https://allahabadi.dev/blogs/ai/claude-code-skills-frontmatter-complete-guide/) — 对 name、description、allowed-tools、disable-model-invocation 等 frontmatter 字段的逐项示例解读。
4. [Specification — Agent Skills (agentskills.io)](https://agentskills.io/specification) — 跨运行时的 Skill 格式规范，明确 frontmatter 与正文 body 的边界。
5. [Build a Claude Skill: YAML Frontmatter, SKILL.md Instructions, and Scripts](https://sjramblings.io/building-skills-for-claude-part-2/) — 含 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)
