关于 testing
testing 是用于规划和编写行为导向软件测试的技能,指导测试范围、面向变异的断言,以及通过与测试主张对应的公开接口收集证据。
主要功能
- 01测试行为而非实现细节
- 02选择相关的受影响测试范围
- 03针对可能的变异规划断言
- 04通过适当的公开接口测试
- 05避免仅为测试抽取辅助函数
适用场景
- 01为变更后的行为规划测试
- 02检查边界值和规范化情形
- 03通过真实接口测试用户旅程
- 04代码变更后选择合适的测试范围
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill testing安装后在 Claude Code 中输入/testing确认已加载。
详细介绍
测试模式
如需通过变异分析验证测试有效性,请加载 mutation-testing 技能。规划和编写测试时使用其变异规则,但将自动化变异测试框架推迟到阶段末的 PR 就绪门禁。如需根据 Dave Farley 的属性评估测试质量,请加载 test-design-reviewer 技能。
核心原则
测试行为,而非实现。 覆盖率有助于发现未执行的代码;阈值由仓库决定,百分比不能替代有意义的断言。
示例: 如果 processPayment() 是被测契约,就通过它来验证验证逻辑,而不是只测试内部验证器来提高覆盖率数字。
快速、相关的测试执行
遵循 tdd 技能中关于快速反馈、监视器生命周期、进程清理、单体仓库、最终检查门禁和仓库权威性的规范。关于 Vitest 的种子语义和可复用命令证明,请阅读其 resources/vitest-watch-feedback.md。
从 RED 阶段的精确选择器开始。对于 GREEN/REFACTOR,优先使用经行为验证、由仓库维护的监视器;其次使用由运行器/编排器推导出的受影响范围或受影响的一次性运行。由仓库维护的命令可以有意完整运行一次相关层级,以初始化运行时图。不要手动挑选更小的持续运行范围,不要接受零测试证据,不要留下未清理的监视器,也不要用监视模式替代完整的非监视模式 PR 门禁。
检查项目脚本和已安装运行器的帮助信息,不要猜测标志。以下是具有代表性的运行器特定选项:
优先顺序:
- 经测试、由仓库维护的监视/受影响测试命令。
- 仅当已证明已安装运行器及仓库配置中的监视行为时,使用原生的 VCS 感知选择。
- 使用从完整的、通过机械方式推导出的变更源文件列表进行原生依赖感知选择。
- 使用运行器/编排器选出的受影响包/项目,包括传递依赖项。
- 仅使用精确测试文件/名称来证明 RED 或进行调试,之后在 GREEN 阶段使用上述范围更广的受影响范围之一。
| 运行器 | GREEN/REFACTOR 受影响范围 | 仅用于 RED/调试的选择器 |
|---|---|---|
| Vitest | 优先使用由仓库维护的监视器。否则,仅在满足 tdd 技能中版本/配置证明条件时使用 vitest --changed <real-base> --watch;一次性运行时使用 --run。仅当变更源文件列表是通过机械方式推导得出时,才使用 vitest related <all-changed-source-paths> | vitest run <test-file> -t <name> 或精确文件监视 |
| Jest | jest --watch;当任务跨越多个提交时添加 --changedSince=<base>;一次性运行时使用 --onlyChanged、--changedSince=<base> 或 --findRelatedTests <all-changed-source-paths>,并禁用监视 | jest --runTestsByPath <test-file> -t <name> |
| pytest | 使用现有的仓库受影响测试/监视任务。若没有,则运行完整的所属包/测试套件以及已知的使用方;依赖影响不确定时扩大范围 | pytest path/to/test_file.py::test_name 或 pytest -k <name> <path> |
| Playwright Test | 优先使用仓库脚本,或使用 playwright test --only-changed[=<real-base>] 运行由运行器选出的受影响测试。可通过项目筛选器传入通过机械方式推导出的完整受影响项目集合。对于测试未导入的运行时应用代码、动态/非导入依赖项或共享全局输入,使用仓库映射出的受影响用户流程/项目集合;不确定时扩大范围 | 仅用于证明 RED 或修复已知故障的文件、--grep、--last-failed 或手动挑选的项目筛选器 |
| Go / Rust / JVM | 使用仓库现有的受影响测试/监视任务,或使用从依赖图推导出的包/模块集合,其中应包含传递使用方 | 仅为证明 RED 或调试而选择的精确包/类/测试名称选择器 |
如果运行器没有依赖图,则仅在 RED/调试时使用其精确选择器。对于 GREEN/REFACTOR,运行行为测试以及所有已知使用方,并运行完整的所属测试套件/项目集合;影响范围不确定时扩大范围。不要把“变更的测试文件”误认为“所有相关测试”。目标是受变更行为影响的测试,无论这些测试文件是否发生了变更。
Vitest related 默认允许空结果通过。必须至少有一个预期测试实际执行。Vitest --standalone 不能替代 TDD;请遵循规范的 TDD 政策。
感知变异的测试规划
规划或编写测试时,自动根据 mutation-testing 技能的 resources/mutator-rules.md 资源中的变异规则,扫描被测代码——包括新代码、改动过的代码,或你首次覆盖的现有代码。好的测试应当能在真实的变异体改变行为时失败。
在编写第一个断言之前,先根据要覆盖的代码列出以下两项。这些地方最容易出现规则已损坏、测试套件却仍然通过的情况:
- 每一处与常量的比较。 对每个与阈值比较的
>、>=、<、<=或===,指出确切阈值,并覆盖略低于阈值、恰好等于阈值和略高于阈值的情况。远高于阈值的值无法区分>和>=;只有恰好位于阈值的测试才能做到。 - 每一处用于规范化或转换输入、输出值的调用。 大小写折叠、去除空白、排序、去重、舍入、为缺失字段设默认值:每一项都是调用方可观察到的行为,而且只要删除相应调用,这些行为就会消失。对于每一项,选择一个在删除该调用后结果会改变的输入——例如大小写不同、而查找操作会折叠大小写的键;带有填充内容、而会被去除空白的输入;顺序混乱、而输出会排序的输入——并断言其结果。
代码中可见但未被测试明确固定下来的行为,就属于未测试,即使请求中没有提到它,而且对应代码行已经计入覆盖率。
如果被测代码包含条件判断、算术、相等性比较、布尔逻辑、数组/字符串操作、可选链或有实际意义的副作用,请加载该资源。在运行 Stryker 之前,用它识别可能存活的变异体。
扫描发现明显的测试缺口时,立即添加或强化行为测试。如果缺口需要产品或领域判断,请在可用时使用宿主的结构化提问功能;否则,用一条简洁的纯文本问题询问。提供具体选项,说明可能出现的变异体,并指出权衡。
提问提示示例:
The bulk path uses `items.length >= 100`, but current tests only cover `150`.
Should the exact `100` boundary use the bulk path?
- Yes: add a boundary test for `100`
- No: change/confirm the rule as `items.length > 100`
- Unspecified: document the behavior as intentionally not guaranteed
如果缺口显然只是缺少断言、边界、分支或副作用检查,就不要提问。直接修复。
通过被测对象的公共接口进行测试
绝不测试实现细节。通过被测对象的公共接口测试行为——并在测试声明的层级进行测试。
原因:
- 重构时测试仍然有效
- 测试记录预期行为
- 测试能发现真正的 Bug,而非实现变更
“公共 API”并不等于“HTTP API”:每个层级都有自己的公共接口,而由测试声明的内容决定应通过哪个接口来获取证据。
| 被测声明 | 提供证据的公共接口 |
|---|---|
| 领域/应用行为 | 导出的领域/应用操作 |
| HTTP API 契约 | HTTP 客户端和有文档说明的请求/响应契约 |
| 组件行为 | 渲染后的组件 DOM 及其公共属性/事件 |
| 浏览器/前端行为 | 导航、无障碍 UI、浏览器生命周期以及浏览器观察到的网络活动 |
| 用户旅程 | 跨越真实旅程的无障碍用户操作和用户可见结果 |
HTTP 端点可以是公共接口,但对于浏览器行为声明,它仍然可能是错误的接口:直接调用端点的“用户创建 X”测试只能证明 HTTP 契约,却绕过了 UI 处理程序、Cookie 策略、CSRF/Fetch Metadata 检查、重定向、加载/错误状态和渲染——而这些环节任一出现问题,测试仍会通过。测试名称、注释、CI 步骤标签、文档和 PR 文案必须说明实际能够证明的最具体范围。如需了解 E2E 证据模型、请求观察和直接传输审计,请加载 front-end-testing 技能的 resources/playwright-e2e.md。
示例
❌ 错误 — 测试实现:
// ❌ Testing HOW (implementation detail)
it('should call validateAmount', () => {
const spy = vi.spyOn(validator, 'validateAmount');
processPayment(payment);
expect(spy).toHaveBeenCalled(); // Tests HOW, not WHAT
});
// ❌ Testing private methods
it('should validate CVV format', () => {
const result = validator._validateCVV('123'); // Private method!
expect(result).toBe(true);
});
// ❌ Testing internal state
it('should set isValidated flag', () => {
processPayment(payment);
expect(processor.isValidated).toBe(true); // Internal state
});
✅ 正确 — 通过公共 API 测试行为:
it('should reject negative amounts', () => {
const payment = getMockPayment({ amountMinorUnits: -100, currency: 'GBP' });
const result = processPayment(payment);
expect(result).toEqual({ success: false, error: expect.stringContaining('Amount must be positive') });
});
it('should reject invalid CVV', () => {
const payment = getMockPayment({ cvv: '12' }); // Only 2 digits
const result = processPayment(payment);
expect(result).toEqual({ success: false, error: expect.stringContaining('Invalid CVV') });
});
it('should process valid payments', () => {
const payment = getMockPayment({ amountMinorUnits: 10_000, currency: 'GBP', cvv: '123' });
const result = processPayment(payment);
expect(result).toEqual({ success: true, data: { transactionId: expect.any(String) } });
});
断言整个 Result 值,而不是先单独执行 expect(result.success),再访问 result.error/result.data。expect(...).toBe(false) 不会缩小判别联合类型的范围,因此在严格 TypeScript 模式下,访问未缩小范围的 Result 成员会失败;而且,对整个值进行断言也更能抵御变异体。
通过行为触达代码
验证代码应通过测试其所保护行为的测试来触达:
// Tests covering validation WITHOUT testing validator directly
describe('processPayment', () => {
it('should reject negative amounts', () => {
const payment = getMockPayment({ amountMinorUnits: -100, currency: 'GBP' });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
it('should reject amounts over 10000', () => {
const payment = getMockPayment({ amountMinorUnits: 1_500_000, currency: 'GBP' });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
it('should reject invalid CVV', () => {
const payment = getMockPayment({ cvv: '12' });
const result = processPayment(payment);
expect(result.success).toBe(false);
});
it('should process valid payments', () => {
const payment = getMockPayment({ amountMinorUnits: 10_000, currency: 'GBP', cvv: '123' });
const result = processPayment(payment);
expect(result.success).toBe(true);
});
});
// ✅ Result: validation branches are exercised through the public behavior
关键要点: 覆盖率下降时,应问 “我漏测了什么业务行为?”,而不是问“我漏了哪一行?”
不要只为匹配测试而提取代码
不要仅仅为了给函数配上对应的单元测试,就把它提取到单独的文件中。只有当提取能形成连贯的契约或接缝、提高可读性、体现共享的知识(参见 refactoring 技能),或分离职责时,才进行提取。如果测试某种行为的困难暴露了真实的依赖接缝,请使用 finding-seams,不要用针对实现细节的测试掩盖问题。
内联代码通常可以通过其使用方的行为测试来测试。如果这会导致失败范围过大,或无法控制某项依赖,就应将这种困难视为设计方面的证据,并引入连贯的接缝,而不是创建仅为匹配测试文件而设的辅助函数。
反模式是让提取出的辅助函数与测试文件形成 1:1 映射(参见下文“不要 1:1 映射”)。提取出的辅助函数是其使用方的实现细节。应测试使用方的行为。
❌ 错误 — 提取只使用一次的辅助函数,并为其创建单独的测试文件:
// prepare-participant-data.ts (new file, one caller)
export const prepareParticipantData = (items: Item[]) => ({
yourClaims: items.filter(i => i.isClaimed && i.isClaimedByCurrentUser),
available: items.filter(i => !i.isClaimedByCurrentUser),
});
// prepare-participant-data.test.ts (tests the helper directly)
it('filters claims', () => { ... });
✅ 正确 — 在使用该函数的代码中内联,并通过其行为进行测试:
// load-participant-view.ts
export const loadParticipantView = async (db: Db, eventId: EventId, userId: UserId) => {
const items = await getItems(db, eventId, userId);
const yourClaims = items.filter(i => i.isClaimed && i.isClaimedByCurrentUser);
const available = items.filter(i => !i.isClaimedByCurrentUser);
return { yourClaims, available };
};
// The behavioral test for loadParticipantView covers the filtering:
it('returns claimed gifts in yourClaims and unclaimed in available', async () => {
const result = await loadParticipantView(db, eventId, userId);
expect(result.yourClaims).toHaveLength(1);
expect(result.available).toHaveLength(2);
});
适合提取的情况: 如果筛选操作有连贯且可复用的含义,或有多个使用方依赖它,就应将其提取出来。在真正负责该行为的最窄公共边界上进行测试;集成测试可能仍然需要使用方级别的测试。
测试工厂模式
当测试数据重复、嵌套,或通过命名的 fixture 构建器表达更清晰时,请使用带可选覆盖项的工厂函数。真正只使用一次的标量值可直接内联。触发条件是机械性的,而非取决于个人偏好:如果文件中有多个测试构造相同形状的对象——包括在 it.each 的行中展开相同形状的对象,或从另一个对象展开后修改一个字段——就应为该形状创建工厂,并将不同的字段设为覆盖项。
核心原则
- 返回符合场景要求的对象,并明确标注有意设置的无效值
- 语言和模型适用时,使用带类型的覆盖项
- 编写工厂函数体之前,检查被测模块为该类型导出了什么。如果它导出了对象的 schema,就通过调用该 schema 的 parse 来构建对象。返回带类型注解的字面量工厂,与重新定义 schema 一样,会漏掉生产契约——编译器会根据类型检查注解,却没有任何机制会根据代码实际验证的边界检查 fixture。只有在不存在 schema 时才返回普通字面量;绝不要为了有东西可供 parse 而凭空创建 schema
- 优先为每个测试创建独立状态:每个测试都通过调用工厂函数构建自己的数据。如果设置相互隔离且清理可靠,使用生命周期钩子也可以;但为每个测试重新构建共享的
let绑定数据并不可取——这只是披着钩子外衣的下文所述反模式。向已有测试数据存放在共享let中,或通过beforeEach重建数据的文件添加测试时,应在同一次更改中用工厂函数替换原有设置,而不是让新测试继续展开这些数据。一旦着手修改某个测试文件,就要负责该文件的整体状态管理规范,而不只是你新增的代码行
基本模式
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
...overrides,
});
};
// Usage
it('creates user with custom email', () => {
const user = getMockUser({ email: '[email protected]' });
const result = createUser(user);
expect(result.success).toBe(true);
});
完整工厂示例
import { UserSchema } from '@/schemas'; // Import real schema
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: new Date('2024-01-01'),
...overrides,
});
};
为什么要复用现有 schema? 这样可以让符合契约的测试数据与生产边界保持一致,并在 schema 变更时发现问题,而无需重新定义契约。纯内部值和有意构造的无效测试数据可能不需要解析 schema。
提示: 对于只涉及部分字段的工厂,可在覆盖参数中使用 Partial<Pick<T, 'field1' | 'field2'>>,以限制调用方可自定义的内容(单独使用 Pick 会让选取的每个字段都成为必填项,因此只覆盖一个字段将无法通过编译)。
工厂组合
对于嵌套对象,可组合使用工厂:
const getMockItem = (overrides?: Partial<Item>): Item => {
return ItemSchema.parse({
id: 'item-1',
name: 'Test Item',
weightGrams: 100,
...overrides,
});
};
const getMockOrder = (overrides?: Partial<Order>): Order => {
return OrderSchema.parse({
id: 'order-1',
items: [getMockItem()], // ✅ Compose factories
customer: getMockCustomer(), // ✅ Compose factories
payment: getMockPayment(), // ✅ Compose factories
...overrides,
});
};
// Usage - override nested objects
it('calculates total with multiple items', () => {
const order = getMockOrder({
items: [
getMockItem({ weightGrams: 100 }),
getMockItem({ weightGrams: 200 }),
],
});
expect(calculateTotalWeightGrams(order)).toBe(300);
});
反模式
❌ 错误:整个测试套件共用一个可变对象
const user: User = { id: 'user-123', name: 'Test User', ... };
it('test 1', () => {
user.name = 'Modified User';
});
it('test 2', () => {
expect(user.name).toBe('Test User'); // Order-dependent failure
});
✅ 正确:每个测试都使用全新状态
it('test 1', () => {
const user = getMockUser({ name: 'Modified User' }); // Fresh state
// ...
});
it('test 2', () => {
const user = getMockUser(); // Fresh state, not affected by test 1
expect(user.name).toBe('Test User'); // ✅ Passes
});
❌ 错误:对象不完整
const getMockUser = () => ({
id: 'user-123', // Missing name, email, role!
});
✅ 正确:对象完整
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
role: 'user',
...overrides, // All required fields present
});
};
❌ 错误:在测试中重新定义 schema
// ❌ Schema already defined in src/schemas/user.ts!
const UserSchema = z.object({ ... });
const getMockUser = () => UserSchema.parse({ ... });
✅ 正确:导入真实 schema
import { UserSchema } from '@/schemas/user';
const getMockUser = (overrides?: Partial<User>): User => {
return UserSchema.parse({
id: 'user-123',
name: 'Test User',
email: '[email protected]',
...overrides,
});
};
识别覆盖率作秀
留意那些只执行代码、却无法证明行为的模式:
模式 1:模拟被测函数
❌ 错误 - 虽然覆盖率达到 100%,但没有测试任何内容:
it('calls validator', () => {
const spy = vi.spyOn(validator, 'validate');
validator.validate(payment);
expect(spy).toHaveBeenCalled(); // Meaningless assertion
});
✅ 正确 - 测试实际行为:
it('should reject invalid payment', () => {
const payment = getMockPayment({ amountMinorUnits: -100, currency: 'GBP' });
const result = validate(payment);
expect(result).toEqual({ success: false, error: expect.stringContaining('Amount must be positive') });
});
模式 2:只测试函数是否被调用
❌ 错误 - 没有验证行为:
it('processes payment', () => {
const spy = vi.spyOn(processor, 'process');
handlePayment(payment);
expect(spy).toHaveBeenCalledWith(payment); // So what?
});
✅ 正确 - 验证结果:
it('should process payment and return transaction ID', () => {
const payment = getMockPayment();
const result = handlePayment(payment);
expect(result).toEqual({ success: true, data: { transactionId: expect.any(String) } });
});
例外: 对通过公共 API 传入的回调进行断言属于行为测试,而非覆盖率作秀。当组件或函数接受回调(例如 onSubmit 属性)时,该回调契约就是输出——expect(handleSubmit).toHaveBeenCalledWith(...) 验证的是可观察行为。此模式禁止的是对调用方未提供的内部协作者进行监视。
模式 3:测试简单的 getter/setter
❌ 错误 - 测试的是实现,而非行为:
it('sets retry limit', () => {
worker.setRetryLimit(3);
expect(worker.getRetryLimit()).toBe(3); // Trivial
});
✅ 正确 - 测试有意义的行为:
it('should calculate shipment weight', () => {
const order = createOrder({ items: [item1, item2] });
expect(order.calculateWeightGrams()).toBe(230);
});
模式 4:行覆盖率 100%,分支覆盖率 0%
❌ 错误 - 遗漏了边界情况:
it('validates payment', () => {
const result = validate(getMockPayment());
expect(result.success).toBe(true); // Only happy path!
});
// Missing: negative amounts, invalid CVV, missing fields, etc.
✅ 正确 - 测试所有分支:
describe('validate payment', () => {
it('should reject negative amounts', () => {
const payment = getMockPayment({ amountMinorUnits: -100, currency: 'GBP' });
expect(validate(payment).success).toBe(false);
});
it('should reject amounts over limit', () => {
const payment = getMockPayment({ amountMinorUnits: 1_500_000, currency: 'GBP' });
expect(validate(payment).success).toBe(false);
});
it('should reject invalid CVV', () => {
const payment = getMockPayment({ cvv: '12' });
expect(validate(payment).success).toBe(false);
});
it('should accept valid payments', () => {
const payment = getMockPayment();
expect(validate(payment).success).toBe(true);
});
});
测试与实现之间没有自动的一对一映射
不要习惯性地为每个实现文件都创建对应的测试文件。围绕稳定的行为或契约组织测试;如果某个文件本身就是被测的公共单元,那么一对一对应也可以。
❌ 错误:
src/
payment-validator.ts
payment-processor.ts
payment-formatter.ts
tests/
payment-validator.test.ts ← 1:1 mapping
payment-processor.test.ts ← 1:1 mapping
payment-formatter.test.ts ← 1:1 mapping
✅ 正确:
src/
payment-validator.ts
payment-processor.ts
payment-formatter.ts
tests/
process-payment.test.ts ← Tests behavior, not implementation files
原因: 重构实现细节时无需改动测试。无论代码在内部如何组织,测试都应验证行为保持正确。
总结清单
编写测试时,请确认:
- 在声明所针对的层,通过被测对象的公共接口测试行为(而非实现细节,也不以更低层的测试替代)
- 不模拟被测函数
- 不测试私有方法或内部状态
- 工厂函数返回符合场景要求的对象,并标明有意设置的无效状态
- 适当复用现有的生产环境模式,而不是在测试中重新定义
- 覆盖值对于所用语言和模型是类型安全的
- 测试状态彼此隔离;生命周期设置有可靠的清理机制
- 被测代码中的每个阈值都有一个恰好位于该阈值的测试,而不只是远超阈值的测试
- 每个规范化或转换调用(大小写折叠、去除空白、排序、舍入、设置默认值)都有一个测试:如果删除该调用,测试结果就会改变
- 覆盖边缘情况(而不只是正常路径)
- 即使实现经过重构,测试仍能通过
- 默认情况下,测试组织应遵循稳定的行为或契约,而不是实现结构
- TDD 内循环使用聚焦的监视模式或相关/受影响范围选择器,而不是反复运行完整测试套件
- GREEN/REFACTOR 使用由运行器、工作区编排器或仓库映射推导出的完整受影响范围;如果没有可靠的依赖图,则使用文档规定的所属测试套件加已知使用方回退方案,并在不确定时扩大范围,绝不手动挑选测试文件
- 使用监视器时,新测试应加入监视范围;否则,应在创建测试后重新运行受影响范围的一次性测试。每项声称的结果至少执行了一个预期测试,且未使用
--passWithNoTests - 监视器及其子进程已停止;针对最终代码树的完整测试套件非监视模式运行结果仍为最新,并且变异测试或其他证据符合目标仓库的策略