首页 › tutorials › 打造单元测试生成Skill

概述

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

前置条件

一、先理解 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。本教程的全部技巧都围绕这条原则展开。

二、创建骨架目录

在项目根目录执行:

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 写成了"产品介绍"

---
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 的三条经验:

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

frontmatter 还能用哪些字段

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。这一步是后续流程的"地基",务必先跑通。

常见问题:

六、把框架约定拆进 references/

这部分是典型的"重内容、低频用"——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 根本不调用它。用两种方式验证:

方式 A:看是否被发现

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

方式 B:看是否被触发

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

方式 C:强制调用做对比

临时把 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

触发 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

预期生成的测试

// 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

常见问题:

九、整体工作流图

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 也降风险。

延伸阅读

训练营适用性评估

参考来源

  1. Extend Claude with skills — Claude Code 官方文档 — Skills 目录结构、frontmatter 字段、触发与手动调用的权威定义。
  2. Agent Skills in the SDK — Claude Code Docs — SKILL.md 作为"目录 + YAML frontmatter + Markdown"的格式说明,含渐进披露说明。
  3. Claude Code Skill Frontmatter: Every YAML Option Explained — 对 name、description、allowed-tools、disable-model-invocation 等 frontmatter 字段的逐项示例解读。
  4. Specification — Agent Skills (agentskills.io) — 跨运行时的 Skill 格式规范,明确 frontmatter 与正文 body 的边界。
  5. Build a Claude Skill: YAML Frontmatter, SKILL.md Instructions, and Scripts — 含 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学通信知识星球海报-扫码加入