首页 › tutorials › 从零造单元测试生成器技能

概述

本教程手把手带你从零构建一个可复用的 Claude Code Skill——单元测试生成器。学完你能掌握 SKILL.md 的 frontmatter 写法、动态上下文注入、参数传递、支撑文件渐进披露、工具预批与触发控制,并把成果沉淀为一条 /gen-tests 命令。适合已经用 Claude Code 跑过几个项目、想把重复指令固化成技能的中级开发者。

前置条件


一、先搞懂 Skill 的运行机制

在动手前,必须弄清三件事,否则后面写的 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:把任务拆成原子动作

「生成单元测试」看似一句话,其实是多步流程,这正是 Skill 的用武之地。我们把它拆成 6 个原子动作,对应正文里的指令:

  1. 发现:找出没有配套测试的源文件
  2. 理解:读取目标函数签名、参数类型、返回类型、外部依赖、纯/非纯函数
  3. 设计:按四类生成用例——happy path、edge case、boundary、error
  4. 隔离:为外部依赖(DB、HTTP、文件系统)造 mock
  5. 落盘:遵循项目命名约定写测试文件
  6. 验证:跑测试、看覆盖率、报缺口并建议补测

拆清楚后,正文就是把这 6 步变成可执行指令。下面分 9 步实现。


步骤详解

步骤 1:创建 Skill 目录骨架

操作说明

在项目根执行:

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,热加载只覆盖「会话启动时已存在」的目录。


步骤 2:写 frontmatter——告诉 Claude 何时、如何用

操作说明

打开 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
---

逐字段解读:

预期结果:保存后,输入 /gen-tests 能触发;或直接问「给 utils 加点测试」,Claude 自动选中本技能。

常见问题: - 不自动触发? 99% 是 description 不够具体。把「生成单元测试」改成带触发短语、带「Use when…」的句子。 - 触发太频繁? description 写得太宽泛(如「improve code quality」)。收窄到具体场景,或干脆 disable-model-invocation: true。 - 描述被截断看不到后半句? description 过长,把它压到 1500 字符以内,或拆到正文。


步骤 3:用动态上下文注入把「未测试清单」喂进来

操作说明

先写扫描脚本 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。


步骤 4:写核心生成指令——AAA + 四类用例

操作说明

正文主体写「生成测试的标准流程」。采用 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。


步骤 5:拆支撑文件做渐进披露

操作说明

把「测试模式库」从主文件移到 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」——指向越具体,加载概率越高。 - 主文件还是太长? 凡是「大段参考资料」都该外移,主文件只留「流程指令」与「文件索引」。


步骤 6:用参数让 Skill 指向任意文件/框架

操作说明

利用 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。


步骤 7:预批工具与触发控制的取舍

操作说明

回顾 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 一直生效? 它在你「下一条消息」后就清除——只对当前技能激活期间有效。


步骤 8:验证、跑测试、看覆盖率

操作说明

/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 写清各框架启用方式。


步骤 9:调优触发与迭代

操作说明

观察几轮使用后做三件事:

  1. 收窄 description:如果 Claude 在「写注释」「写文档」时也误触发,把描述里泛词去掉,只留「unit tests / test coverage / add tests」。
  2. 加 paths 限定:只在编辑测试相关文件时自动加载: yaml paths: "**/*.{js,ts,jsx,tsx,py}", "**/test*/**"
  3. 必要时改 disable-model-invocation: true:把这个生成器变成「按需手动」命令,避免它自作主张改你的测试。

调优后用 /code-review 或人工抽检生成质量,必要时回到 reference.md 补模式。

预期结果:误触发降到接近零,命中率稳定。


实战案例:给 slugify 函数造测试

把上面所有片段串成一个完整可运行示例。

源文件 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 主指令 &lt;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 接受信任对话框

延伸阅读


训练营适用性评估


参考来源

  1. Extend Claude with skills — Claude Code Docs — 官方 Skills 完整规范,含 frontmatter 字段表、字符串替换、动态注入与生命周期,是本文 frontmatter 与渐进披露部分的权威依据。
  2. Creating custom skills — Claude.ai Documentation — 官方「如何创建技能」实操指南,本文步骤 1–3 的目录结构与触发控制参考自此。
  3. unit-test-generator SKILL.md(GitHub) — 一个真实可用的单元测试生成技能,本文四类用例法、mock 策略与 AAA 模式直接取材于此。
  4. How to Build Your Own Claude Code Skill — freeCodeCamp — 强调 Skill 作为开放标准、跨工具复用的视角,补充了本文延伸阅读部分。
  5. How to Build a Production-Ready Claude Code Skill — Towards Data Science — 生产级技能的调优与迭代建议,本文步骤 9 与训练营考核点参考其工程实践。
  6. Claude Skills Tutorial: Build Your First Skill — TECHSY — 入门级两个 SKILL.md 范例,可用于对比本文从零搭建的完整流程。

[!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学通信知识星球海报-扫码加入