首页
/ agent-skills 测试模式参考详解:JS/TS 测试结构、命名、Mock 纪律与反模式实战

agent-skills 测试模式参考详解:JS/TS 测试结构、命名、Mock 纪律与反模式实战

2026-09-05 17:29:44作者:宣聪麟

references/testing-patterns.md 是 agent-skills 仓库中面向 JavaScript/TypeScript 生态的测试模式快速参考(Quick Reference),它把 test-driven-development 技能中定义的四条通用原则——Arrange-Act-Assert 结构、描述性命名、Mock 纪律和反模式清单——落到了 Jest、React Testing Library、Supertest 与 Playwright 的具体语法上。读完本篇,你不仅能直接复制其中的断言、Mock 与组件/API/E2E 测试写法,还能对照仓库中的真实 TDD 评估工程(evals/fixtures/test-driven-development/)与评估用例(evals/cases/test-driven-development.json),理解这套模式在"AI Agent 驱动开发"场景下的完整落地方式。

一、定位:它是 TDD 技能的"语法层"参考

在 agent-skills 中,每个技能(skill)负责"怎么做"的通用原则,而 references/ 目录下的参考清单(Reference Checklists)则是技能按需拉取的速查材料。testing-patterns.mdREADME.md 的参考清单表中的定位是:

Reference Covers
testing-patterns.md Test structure, naming, mocking, React/API/E2E examples, anti-patterns (JavaScript/TypeScript)

skills/test-driven-development/SKILL.md 的 "See Also" 一节(约 L361)明确指向该文件,并给出了一段关键的适用边界声明:

原则(Arrange-Act-Assert、命名、mock 纪律、反模式)适用于任何生态;这里展示的语法与工具是 JS/TS 专属的。在其他技术栈中,遵循相同的原则,使用该仓库自己的测试框架与命令即可。

docs/getting-started.md 中的技能-参考映射表也把 testing-patterns.md 绑定到 test-driven-development 技能。因此理解这篇参考的正确姿势是:原则层在 TDD 技能里,语法层在本参考里。下面按原文档的章节顺序完整展开每个模式,并结合仓库内可运行的测试工程补充佐证。

二、测试结构:Arrange-Act-Assert

每个测试体内部按"三幕"组织:Arrange(准备测试数据与前置条件)→ Act(执行被测动作)→ Assert(验证结果):

it('describes expected behavior', () => {
  // Arrange: Set up test data and preconditions
  const input = { title: 'Test Task', priority: 'high' };

  // Act: Perform the action being tested
  const result = createTask(input);

  // Assert: Verify the outcome
  expect(result.title).toBe('Test Task');
  expect(result.priority).toBe('high');
  expect(result.status).toBe('pending');
});

这一模式与 skills/test-driven-development/SKILL.md 中 "Use the Arrange-Act-Assert Pattern" 一节的示例是同一来源,只是本参考更强调注释标记三个阶段的边界,便于在长测试中快速定位"我到底在验证哪一步"。

三、命名约定:测试名即规格说明

命名模式为 [单元] [预期行为] [条件]

// Pattern: [unit] [expected behavior] [condition]
describe('TaskService.createTask', () => {
  it('creates a task with default pending status', () => {});
  it('throws ValidationError when title is empty', () => {});
  it('trims whitespace from title', () => {});
  it('generates a unique ID for each task', () => {});
});

注意两个要点:

  • describe 块命名精确到 模块.方法TaskService.createTask),而不是笼统的模块名——TDD 技能的 Red Flags 清单中明确把"测试名不描述预期行为"列为红旗项;
  • it 描述句是"行为 + 条件"的陈述句,读者不读实现也能知道该测试验证什么。agents/test-engineer.md 中"6. Every test name should read like a specification(每个测试名都应当读起来像一条规格)"与此完全一致。

四、常用断言速查(Jest Matchers)

原文档给出了一组按类别分组的断言速查,完整继承如下,并补充了各断言的语义要点:

// Equality
expect(result).toBe(expected);           // 严格相等 (===)
expect(result).toEqual(expected);        // 深度相等(对象/数组)
expect(result).toStrictEqual(expected);  // 深度相等 + 类型匹配

// Truthiness
expect(result).toBeTruthy();
expect(result).toBeFalsy();
expect(result).toBeNull();
expect(result).toBeDefined();
expect(result).toBeUndefined();

// Numbers
expect(result).toBeGreaterThan(5);
expect(result).toBeLessThanOrEqual(10);
expect(result).toBeCloseTo(0.3, 5);      // 浮点数(精度到小数点后 5 位)

// Strings
expect(result).toMatch(/pattern/);
expect(result).toContain('substring');

// Arrays / Objects
expect(array).toContain(item);
expect(array).toHaveLength(3);
expect(object).toHaveProperty('key', 'value');

// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow(ValidationError);
expect(() => fn()).toThrow('specific message');

// Async
await expect(asyncFn()).resolves.toBe(value);
await expect(asyncFn()).rejects.toThrow(Error);

三个最容易被用错的点:

  1. toBe=== 比较引用,只适合原始值;对象/数组断言应使用 toEqual(深度相等),toStrictEqual 还会校验 undefined 属性与原型链,适合对类型敏感的场景;
  2. toBeCloseTo(0.3, 5) 的第二个参数是"允许的位数差",数值越小精度要求越宽松——涉及浮点比较时必须用它而不是 toBe
  3. 异步断言必须 await。原参考反模式表的最后一行专门指出"no async error handling / Swallowed errors, false passes(吞掉的错误导致假通过)",因此异步场景下 resolves/rejects 两个断言不可省略 await

五、Mock 模式:只在边界处 Mock

5.1 Mock 函数

const mockFn = jest.fn();
mockFn.mockReturnValue(42);
mockFn.mockResolvedValue({ data: 'test' });
mockFn.mockImplementation((x) => x * 2);

expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(3);
  • mockReturnValue 用于同步固定返回;mockResolvedValue 用于把异步函数"钉"成返回固定数据的 Promise;mockImplementation 则给出带逻辑的替身;
  • 交互断言(toHaveBeenCalled 系列)只应在验证"外部副作用确实发生了"时使用——这与 TDD 技能"Test State, Not Interactions(测状态,不测交互)"的原则相衔接:默认断言结果状态,而不是内部调用序列。

5.2 Mock 模块

// Mock an entire module
jest.mock('./database', () => ({
  query: jest.fn().mockResolvedValue([{ id: 1, title: 'Test' }]),
}));

// Mock specific exports
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  generateId: jest.fn().mockReturnValue('test-id'),
}));

第二种写法是实用技巧:用 jest.requireActual 先展开真实模块,再只覆盖需要隔离的导出(此处仅替换 generateId),其余导出保持真实行为——这比整模块 Mock 更接近"最小替身"原则。

5.3 边界 Mock 清单:Mock 什么、不 Mock 什么

原文档给出了一张决策对照表,这是 Mock 纪律的核心:

Mock these:                    Don't mock these:
├── Database calls             ├── Internal utility functions
├── HTTP requests              ├── Business logic
├── File system operations     ├── Data transformations
├── External API calls         ├── Validation functions
└── Time/Date (when needed)    └── Pure functions

判断依据可以从 skills/test-driven-development/SKILL.md 的 "Prefer Real Implementations Over Mocks" 一节补全为四级优先序:

1. Real implementation  → 置信度最高,能抓到真实 bug
2. Fake                 → 依赖的内存版实现(如内存数据库)
3. Stub                 → 只返回固定数据、无行为
4. Mock (interaction)   → 验证方法调用—— sparingly(慎用)

只有当真实实现"太慢、非确定性、或存在不可控副作用(外部 API、发邮件)"时才允许 Mock。agents/test-engineer.md 的规则 5 同样写道:"Mock at system boundaries (database, network), not between internal functions(在系统边界处 Mock,而不是在内部函数之间)"。

六、React / 组件测试(React Testing Library)

import { render, screen, fireEvent, waitFor } from '@testing-library/react';

describe('TaskForm', () => {
  it('submits the form with entered data', async () => {
    const onSubmit = jest.fn();
    render(<TaskForm onSubmit={onSubmit} />);

    // Find elements by accessible role/label (not test IDs)
    await screen.findByRole('textbox', { name: /title/i });
    fireEvent.change(screen.getByRole('textbox', { name: /title/i }), {
      target: { value: 'New Task' },
    });
    fireEvent.click(screen.getByRole('button', { name: /create/i }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({ title: 'New Task' });
    });
  });

  it('shows validation error for empty title', async () => {
    render(<TaskForm onSubmit={jest.fn()} />);

    fireEvent.click(screen.getByRole('button', { name: /create/i }));

    expect(await screen.findByText(/title is required/i)).toBeInTheDocument();
  });
});

这段示例里有三条值得刻进习惯的规则:

  1. 按可访问性角色定位元素getByRole('textbox', { name: /title/i })),注释明确写着 "not test IDs"。用 data-testidclass 定位属于"测实现细节",重构即碎;按角色/标签定位则等价于以"用户视角"驱动组件,且天然覆盖可访问性回归;
  2. 对 props 回调做最小 MockonSubmit = jest.fn())——组件测试的边界就是 props,而不是组件内部状态机;
  3. 异步用 waitFor / findBy* 处理渲染时序,避免轮询式 sleep。这与第四节的异步断言纪律一脉相承。

第二个用例还演示了"负路径测试"的写法:触发空标题提交,断言错误文案出现——对应 agents/test-engineer.md 中要求每类函数覆盖的"Happy path / Empty input / Boundary values / Error paths / Concurrency"五类场景中的后几类。

七、API / 集成测试(Supertest)

import request from 'supertest';
import { app } from '../src/app';

describe('POST /api/tasks', () => {
  it('creates a task and returns 201', async () => {
    const response = await request(app)
      .post('/api/tasks')
      .send({ title: 'Test Task' })
      .set('Authorization', `Bearer ${testToken}`)
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(String),
      title: 'Test Task',
      status: 'pending',
    });
  });

  it('returns 422 for invalid input', async () => {
    const response = await request(app)
      .post('/api/tasks')
      .send({ title: '' })
      .set('Authorization', `Bearer ${testToken}`)
      .expect(422);

    expect(response.body.error.code).toBe('VALIDATION_ERROR');
  });

  it('returns 401 without authentication', async () => {
    await request(app)
      .post('/api/tasks')
      .send({ title: 'Test' })
      .expect(401);
  });
});

要点解读:

  • request(app) 直接以应用实例发起请求,走的是真实中间件/路由/序列化管线,属于 TDD 技能资源模型中的 Medium 级测试(localhost、秒级、可带测试数据库),无需起容器;
  • 三个用例恰好覆盖一个接口的三条契约:成功路径(201 + 响应体结构)、校验失败(422 + 错误码 VALIDATION_ERROR)、未认证(401)。注意 201 用例中对动态 id 使用 expect.any(String)——只断言类型不断言具体值,这正是"断言结果状态、但不断言无法稳定承诺的实现细节"的正确取舍;
  • toMatchObject 做部分结构匹配,允许接口新增字段而不必改测试,属于"宽松但不越界"的断言风格。

八、E2E 测试(Playwright)

import { test, expect } from '@playwright/test';

test('user can create and complete a task', async ({ page }) => {
  // Navigate and authenticate
  await page.goto('/');
  await page.getByRole('textbox', { name: /email/i }).fill('test@example.com');
  await page.getByLabel(/password/i).fill('testpass123');
  await page.getByRole('button', { name: /log in/i }).click();

  // Create a task
  await page.getByRole('button', { name: /new task/i }).click();
  await page.getByRole('textbox', { name: /title/i }).fill('Buy groceries');
  await page.getByRole('button', { name: /create/i }).click();

  // Verify task appears
  const task = page.getByRole('listitem', { name: /buy groceries/i });
  await expect(task).toBeVisible();

  // Complete the task
  await task.getByRole('checkbox', { name: /complete buy groceries/i }).check();
  await expect(task).toHaveCSS('text-decoration-line', 'line-through');
});

按 TDD 技能的测试金字塔,E2E 只占约 5%,应限制在关键用户路径上;这个用例就是典型的"登录 → 创建 → 完成"关键链路。写法上延续了前几节的纪律:

  • 全程用 getByRole / getByLabel 语义定位器,没有任何 CSS 选择器或 data-testid
  • 断言混合了可见性(toBeVisible)与视觉状态(toHaveCSS('text-decoration-line', 'line-through'))——完成态用"删除线"这类用户可感知的外观变化来验证,而不是去查 DOM 里的某个内部类名;
  • 注意与组件测试的分工:表单交互逻辑已在第六节的组件测试中覆盖,E2E 只负责把整条链路(含认证、路由、真实浏览器渲染)串起来验证一次,不重复单元测试已覆盖的细节。

九、测试反模式清单(完整表格)

原文档以一张三列表格收尾,逐条给出"反模式 → 危害 → 更好的做法",完整继承如下:

反模式 问题 更好的做法
Testing implementation details Breaks on refactor Test inputs/outputs
Snapshot everything No one reviews snapshot diffs Assert specific values
Shared mutable state Tests pollute each other Setup/teardown per test
Testing third-party code Wastes time, not your bug Mock the boundary
Skipping tests to pass CI Hides real bugs Fix or delete the test
Using test.skip permanently Dead code Remove or fix it
Overly broad assertions Doesn't catch regressions Be specific
No async error handling Swallowed errors, false passes Always await async tests

这八条与 skills/test-driven-development/SKILL.md 的 "Test Anti-Patterns to Avoid" 表互为补充:技能侧重"实现细节测试、Flaky 测试、无隔离、过度 Mock"等流程性反模式,本参考则补充了 test.skip 滥用、CI 跳过、异步未 await 这类在具体语法层最容易踩的坑。两份清单合起来即是一份可执行的测试审查核对表。

十、仓库佐证:在 TDD 评估工程里验证这套模式

agent-skills 并非只谈原则——evals/fixtures/ 目录内置了一批可运行的"沙箱项目",其中 evals/fixtures/test-driven-development/ 最能体现上述模式如何被真实使用。

10.1 一个带真实 bug 的最小 Node 工程

该工程是一个名为 split-payment 的分账工具,package.json 声明的测试命令是 Node 内置 runner("test": "node --test"),而不是 Jest——这恰好印证了本参考开篇的声明:原则通用,工具栈因仓库而异,先发现本仓库的测试命令再动手(TDD 技能 "Discover the Stack First" 一节的要求)。

function splitCents(totalCents, n) {
  const share = Math.floor(totalCents / n);
  return Array.from({ length: n }, () => share);
}

Math.floor 直接丢弃余数,余数没有分给任何一份。

  • 既有测试 test/split.test.js 只覆盖了整除与单参与者两种"整除型"场景(用 node:test + assert/strictdeepEqual,断言的是输出值而非内部调用——符合"测状态不测交互");

  • BUG.md 记录了财务对账工单 FIN-482:splitCents(10000, 3) 期望 [3334, 3333, 3333],实际得到 [3333, 3333, 3333](合计 9999,丢一分钱);

  • README.md 定义了两条必须对所有输入成立的不变量:Exactness(各份之和恰等于总额)与 Fairness(任意两份相差不超过 1 分,余数按顺序每份 1 分),并给出标准答案 splitCents(100, 7) 应为 [15, 15, 14, 14, 14, 14, 14]

10.2 评估用例如何"验收"这些原则

evals/cases/test-driven-development.json 为这个工程定义了三个评估用例,其 expectations 字段几乎逐条对应本文前面各节的模式要求:

  1. Prove-It 时序A test reproducing the lost-cent case from BUG.md is added and shown failing before src/split.js is modified —— 修复前必须先有"可复现且失败"的测试。这正是 TDD 技能 Prove-It Pattern 流程图(复现测试 → 确认失败 → 实现修复 → 确认通过 → 跑全量套件)的可执行版本;
  2. 不变量要有自己的测试The fairness invariant from the README has its own test case ... on an input with remainder of at least 2 (such as splitCents(100, 7)) —— 即第四节强调的"断言具体值、覆盖边界输入":splitCents(100, 7) 是检验"余数均分给最早的份额"还是"余数全堆给一份"的判别输入;
  3. 全量套件用本仓库命令跑The full suite is run with the repository's own command after the fix —— 用例 3 甚至专门考察跨生态场景(Python 工程里必须用 python3 -m unittest 而不是 npm test)。

另一个佐证在 evals/fixtures/debugging-and-error-recovery/pagination.test.js:同样是 node:test + 描述性测试名('returns the second page for a one-based page number')+ 对返回值做 deepEqual 的结构断言——与第三节命名约定和第五节"状态断言优先"完全同构。

10.3 使用入口:/test 命令与 test-engineer 角色

在仓库的消费端,这套参考通过两条路径被 Agent 实际调用:

  • 斜杠命令 commands/test.toml/test 会触发 TDD 工作流——新功能走"写失败测试 → 实现 → 重构",修 bug 走 Prove-It 五步(复现测试 → 确认失败 → 修复 → 确认通过 → 全量回归);
  • 测试工程角色 agents/test-engineer.md:QA 人设,规则集与本参考一致(行为优先、每测一概念、测试独立、慎用快照、只 Mock 边界、测试名如规格),并规定其覆盖"快乐路径 / 空输入 / 边界值 / 错误路径 / 并发"五类场景。

十一、落地小结:把这份参考当作审查清单

references/testing-patterns.md 与 TDD 技能放在一起使用,可以得到一套可直接执行的检查流程:

  1. 写之前:确认本仓库的测试命令与既有测试的目录/命名约定(不要假设 npm test);
  2. 写的时候:每个测试内部 Arrange-Act-Assert 三段分明;describe模块.方法it 写成"行为 + 条件";组件测试按角色定位元素,API 测试覆盖 201/422/401 三类契约,E2E 只留给关键用户链路;
  3. Mock 检查:对照"Mock these / Don't mock these"清单与四级替身优先序,凡能在边界内用真实实现或 Fake 解决的,不要动用交互 Mock;
  4. 提交前:过一遍九节那张八条反模式表,加上 TDD 技能的 Red Flags 清单(无对应测试的新代码、首跑即通过的"测试"、跳过测试让 CI 变绿等);
  5. 收尾:用仓库自己的命令跑完整测试套件,且只改代码后才需要重跑——干净跑完后重复执行同一命令并不增加置信度(TDD 技能 Verification 一节的备注)。

需要再次强调的适用边界:本参考中所有 jest.*@testing-library/reactsupertest@playwright/test 语法均面向 JavaScript/TypeScript 技术栈;若项目使用 Go、Rust、Python 或其他框架,应保留同样的结构、命名、Mock 纪律与反模式标准,替换为该生态的测试框架与命令。

登录后查看全文
热门项目推荐
相关项目推荐