本教程手把手带你从零构建一个可复用的 Claude Code Skill——单元测试生成器。学完你能掌握 SKILL.md 的 frontmatter 写法、动态上下文注入、参数传递、支撑文件渐进披露、工具预批与触发控制,并把成果沉淀为一条 /gen-tests 命令。适合已经用 Claude Code 跑过几个项目、想把重复指令固化成技能的中级开发者。
${CLAUDE_SKILL_DIR}、/reload-plugins 等较新特性)vitest 或 jest),或 Python + pytestmkdir、cat、bash在动手前,必须弄清三件事,否则后面写的 SKILL.md 会「不触发」或「触发太频繁」。
Skill 是什么? 一个目录,里面至少有一个 SKILL.md。Claude Code 把它的「描述」常驻上下文,但「正文指令」只有在你或 Claude 真正调用时才加载——这正是它比把一切塞进 CLAUDE.md 更省 token 的原因。官方一句话总结:当你反复把同一段指令、检查清单、多步流程粘进对话框时,就该把它做成 Skill。
它由两部分组成:
1. YAML frontmatter(--- 之间):告诉 Claude「何时用、怎么用」
2. Markdown 正文:Claude 调用后真正遵循的指令
关键生命周期事实(决定你如何写正文):
- 正文被调用后会作为一条消息进入对话,并在整个会话内常驻,Claude 不会每轮重新读文件。所以正文要写成「持续生效的指令」,而不是「一次性步骤」。
- 自动压缩(auto-compaction)时,Claude Code 会重新挂载「最近调用过的」技能,每个保留前 5000 tokens,合计预算约 25000 tokens。如果调用太多技能,旧的会被丢掉——这解释了为什么长会话里某个技能「突然不生效」。
- 技能目录支持热加载:在 ~/.claude/skills/ 或项目 .claude/skills/ 下增删改 SKILL.md,当前会话即时生效,无需重启。
技能存放在哪决定谁能用:
| 层级 | 路径 | 作用范围 |
|---|---|---|
| 个人 | ~/.claude/skills/<名字>/SKILL.md |
你所有项目 |
| 项目 | .claude/skills/<名字>/SKILL.md |
仅当前项目 |
| 插件 | <plugin>/skills/<名字>/SKILL.md |
启用插件处 |
同名时个人覆盖项目、企业覆盖个人。本教程我们放在项目级,便于随仓库分发。
核心要点:description 常驻、正文按需加载、正文常驻到会话结束、单文件建议 500 行以内。这三条决定了下面每一步的设计取舍。
「生成单元测试」看似一句话,其实是多步流程,这正是 Skill 的用武之地。我们把它拆成 6 个原子动作,对应正文里的指令:
拆清楚后,正文就是把这 6 步变成可执行指令。下面分 9 步实现。
操作说明
在项目根执行:
mkdir -p .claude/skills/gen-tests/scripts
mkdir -p .claude/skills/gen-tests/examples
touch .claude/skills/gen-tests/SKILL.md
最终结构:
.claude/skills/gen-tests/
├── SKILL.md # 主指令,<500 行
├── reference.md # 测试模式库,按需加载
├── scripts/
│ └── scan-untested.sh # 扫描未测试文件的脚本
└── examples/
└── vitest.test.js # 输出示例,展示期望格式
预期结果:进入会话后输入 / 能在自动补全里看到 gen-tests。若没看到,多半是目录名拼错或 SKILL.md 还没写入有效内容。
常见问题:
- 目录建在子包里不生效? 只有当 Claude 读写该子目录文件时,嵌套 .claude/skills/ 才被按需发现。想全局生效就放项目根或 ~/.claude/skills/。
- 新建的顶层 skills 目录会话启动时不存在? 这需要重启 Claude Code,热加载只覆盖「会话启动时已存在」的目录。
操作说明
打开 SKILL.md,写入 frontmatter:
---
name: gen-tests
description: 'Generate comprehensive unit tests from source code, covering happy paths, edge cases, boundary values, and error handling. Use when the user asks to "generate unit tests", "add test coverage", "create tests for", or "write tests for" a function, class, or module.'
when_to_use: 'Trigger phrases: "生成单元测试", "补测试", "加测试覆盖", "generate unit tests", "add test coverage". Falls back to the user naming a source file to test.'
allowed-tools: Read, Glob, Grep, Write, Edit, Bash(npm test *), Bash(npx vitest *), Bash(npx vitest run *), Bash(pytest *)
arguments: target framework
disable-model-invocation: false
---
逐字段解读:
name:显示名,默认就是目录名,这里写 gen-tests,命令就是 /gen-tests。description:最重要。Claude 用它判断要不要自动调用。第一句话写「做什么」,后面写「何时用」。注意:description + when_to_use 在技能列表里合计截断到 1536 字符,所以别写小说。when_to_use:补充触发短语,会拼到 description 后面,同样计入 1536 上限。allowed-tools:列出技能激活时免确认的工具。注意它「不限制」Claude 只能用这些,而是预批准——其余工具仍可用,受你全局权限管控。arguments:声明具名参数,target framework 表示 $target 取第一个参数、$framework 取第二个。disable-model-invocation:默认 false,即 Claude 可自动调用。如果你觉得它「自动调用太频繁」,可改成 true 改为只能手动 /gen-tests 触发。预期结果:保存后,输入 /gen-tests 能触发;或直接问「给 utils 加点测试」,Claude 自动选中本技能。
常见问题:
- 不自动触发? 99% 是 description 不够具体。把「生成单元测试」改成带触发短语、带「Use when…」的句子。
- 触发太频繁? description 写得太宽泛(如「improve code quality」)。收窄到具体场景,或干脆 disable-model-invocation: true。
- 描述被截断看不到后半句? description 过长,把它压到 1500 字符以内,或拆到正文。
操作说明
先写扫描脚本 scripts/scan-untested.sh(列出没有配套 *.test.* 的源文件):
#!/usr/bin/env bash
# List source files lacking a co-located test file (JS/TS demo).
set -euo pipefail
root="${1:-src}"
[ -d "$root" ] || { echo "dir not found: $root"; exit 0; }
while IFS= read -r f; do
dir=$(dirname "$f")
base=$(basename "$f" | sed -E 's/\.(ts|js|jsx|tsx)$//')
if ! ls "$dir"/*"$base"*test.* "$dir"/{__tests__,tests}/*"$base".* 2>/dev/null | grep -q .; then
echo "$f"
fi
done < <(find "$root" -type f \( -name '*.ts' -o -name '*.js' \) | grep -v node_modules)
赋权:chmod +x .claude/skills/gen-tests/scripts/scan-untested.sh。
在 SKILL.md 正文顶部用 !command`` 语法注入其输出:
## 0. Untested files in this repo
!`bash ${CLAUDE_SKILL_DIR}/scripts/scan-untested.sh src`
关键点:${CLAUDE_SKILL_DIR} 会展开成 SKILL.md 所在目录,所以无论当前工作目录在哪都能找到脚本。!command`` 是预处理——命令在 Claude 看到正文前就已执行,输出替换占位符,Claude 直接拿到「实际数据」而非「命令本身」。
预期结果:调用 /gen-tests 时,正文开头出现真实的文件路径清单,例如:
src/utils/slugify.js
src/utils/parse.js
常见问题:
- ${CLAUDE_SKILL_DIR} 没展开? 确认 Claude Code 版本足够新;老版本可用绝对路径代替。
- 脚本输出报错被吃掉? 别在脚本里 set -e 后直接 exit 1,空目录应 exit 0 返回空清单,否则整条注入失败。
- 命令输出含敏感信息? 注入内容会进入上下文,扫描脚本里别打印 .env、密钥、token。
操作说明
正文主体写「生成测试的标准流程」。采用 Arrange-Act-Assert(AAA)模式与四类用例法(来自成熟测试技能的通用配方):
## 1. Understand the target
If the user named a target file ($target), Read it in full.
Otherwise, work through the untested files above in order.
For each public function/method, extract: signature, parameter and return
types, thrown exceptions, external dependencies, and whether it is pure.
## 2. Generate four categories of cases per function
- Happy path: valid inputs → expected output (>= 2 cases).
- Edge cases: empty string/array, zero, negative, null, undefined, max value.
- Boundary: off-by-one, runs of separators, length limits.
- Error conditions: wrong type, missing field, network/permission failure.
## 3. Isolate dependencies
Mock external calls (DB, HTTP, filesystem) with predictable fixtures.
Place mocks at the top of the file before any imports that cache the real module.
## 4. Write the file following project conventions
- Match existing naming: *.test.ts / *.spec.js / test_*.py.
- Group with describe/context blocks per function.
- Use beforeEach/afterEach for setup and teardown.
- Add inline comments only for non-obvious assertions.
## 5. Verify
Run the framework command and confirm all cases pass.
Then report line/branch coverage and list uncovered branches with a
suggested extra case for each.
预期结果:Claude 按 1→5 顺序执行,生成结构清晰的测试,跑通后给出覆盖率。
常见问题:
- Claude 只写 happy path 就收手? 在第 2 点后面加硬性要求:「Cover ALL four categories; do not stop until each has at least one case.」
- 测试「单独跑过、一起跑挂」? 第 3 点强调 mock 置顶、afterEach 重置共享状态——这是测试隔离的常见坑。
- 异步用例超时? 提示加 await 与合理 timeout。
操作说明
把「测试模式库」从主文件移到 reference.md,主文件只在需要时引用它。这是 Skill 的核心优化手段:大段参考资料不必每次都进上下文,按需加载即可。
reference.md(节选):
# Test patterns reference
## Testing a pure function
No mocks needed. Assert input→output directly.
## Testing an async function
- Always `await` the call.
- Assert both resolved value and rejection (use `.rejects.toThrow(...)`).
## Mocking a module (vitest)
\`\`\`js
import { vi } from 'vitest';
vi.mock('../src/db', () => ({
findUser: vi.fn().mockResolvedValue({ id: 1 }),
}));
\`\`\`
Define mocks BEFORE importing the module under test.
## Coverage thresholds
- Lines >= 80%, Branches >= 75% as a sane default; raise per project policy.
在 SKILL.md 末尾加引用说明:
## Additional resources
- For full pattern library and mock recipes, Read [reference.md](reference.md).
- For expected output format, see [examples/vitest.test.js](examples/vitest.test.js).
预期结果:SKILL.md 控制在 500 行内;遇到复杂 mock 场景时 Claude 会主动 Read reference.md。
常见问题: - Claude 不去读 reference.md? 主文件里要明确说「when you need to mock a module, Read reference.md section X」——指向越具体,加载概率越高。 - 主文件还是太长? 凡是「大段参考资料」都该外移,主文件只留「流程指令」与「文件索引」。
操作说明
利用 frontmatter 里声明的 arguments: target framework,正文里用 $target、$framework:
## Target
$target # 展开为第一个参数,如 src/utils/slugify.js
$framework # 展开为第二个参数,如 vitest
## Run command by framework
- vitest → `npx vitest run $target` 邻近的 test 文件
- jest → `npx jest <pattern>`
- pytest → `pytest <test_file>`
调用方式:
/gen-tests src/utils/slugify.js vitest
等价于 $target=src/utils/slugify.js、$framework=vitest。也可用 $0、$1 的位置简写。多词参数要加引号:/gen-tests "src/big dir/x.js" jest。
预期结果:Claude 只为指定文件生成测试,并按指定框架选运行命令。
常见问题:
- 参数没被替换? 正文里必须出现 $target / $ARGUMENTS。若正文完全不含这些占位符,Claude Code 会把参数作为 ARGUMENTS: <值> 追加到正文末尾。
- 正文里有 $1.00 这种美元金额被误替换? 用反斜杠转义:\$1.00。
操作说明
回顾 step 2 的 allowed-tools,思考两个边界:
| 场景 | 配置 | 效果 |
|---|---|---|
| 你想手动控制每次生成 | disable-model-invocation: true |
Claude 不自动跑,只能你 /gen-tests |
| 这是团队后台知识,不该手动触发 | user-invocable: false |
从 / 菜单隐藏,只有 Claude 自动调用 |
| 既想自动又想手动 | 默认(两者都不写) | 描述常驻、正文按需加载 |
| 想禁止它乱删文件 | disallowed-tools: Bash(rm *) Bash(git push *) |
该技能激活期间这些工具被移出可用池 |
本生成器需要「读源码、写测试、跑测试」,所以预批 Read/Glob/Grep/Write/Edit 与受限的 Bash(npm/vitest/pytest);同时禁止危险命令:
disallowed-tools: Bash(rm *), Bash(git push *), Bash(git commit *)
预期结果:生成过程中不再频繁弹权限确认;危险命令被挡。
常见问题:
- 项目级技能的 allowed-tools 没生效? 需先接受仓库信任对话框(workspace trust)。allowed-tools 不限制工具范围,只是「免确认」,别误以为它能让 Claude 只用这几个工具。
- disallowed-tools 一直生效? 它在你「下一条消息」后就清除——只对当前技能激活期间有效。
操作说明
/gen-tests src/utils/slugify.js vitest
然后让 Claude 自己执行验证闭环:写文件 → 跑测试 → 失败则读报错修正 → 重跑 → 报覆盖率。
期望输出形态:
✓ __tests__/slugify.test.js created (8 cases, 4 categories)
✓ npx vitest run slugify → 8 passed
Coverage: lines 100%, branches 83%
Gap: branch `input === null` not asserted → suggest: it('throws on null')
常见问题:
- 生成的测试一开始就红? 正常。指令里要明确「run, read failures, fix, re-run」形成闭环,不要写完就停。
- 覆盖率命令不存在? 框架需装 coverage 插件(vitest 内置 --coverage,jest 需 --coverage,pytest 需 pytest-cov)。在 reference.md 写清各框架启用方式。
操作说明
观察几轮使用后做三件事:
paths 限定:只在编辑测试相关文件时自动加载:
yaml
paths: "**/*.{js,ts,jsx,tsx,py}", "**/test*/**"disable-model-invocation: true:把这个生成器变成「按需手动」命令,避免它自作主张改你的测试。调优后用 /code-review 或人工抽检生成质量,必要时回到 reference.md 补模式。
预期结果:误触发降到接近零,命中率稳定。
把上面所有片段串成一个完整可运行示例。
源文件 src/utils/slugify.js:
export function slugify(input) {
if (typeof input !== 'string') {
throw new TypeError('slugify expects a string');
}
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
调用:/gen-tests src/utils/slugify.js vitest
Claude 生成的 __tests__/slugify.test.js(四类齐全):
import { describe, it, expect } from 'vitest';
import { slugify } from '../src/utils/slugify';
describe('slugify', () => {
// Happy path
it('converts spaces to hyphens', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('lowercases uppercase letters', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
// Edge cases
it('returns "" for empty string', () => {
expect(slugify('')).toBe('');
});
it('returns "" for only-separators input', () => {
expect(slugify('!!!')).toBe('');
});
it('trims leading/trailing whitespace', () => {
expect(slugify(' hi ')).toBe('hi');
});
// Boundary
it('collapses runs of separators into one hyphen', () => {
expect(slugify('a b')).toBe('a-b');
});
// Error conditions
it('throws TypeError for non-string input', () => {
expect(() => slugify(null)).toThrow(TypeError);
expect(() => slugify(42)).toThrow(TypeError);
});
});
运行 npx vitest run slugify:8 passed。Claude 随后报告 lines 100%, branches 100%,无缺口。
这个案例一次性展示了:参数定位($target)、AAA 分组、四类用例、错误断言、与验证闭环。
技能内部架构:
flowchart LR
subgraph GT[.claude/skills/gen-tests]
S[SKILL.md 主指令 <500行]
R[reference.md 模式库]
P[scripts/scan-untested.sh]
EX[examples/vitest.test.js]
end
S -->|按需 Read| R
S -->|!脚本 注入执行| P
S -->|示例格式| EX
运行时流程:
flowchart TD
A[用户: 给 X 加单元测试] --> B{description 命中?}
B -- 否 --> Z[不加载本技能]
B -- 是 --> C[渲染 SKILL.md]
C --> D["!`scan-untested.sh` 注入未测试清单"]
C --> E["参数 $target / $framework 展开"]
D --> F[Claude Read 目标源码]
E --> F
F --> G[四类用例 + mock 设计]
G --> H[Write 测试文件]
H --> I[运行 vitest/pytest]
I --> J{全部通过?}
J -- 否 --> K[读报错 修正用例]
K --> I
J -- 是 --> L[报告覆盖率与缺口]
| 现象 | 根因 | 解决 |
|---|---|---|
/gen-tests 不出现 |
目录名/路径错,或 SKILL.md 空文件 |
检查 .claude/skills/gen-tests/SKILL.md 存在且有 frontmatter |
| 不自动触发 | description 不含触发短语 | 加 Use when … "generate unit tests" 等短语 |
| 触发太频繁 | description 太宽泛 | 收窄或 disable-model-invocation: true |
| 描述后半句消失 | description+when_to_use 超 1536 字符 | 精简,细节移正文 |
| 长会话后技能失效 | auto-compaction 丢掉了旧技能 | 关键步骤后重新 /gen-tests 一次 |
${CLAUDE_SKILL_DIR} 没展开 |
Claude Code 版本旧 | 升级 v2.1.145+ 或改绝对路径 |
| mock 拦不住调用 | mock 写在 import 之后被缓存 | mock 必须置于文件顶部 import 之前 |
| allowed-tools 没免确认 | 项目级未接受 workspace trust | 接受信任对话框 |
/code-review、/batch、/debug、/loop 的用法——它们本身是最好的 SKILL.md 范本SKILL.md 也能在 Copilot、Cursor 等工具复用/run-skill-generator:官方把「如何启动你的项目」固化成技能的范例,结构值得借鉴SKILL.md < 500 行并正确引用支撑文件;(4) 误触发率趋零。c25v/--coverage JSON);加 context: fork 让生成跑在子代理里、不污染主上下文;结合 hooks 在提交前自动补测。[!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 等场景全覆盖。
