维护一个内部 Skill 仓库的体验,比大多数人想象中要狼狈得多。你可能也有过类似的经历:上次运行还很正常的技能,这周突然在某个边界输入上报错;新增了一个看似无关的小功能,结果把老链路整个带崩;上线前手工点了一遍没问题,可用户换个说法就翻车。这些问题的根源其实很一致——Skill 的本质是一个承载不确定性的组件,它接的输入是开放的,输出又常常是非结构化的,而大部分团队还在靠“写完随手点几下”来验证它。我在踩过几轮坑之后,才彻底转向测试驱动开发(TDD):先把预期的行为固化成测试,再让实现去满足测试。这个方法不需要什么高深工具,但它能把“今天运气好没出问题”变成“每次改动都有据可查”。这篇内容会从为什么、怎么做、踩过哪些坑三个层面展开,适合正在开发 Skill、Agent 插件或任何带 AI 能力的可复用模块的开发者参考。我会用一套可以照抄的工程化流程,把 Skill 从“玄学”变成“可测试的确定性模块”。
1. 为什么 Skill 是天然的 TDD 场景
1.1 Skill 的“不稳定病根”在哪里
先明确一下这里说的 Skill 是什么。它不是传统意义上函数或后端服务那样输入输出边界特别清晰的东西,而是一个对外提供某种能力、内部往往依赖大模型或规则引擎的组合模块。举个例子:一个“日程提取 Skill”,输入是一句口语(“明天上午十点开会,下午记得买牛奶”),输出是结构化的日程数组。听起来很简单,但真正让它难以稳定的是三个特征:输入不可穷尽、输出不完全可控、依赖项多且易变。
输入不可穷尽意味着测试永远不可能覆盖所有说法。同样是表达“明天上午十点”,用户可能说“明早十点”“明儿上午十点开会”“明天十点有个会”甚至夹杂着错别字和口语碎片。输出不完全可控则更棘手——大模型的输出是概率性的,同一条输入跑两次结果可能有细微差别,且 JSON 格式可能破损、字段名可能漂移、枚举值可能超出约定。更别说底层的模型接口会更新、Prompt 会被同事顺手改掉一个字、工具调用的返回结构会变动。这些因素叠加在一起,就形成了“今天能用,明天说不定哪里炸”的即视感。
传统的手工验证对这种模块几乎无效。手工点的场景顶多十来个,而 Skill 面对的输入空间是无限的;更致命的是,手工验证只能证明“当前这次跑通了”,无法防止下一次改动引入回归。我在一个项目里就亲眼见过,某同事为了修一个日期解析的小 bug,把整个 Prompt 重写了一遍,修好了 A 场景,却让 B、C、D 三个场景全部静默失效——如果没有回归测试兜底,这种问题会一直潜伏到用户反馈才暴露。
1.2 TDD 不是测试技巧,是设计工具
很多人对 TDD 的第一印象是“先写测试再写代码,让测试变绿”。但如果只看到这一层,就会觉得它增加了工作量,甚至是在做样子。实际用过之后我的感受是,TDD 对 Skill 开发的作用被严重低估了,它真正改变的是设计方式。
TDD 的循环只有三步:红(让测试失败)、绿(让测试通过)、重构(在测试保护下优化代码)。但“红”这一步逼着你先回答一个关键问题——这个 Skill 到底承诺了什么行为?依赖项被 mock 掉之后,输入 X 应该得到什么结构的输出?边界输入应该怎么兜底?异常情况下应该抛错还是返回默认值?这些问题的答案会沉淀成一组测试用例,而这些测试用例本质上就是 Skill 的行为契约。
传统开发顺序是“先实现,再补测试”,测试往往变成了实现细节的复印件,测的是“代码怎么写的”,而不是“行为应该是什么”。TDD 反过来,先定义行为,再决定实现。尤其对 Skill 来说,行为契约比实现重要得多——调用方只关心输入输出,不关心你内部用的是哪个模型、写的什么 Prompt。用 TDD 开发 Skill,你会被迫提前思考接口边界、错误处理、异常输入,而不是只顾着让“happy path”跑通。
还有一个容易被忽视的好处:TDD 让重构变得安全。Skill 的 Prompt 调优是个高频操作,有时候只是换个措辞,输出质量就明显提升,但同时可能破坏某些场景。有测试兜底,你可以大胆地实验 Prompt 措辞、调整后处理逻辑、替换底层模型,然后让测试告诉你有没有破坏约定。没有测试,每次 Prompt 改动都像蒙着眼睛拆弹。
1.3 Skill 场景下单元测试、集成测试怎么分工
测试金字塔理论在这里同样适用,但 Skill 场景的分工略有不同。单元测试针对的是 Skill 内部的纯逻辑部分,包括输入预处理、输出解析、结构化校验、规则兜底等,这些部分不依赖外部服务,运行快、结果确定,是 TDD 的主战场。集成测试则把真实的模型 API 或工具服务纳入进来,用于验证 Prompt 实际效果、模型输出质量、整体链路是否顺畅。
这两层测试的目的不一样,不能互相替代。单元测试管的是“逻辑是否按照契约执行”,集成测试管的是“模型是否按预期理解任务”。如果只写单元测试,你可能把一层完美的解析逻辑架在一个根本产出不良输出的模型上;如果只写集成测试,速度慢、不稳定、且无法精确定位问题——一次失败到底是因为模型抽风还是解析 bug?
我的习惯是 80% 的测试放在单元层:输入清洗函数、日期解析、JSON 归一化、schema 校验、异常分支全部用 mock 数据覆盖。剩下的 20% 是集成冒烟测试:让 Skill 跑几条典型的真实请求,做基础的质量断言。TDD 在这个框架下的角色非常清晰:用单元测试把逻辑钉死,用集成测试验证真实效果,以稳定的代码逻辑为基座去拥抱模型的不确定性。
2. 搭好测试基座:Skill 测试骨架的一次到位
2.1 测试框架与断言库选型
测试框架的选择其实不用太纠结,关键是与项目语言栈匹配。如果你的 Skill 是 TypeScript 生态,比较顺手的组合是 vitest 加 supertest;Python 生态则是 pytest 加 pytest-asyncio;如果是 Java/Kotlin,JUnit 加 Mockito 也完全够用。框架本身不影响 TDD 的流程,真正影响体验的是断言库的表达力和异步测试的支持度。
Skill 场景有两个特性对测试框架提出了硬性要求。第一,异步是常态,因为 Skill 内部几乎一定会调用外部服务(模型 API、工具服务),测试框架必须原生支持异步用例,否则半路会踩各种回调地狱。第二,断言不仅要支持基础类型比较,还要能对复杂结构做部分匹配,因为模型输出的字段可能很多,你只关心其中关键的几个。比如 TypeScript 生态我会用 vitest 的expect.objectContaining,Python 用 pytest 加pytest-structlog或者直接配合 jsonschema 做校验——先把框架搭起来,后续所有用例都会落到这层能力上。
另外强烈建议在项目里接入覆盖率工具,vitest --coverage或pytest --cov都可以。覆盖率不一定需要追求 100%,但对 Skill 来说,核心解析逻辑的覆盖率低于 80% 基本可以认为测试没写到位。原因后面会详细讲,这里先埋个伏笔。
2.2 目录与命名规范
测试基建里最容易做错的事情,是把测试文件和源文件混在一起但又不讲规矩。没有规则的测试目录短期看着省事,长期一定会变成一锅粥:文件名重复、用例不知道对应哪个模块、跑完测试不知道哪个挂了影响哪里。
我目前的规范是这样的:项目根目录下建独立的tests目录,按模块做镜像结构。src/skills/todo-skill/parser.ts对应的测试放在tests/skills/todo-skill/parser.spec.ts,解析逻辑的测试和 Skill 整体行为的测试分开建文件。命名上使用.spec.ts或.test.ts后缀,测试框架默认就自动发现这些文件,不需要额外配置。
文件名只是基础,真正重要是describe的层级要和被测结构一一对应。拿“日程提取 Skill”举例,parser模块下会有describe('parser')作为顶层,下面再按函数或场景拆成describe('extractDateTime')、describe('normalizeInput')。这样任何一个测试失败,控制台输出的第一行就是“哪个模块的哪个行为出了问题”,你可以直奔现场,而不是从 30 个测试里玩排除法。
这里还有一个实用的细节:测试文件里不要写依赖执行顺序的用例。每个测试必须是独立的,setUp 里给的数据当场构造,不共享可变状态。Skill 测试最怕“上一个用例改了全局变量所以我挂了”这种幽灵场景,一旦出现,排查成本会指数级上升。
2.3 让外部依赖“可控”:Mock LLM 与工具调用
Skill 测试最大的难点在于外部依赖不可控。模型 API 可能网络波动、可能限流、可能返回格式变化;工具服务也存在同样的不确定性。如果不做处理,你的测试就会变成“网络测试”而不是“逻辑测试”。所以测试基座里必须有一套成熟的 mock 策略。
mock 的核心原则是:mock 掉不稳定与慢的部分,保留想验证的逻辑。对于 LLM 调用,我通常会封装一个LLMClient接口,真实环境注入模型 SDK,测试环境注入一个返回固定响应的 fake 实现。比如要测试“日程提取 Skill 的解析函数”,就 fake LLM 返回一段固定的 JSON 文本——包括正常的 JSON、带 markdown 代码块的 JSON、损坏的 JSON,每个用例给一个 fixture,解析逻辑在可控输入下逐行暴露。
mock 工具调用同理。Skill 可能内部调用了日历服务、搜索接口或内部工具,这些都要在外围 mock 掉。这里的技巧是:不要 mock Skill 自身的函数,而是 mock 它依赖的边界。用依赖注入的方式把外部服务变成可替换的接口,而不是在测试里用vi.mock去偷换内部实现。这样测试的角度是“通过输入输出接口验证行为”,而不是“验证内部函数的调用顺序”,前者才是行为契约测试。
mock 数据本身也很讲究。很多人随手捏一个很规整的 JSON 就当作 fixture,这是大忌。真实世界的模型输出永远是脏的:有多余的空白、带错误的转义、字段顺序不稳定、可能多出没约定过的属性。测试 fixture 应该从真实运行日志里截取,再脱敏整理。这样测试才能真正模拟你害怕的场景,而不是自欺欺人地测一个理想世界。
3. 手写一个 Skill:完整 TDD 循环直播
3.1 需求拆解:把一句话需求变成测试清单
理论说太多没用,直接来看实操。这里我用一个“待办提取 Skill”作为示例——输入一句口语描述,输出一个结构化的待办清单。需求一句话就能说清:“从用户描述中提取待办事项、时间信息和优先级。”但做 TDD 第一步不是写代码,而是把这个需求拆成一份测试清单,也就是行为契约。
我会和需求方把边界问清楚:遇到“明天十点开会”这种明确时间怎么处理?遇到“记得买牛奶”这种没有时间只有事项的呢?优先级怎么定,用户不说就默认普通吗?输入为空或纯空白字符串,是抛异常还是返回空数组?日期格式五花八门,是统一转成 ISO 还是保留原文?这些问题看着琐碎,但每个答案都会变成一个测试用例。
最终这份清单大概长这样:
- 输入正常文本,输出包含所有待办事项
- 带明确时间的输入,解析出正确的时间字段
- 输入无时间信息时,时间字段为空但待办不丢
- 输入为空字符串,返回空数组而不是抛异常
- 输入纯空格,与空字符串行为一致
- 多个待办事项同时存在,顺序与输入一致
- 以“赶紧/务必/尽快”等语气词识别为高优先级
- 模型的返回是带 markdown 代码块的 JSON,仍能正确解析
- 模型返回损坏 JSON 时,返回降级处理结果而不是崩溃
这就是 TDD 的红阶段设计。测试清单不需要一次写完,你完全可以在第一轮先写三五个核心用例,之后的循环里慢慢补充边界。但有一个原则:每个测试必须表达一种“可观察的行为”,而不是“内部实现的细节”。比如“调用 schema 校验”“解析 JSON 时 catch 了异常”这种用例就属于实现细节,写了反而增加维护负担。
3.2 第一轮红:先写失败测试
选一个核心行为开始,例如“输入正常文本,输出包含所有待办事项”。我先把 Skill 的接口定下来:extractTodos(input: string): TodoItem[]。TodoItem包含title、time、priority三个字段。然后写测试,用 vitest 写法大概是这样的:
import { describe, it, expect } from 'vitest'; import { createFakeLLM } from '../test-utils/fake-llm'; import { extractTodos } from '../../src/skills/todo-skill/todo-extractor'; describe('extractTodos', () => { it('从正常输入中提取所有待办事项', async () => { // 构造 fake LLM 返回 const llm = createFakeLLM({ response: JSON.stringify([ { title: '开会', time: '2025-06-20T10:00:00', priority: 'normal' }, { title: '买牛奶', time: '', priority: 'normal' }, ]), }); const todos = await extractTodos('明天上午十点开会,下午记得买牛奶', { llm }); expect(todos).toHaveLength(2); expect(todos[0].title).toBe('开会'); expect(todos[1].title).toBe('买牛奶'); }); it('输入为空字符串时返回空数组', async () => { const llm = createFakeLLM({ response: '[]' }); await expect(extractTodos(' ', { llm })).resolves.toEqual([]); }); });注意测试里我把 LLM 客户端作为参数注入进去。这是 TDD 里很关键的设计选择:extractTodos(input, deps)这样写,测试就不需要任何全局 mock,也能让依赖关系在函数签名里暴露得明明白白。此时extractTodos还没有实现,两个测试必然失败——这就是我们想要的“红”。看到红灯不要慌,这正是 TDD 起效的证明:测试真的在验证行为,而不是空转。
3.3 第二轮绿:写最小实现通过测试
红的目标是让我们看到“行为缺失”的信号,绿的目标则是用最小实现让测试通过。所谓最小实现,就是代码宗旨不是优秀、不是完整,而是“刚好满足当前测试的描述”。这段过程很多人容易走偏——会忍不住一次性把完整功能写全,觉得顺带实现让我省事。但这么做恰恰破坏了 TDD 的节奏,因为一旦实现超越了测试的约束,你写的那些“额外功能”就没有测试保护,后面一重构就碎。
对于上面两个测试,最小实现可以是:
import { LLMClient } from '../types'; export interface TodoItem { title: string; time: string; priority: 'high' | 'normal'; } export async function extractTodos( input: string, deps: { llm: LLMClient } ): Promise<TodoItem[]> { const trimmed = input.trim(); if (!trimmed) return []; const response = await deps.llm.complete( `提取以下文本中的待办事项,返回 JSON 数组。\n\n${trimmed}` ); const todos = JSON.parse(response); return todos; }只看这段代码,甚至没有错误处理,没有日期规范化,但两个测试都过了。这个阶段代码是“丑”的没关系,TDD 的红绿循环里,重构是下一步才做的事。重要的是我建立了一个安全的垫脚石:后续再添加测试、再改实现,都有这两个测试盯着,不会在不知不觉中退步。
这里顺便解释一个会被新手误会的点:TDD 不是说代码永远不完善,而是说“每一次功能扩展都是由测试驱动的”。每次你想加错误处理逻辑或降级策略,先写一个会失败的测试用例,再去实现。这样代码的每条行为都是被测试锚定的,而不是“我记得这里应该处理一下”的裸奔。
3.4 第三轮重构:稳定性加固
在第一轮红绿通过后,测试清单还剩下很多边界用例。我不会一口气全写,而是挑一种最危险的情况来开始:模型的返回带 markdown 代码块。这是实际中最常见的坑——模型喜欢把 JSON 包在```json里,直接JSON.parse必挂。
先写测试:
it('解析被 markdown 代码块包裹的 JSON', async () => { const llm = createFakeLLM({ response: '```json\n[{"title": "开会", "time": "", "priority": "normal"}]\n```', }); const todos = await extractTodos('明天十点开会', { llm }); expect(todos).toHaveLength(1); expect(todos[0].title).toBe('开会'); });红灯,因为JSON.parse处理不了 markdown 代码块。然后实现一个最小修复:先提取 JSON 部分,再解析。此时我会顺势处理另一个高风险场景:损坏 JSON 时的降级。再补一个测试:
it('模型返回损坏 JSON 时,降级为基于标题的简单解析', async () => { const llm = createFakeLLM({ response: '{ title: 开会 }' }); const todos = await extractTodos('明天十点开会', { llm }); expect(todos).toHaveLength(1); expect(todos[0].title).toBe('明天十点开会'); });这个用例的意图很明确:Skill 不能因为模型输出异常就让整个链路崩溃,至少要退化成“把原文当作待办标题”的保底方案。实现可以用正则抓 JSON 片段,拿到干净字符串就尝试解析,失败就进入兜底逻辑。
这种“红绿循环”每轮只加一个行为,代码一步步从直白变得健壮。积累几个用例之后,你会发现 Skill 的稳定性不是靠一次设计出来的,而是靠测试清单逐步钉出来的。每修完一个边界,测试集合就更密,行为契约就更完整。这也是为什么我说 TDD 对 Skill 开发不是可选项,而是保证“稳定性”这个目标最省力的路径。
4. 稳定性不只是“能跑”,还要“扛得住”
4.1 边界值与异常输入的回归网
很多人理解的测试是“验证功能正常”,但 Skill 的稳定性建设里,边界测试的价值远高于普通功能测试。一个“把明天上午十点变成 ISO 时间”的解析函数,最容易挂掉的不是标准情况,而是“明天”“后天”“下周一”“周五晚上”“3月5号下午三点”这种五花八门的自然语言表达。
边界测试的设计思路是绕着非法和异常来布网。空字符串、纯空白、超长输入、emoji 混杂、全是标点符号、只有日期没有事项、只有事项没有日期、长文本里有多个待办事项但互相嵌套——这些用例不必一次写完,但每遇到一个真实故障,就把它固化为一个回归测试。这是稳定性建设的最低成本做法:线上用户帮你踩过的坑,必须变成自动测试拦住它再犯。
我在实际项目里有个铁律:线上 bug 修复必须附带回溯测试。不是说修完代码就完事,而是先写一个能复现该 bug 的测试,确认它红灯,再修代码让测试变绿。这等于给每个历史故障立了一块墓碑,提醒后续改动不要在同一个位置跌倒。这种做法积累两三个月后,Skill 的回归网会厚到让你对重构有底气。
4.2 输出结构校验:让 LLM 返回不可信成为历史
LLM 输出的结构不稳定是 Skill 稳定性的头号敌人。你让它返回固定的 JSON,它可能给你加字段、漏字段、字段名拼错,甚至给你返回一段自然语言。单元测试能覆盖 mock 数据下的情况,但真实环境里模型比你想象的更不可控。所以在 Skill 内部必须有一个结构校验层,让“模型乱输出”不至于直接污染下游。
我最常用的做法是引入 JSON Schema 校验。定义好TodoItem的 schema,模型输出经过解析后,先用 schema 校验,合法则放行,不合法则尝试修复或降级。这一层逻辑也可以遵循 TDD——先写一个“模型输出缺少必填字段”的测试,再实现校验与降级策略。
import { z } from 'zod'; const TodoSchema = z.object({ title: z.string().min(1), time: z.string().optional().default(''), priority: z.enum(['high', 'normal']).default('normal'), }); export function parseTodos(jsonText: string): TodoItem[] { const raw = JSON.parse(jsonText); if (!Array.isArray(raw)) return []; return raw .map((item) => { const parsed = TodoSchema.safeParse(item); return parsed.success ? parsed.data : null; }) .filter((x): x is TodoItem => x !== null); }这段逻辑把“模型输出不可信”的问题隔离在了一个小函数里。后续模型再怎么乱来,最多走到这个函数被过滤掉,不会让脏数据流入业务逻辑。而且这个函数是纯函数——不依赖外部服务,输入输出确定,可以安心地堆测试覆盖。我建议所有 Skill 都把“脏输入 → 结构化干净数据”这个环节做成纯函数,这是整个模块最值得投资测试的地方。
4.3 覆盖率与测试金字塔
覆盖率是测试建设的仪表盘,但怎么看待它需要一点辩证。对 Skill 而言,盲目追求行覆盖没有意义——你覆盖了一堆内部细节,却漏掉了最危险的行为边界。我更推荐以“分支覆盖”为参考,关注每个函数里if/else、switch、||等分支是否都走到了。核心解析逻辑分支覆盖率达到 80% 以上,基本上可以放心重构。
如何判断哪些是核心逻辑?看一个标准:出问题后影响面多大。输入清洗、输出解析、字段映射、时间规范化、schema 校验——这些是 Skill 的“骨架”,必须重兵把守。而 Prompt 拼接、工具调用顺序这些相对稳定或难以断言的,可以适度放过。把测试资源投在回报最高的地方,而不是雨露均沾地追求覆盖率数字。
测试金字塔在 Skill 领域的分工也很明显。底层的纯函数测试最多最细,运行毫秒级,适合频繁执行。中间层的模块测试把“输入文本 → 调用 mock 依赖 → 输出结构化结果”整条链路跑通。顶层的集成测试数量少、代价高,只在发布前跑。这个金字塔的比例大概控制在 8:2:1 比较舒服,能保证反馈速度和稳定性的平衡。
5. 与 CI/CD 结合:让测试成为 Skill 发布的守门员
5.1 CI 里跑哪些测试
测试写好了,如果只在本地手动跑,价值会大打折扣。真正的稳定性红利来自把测试接入流水线:每次提交代码,自动跑一遍全量用例,把回归消灭在与仓库交互的那一刻,而不是等上线后被用户发现。
本地开发阶段跑的是快速测试组——纯函数和 mock 依赖的模块测试,因为不访问真实服务,几秒钟就出结果,适合在编辑器里频繁执行。CI 阶段则建议分两层:合并请求(MR)触发时跑全量单元测试与静态检查;发布阶段再跑集成测试,因为集成测试依赖真实环境且耗时较长,没必要每次提交都折腾一遍。
持久化测试结果报告也值得做。CI 上发布的测试趋势图可以直观看到用例数量、覆盖率、失败曲线的变化。我曾在一个项目里发现单元测试数量一直在涨,但覆盖率跌了——说明新增测试都挤在了同一类用例上,其他模块依然裸奔。如果没有趋势图,这种隐性恶化很难被察觉。
5.2 测试即文档:从测试用例反推 Skill 行为契约
测试还有一层很少被大家重视的价值:它是最靠近代码的行为文档。Skill 这种模块,文档通常滞后于代码,Prompt 一改,需求文档就过时了。但测试是即时更新的——每次行为变化,对应的测试必然跟着变。
因此我建议给 Skill 仓库建一份BEHAVIOR.md,但内容不是空对空的描述,而是直接从测试清单导出的行为列表。写清楚当前版本支持的输入类型、边界情况、降级策略、错误处理方式。这份文档的维护成本极低,因为每一次 TDD 循环新增的测试用例,本身就是在扩充行为列表。新成员接岗时,看测试比看文档有效得多——测试展示了具体的示例输入与期望输出,相当于嵌在代码里的活文档。
另一个实践是给测试用例分标签或用命名前缀做分类。比如[core]表示核心契约,[edge]表示边界行为,[regression]表示历史故障回归。这样 CI 失败时,一眼就能判断严重等级:[core]挂了说明行为契约被破坏了,必须立即处理;[edge]挂了可能是边界策略需要调整。分类还能辅助“哪些用例能删除”的决策——[regression]用例原则上永不删除,因为它代表的是线上真实故障的防线。
6. 常见问题与排查技巧实录
6.1 问题速查表
真刀真枪跑起来,你会遇到不少看着简单、实际坑人的问题。我整理了一张速查表,都是 Skill 测试落地过程中的高发问题,每条后面附了排查思路和解决方案。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 测试时而通过时而失败 | 依赖了真实 LLM API,网络或模型波动影响 | 使用 fake LLM,把外部依赖隔离在测试之外 |
JSON.parse抛错 | 模型输出带 markdown 代码块或多余文本 | 实现 JSON 提取与规范化函数,并测试覆盖 |
| 用例执行顺序影响结果 | 测试共享了全局可变状态 | 每个用例独立构造数据,不要在模块级缓存结果 |
| 覆盖率数字一直上不去 | 测试集中在 happy path,分支没覆盖 | 引入 mutation testing 或检查分支覆盖,补边界用例 |
| 一个用例失败导致一片用例失败 | beforeEach 里状态污染 | 检查公共 fixture 是否被修改,建议用工厂函数替代共享对象 |
| Prompt 改动后全量测试惊喜不断 | Prompt 输出结构与预期不一致 | 在 Prompt 输出后加 schema 校验层,保证下游逻辑不受影响 |
| 测试跑得越来越慢 | 用例里堆积了大量重量级依赖初始化 | 把纯逻辑抽离成纯函数,缩小 mock 范围,用轻量 fixture |
| 新功能写了但忘了写测试 | TDD 节奏被打破,直接进入实现 | 回到红绿循环,先补失败用例再实现,或至少补回归用例 |
这张表不一定覆盖你遇到的所有问题,但它是排查方向的起点。Skill 测试的大多数诡异故障,追根溯源都绕不开“外部依赖不可控”和“测试间状态隔离不彻底”这两个基本盘。
6.2 踩坑心得:过度 Mock、铁铲断言、测试数据污染
最后分享几个我一个人扛过之后才真正理解的教训。
第一个教训是过度 mock。有一段时间我给 Skill 测试写了很多 mock,把内部依赖全都替换成理想返回值,测试全绿,但一上线就出问题。原因是 mock 得越深,测试距离真实行为越远,它验证的是“我 mock 的接口格式对不对”,而不是“Skill 在实际环境中能不能完成任务”。后来我调整策略:测试里 mock 只到边界(LLM 接口、外部工具),但内部完整链路——输入清洗、调用逻辑、解析、校验、降级——全部走真实代码。
第二个教训是铁铲断言。这个词是我自创的,指断言太贴近实现细节,把代码重构锁死。比如测试里断言了“内部调用了某函数两次”“某个中间变量应该等于什么”,一旦重构实现方式,测试就碎一地,但行为其实完全没变。正确的做法是断言输入输出的外部可观察行为,以用户视角验证结果,而不是以开发视角检查内部动作。
第三个教训是测试数据污染。有一回我在 fixture 里硬编码了一个真实用户导出的排期数据,里面带了一堆隐私字段,推上仓库后被安全评审拦截。现在已经学乖了:一切测试数据都走“合成数据”路线,用可复现的随机生成器,关键的边界场景手写造数,明确禁止从生产环境复制数据。测试是代码库的一部分,它的安全级别应该和生产代码一样高。
还有一个容易被忽略的细节:测试命名。很多人写it('test1')、it('should work')这种毫无信息量的命名,失败的时候你只看得到“should work failed”,完全想不起来这是哪个行为。好的命名应该是一句话的行为描述:it('输入为空字符串时返回空数组而不是抛异常')。这句话不仅是给机器跑的,更是给未来的维护者读的行为契约摘要。
Skill 的稳定性建设不是一锤子买卖,它更像是一个持续加固的过程。我从一开始的手工验证,到后来被线上问题追着跑,再到老老实实 TDD,最大的变化不是测试数量变多了,而是开发心态变了:每次改动前先想想“我要改变什么行为”,让测试来回答“我有没有改坏其他行为”。如果你也在维护 Skill 类模块,不妨从下一个需求开始,试着先写两条失败测试再动手实现。这个习惯用上两三个迭代之后,你对“稳定”的定义会发生根本性的改变。