mcpField
T
testing

testing

github.com/citypaul测试与质量759216 次安装4 周前更新
代码仓库

关于 testing

testing 是用于规划和编写行为导向软件测试的技能,指导测试范围、面向变异的断言,以及通过与测试主张对应的公开接口收集证据。

主要功能

  1. 01测试行为而非实现细节
  2. 02选择相关的受影响测试范围
  3. 03针对可能的变异规划断言
  4. 04通过适当的公开接口测试
  5. 05避免仅为测试抽取辅助函数

适用场景

  1. 01为变更后的行为规划测试
  2. 02检查边界值和规范化情形
  3. 03通过真实接口测试用户旅程
  4. 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 门禁。

检查项目脚本和已安装运行器的帮助信息,不要猜测标志。以下是具有代表性的运行器特定选项:

优先顺序:

  1. 经测试、由仓库维护的监视/受影响测试命令。
  2. 仅当已证明已安装运行器及仓库配置中的监视行为时,使用原生的 VCS 感知选择。
  3. 使用从完整的、通过机械方式推导出的变更源文件列表进行原生依赖感知选择。
  4. 使用运行器/编排器选出的受影响包/项目,包括传递依赖项。
  5. 仅使用精确测试文件/名称来证明 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> 或精确文件监视
Jestjest --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 资源中的变异规则,扫描被测代码——包括新代码、改动过的代码,或你首次覆盖的现有代码。好的测试应当能在真实的变异体改变行为时失败。

在编写第一个断言之前,先根据要覆盖的代码列出以下两项。这些地方最容易出现规则已损坏、测试套件却仍然通过的情况:

  1. 每一处与常量的比较。 对每个与阈值比较的 >、>=、<、<= 或 ===,指出确切阈值,并覆盖略低于阈值、恰好等于阈值和略高于阈值的情况。远高于阈值的值无法区分 > 和 >=;只有恰好位于阈值的测试才能做到。
  2. 每一处用于规范化或转换输入、输出值的调用。 大小写折叠、去除空白、排序、去重、舍入、为缺失字段设默认值:每一项都是调用方可观察到的行为,而且只要删除相应调用,这些行为就会消失。对于每一项,选择一个在删除该调用后结果会改变的输入——例如大小写不同、而查找操作会折叠大小写的键;带有填充内容、而会被去除空白的输入;顺序混乱、而输出会排序的输入——并断言其结果。

代码中可见但未被测试明确固定下来的行为,就属于未测试,即使请求中没有提到它,而且对应代码行已经计入覆盖率。

如果被测代码包含条件判断、算术、相等性比较、布尔逻辑、数组/字符串操作、可选链或有实际意义的副作用,请加载该资源。在运行 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 的行中展开相同形状的对象,或从另一个对象展开后修改一个字段——就应为该形状创建工厂,并将不同的字段设为覆盖项。

核心原则
  1. 返回符合场景要求的对象,并明确标注有意设置的无效值
  2. 语言和模型适用时,使用带类型的覆盖项
  3. 编写工厂函数体之前,检查被测模块为该类型导出了什么。如果它导出了对象的 schema,就通过调用该 schema 的 parse 来构建对象。返回带类型注解的字面量工厂,与重新定义 schema 一样,会漏掉生产契约——编译器会根据类型检查注解,却没有任何机制会根据代码实际验证的边界检查 fixture。只有在不存在 schema 时才返回普通字面量;绝不要为了有东西可供 parse 而凭空创建 schema
  4. 优先为每个测试创建独立状态:每个测试都通过调用工厂函数构建自己的数据。如果设置相互隔离且清理可靠,使用生命周期钩子也可以;但为每个测试重新构建共享的 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
  • 监视器及其子进程已停止;针对最终代码树的完整测试套件非监视模式运行结果仍为最新,并且变异测试或其他证据符合目标仓库的策略

评论

支持 Markdown