本教程带你从零打造一个名为 gen-tests 的自定义 Skill,它能在给定源文件后自动侦测测试框架、套用对应约定、生成可运行的单元测试并可选地跑一遍验证。教程覆盖 Skill 的目录结构、SKILL.md frontmatter 写法、渐进披露(progressive disclosure)分层、可执行脚本封装与触发调优。适合已跑通 Claude Code 首个项目、想把自己的重复工作沉淀为可复用能力的开发者,预计动手时长 90 分钟。
claude --version 可输出版本号)。package.json(Jest/Vitest)或 go.mod 的小工程,用于最后验证。没有也无妨,文中会给出最小可运行样例。acceptEdits 或默认模式下操作,确认 Bash/Write 调用时按需放行。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。本教程的全部技巧都围绕这条原则展开。
在项目根目录执行:
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/ 下。
frontmatter 决定 Skill 是否被触发,正文决定触发后怎么做。先看一个会失败的写法,再看正确写法,对比记忆最深。
---
name: gen-tests
description: A powerful unit test generator for developers.
---
这句话对人类像介绍,对 Claude 几乎无用——它没有说明什么时候该用。结果是:你让 Claude "给 utils.js 写测试",它大概率不会触发这个 Skill,而是凭默认能力硬写,质量取决于运气。
---
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 的三条经验:
Generate、Add 这类动作开头,而不是 A tool that...。name 与 description 是主力,其余按需:
---
name: gen-tests
description: ...(同上)
allowed-tools: ["Read", "Write", "Edit", "Bash"] # 收紧可用工具,防越权
disable-model-invocation: false # 设 true 则只能手动 /skill 调用
---
allowed-tools 是把 Skill 关进沙箱的好习惯——生成测试只需要读源码、写测试、跑命令,给这几样就够,没必要把全部工具敞开。
正文是 Claude 触发后照着执行的剧本。写法的核心是像写给一位聪明但不了解你项目的新同事:交代清楚步骤、判断点、输出位置。下面是完整正文,可直接粘贴进 SKILL.md:
# 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:
#!/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:
chmod +x .claude/skills/gen-tests/scripts/detect-framework.sh
本地先自测一遍,确认输出符合预期:
$ 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 是否匹配到了无关键。grep -q 行为异常:BSD grep 对部分正则兼容性差,本例用纯字符串匹配可规避。这部分是典型的"重内容、低频用"——Jest 约定只在 Jest 项目用到,塞进正文纯属浪费。创建 references/jest-conventions.md:
# 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 根本不调用它。用两种方式验证:
启动会话后输入 /skills,列表里应出现 gen-tests 及其 description。没出现说明目录路径或文件名不对(SKILL.md 必须全大写)。
对一个 JS 文件说:"给 src/format.js 补几个单元测试。" 观察 Claude 的行为:
detect-framework.sh,再 Read 约定文件,最后写测试并运行——说明 Skill 被正确激活并按剧本走。test.js 且没跑脚本。这时回到第三步调 description,把"format.js 补测试"这类说法更明确地写进触发词。临时把 frontmatter 里 disable-model-invocation: true,改用 /skill gen-tests 手动触发,看手动与自动两种路径的输出差异,定位问题是在"触发判断"还是"执行剧本"。
在项目里建 src/sum.js:
// 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:
{
"name": "gen-tests-demo",
"scripts": { "test": "jest" },
"devDependencies": { "jest": "^29.0.0" }
}
npm install
在 Claude Code 里输入:
给 src/sum.js 写单元测试。
Claude(若 Skill 正常)会执行如下链路:
bash .claude/skills/gen-tests/scripts/detect-framework.sh . → jest.claude/skills/gen-tests/references/jest-conventions.mdsrc/sum.jssrc/__tests__/sum.test.jsnpx jest src/__tests__/sum.test.js// 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。约定文件里应写清这条坑。allowed-tools 是否漏了 Bash,或脚本路径写错。__tests__/):约定文件里强调"Go 测试必须与源文件同目录同包",并在正文流程中点名。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
拆分子 Skill:当约定文件膨胀,可把 gen-tests 拆成 gen-tests-jest、gen-tests-pytest,父 Skill 根据框架结果"转发"。这契合渐进披露,避免一个包过载。
缓存侦测结果:若工程很大、脚本较慢,可在仓库根写 .claude/.framework-cache,正文先读缓存、缺失再跑脚本。注意把缓存文件加进 .gitignore。
描述别过度承诺:不要在 description 写 "always produces perfect tests"。Skill 是辅助,措辞要留余地,否则失败时用户预期落差大。
用 Evals 量化:准备 10 个不同语言的源文件作为盲测集,记录生成测试的首次通过率,迭代 description 与约定文件,盯住这个指标上升。这是把 Skill 从"能用"推向"好用"的唯一可靠方法。
安全收口:allowed-tools 只给必要的四个(Read/Write/Edit/Bash),别给 WebFetch 之类。生成测试不需要联网,关掉它既省 token 也降风险。
https://code.claude.com/docs/en/skills —— 最权威的字段与行为定义,写新 Skill 前必读。SKILL.md 在更广生态中的定位。/skill skill-creator 半自动生成骨架,适合快速起步。[!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 等场景全覆盖。
