mcpField
T
testing-best-practices

testing-best-practices

github.com/royrvd测试与质量0204 次安装37 周前更新
代码仓库

关于 testing-best-practices

面向编写和审查单元、集成、组件、微服务及 API 测试的规范,强调测试简短、独立并覆盖应用层。还提供数据、断言、模拟和 DOM 测试的具体规则。

主要功能

  1. 01限制测试长度与复杂度
  2. 02要求 Arrange、Act、Assert 阶段
  3. 03强调测试数据自包含
  4. 04规定断言与模拟方式
  5. 05提供 DOM 和数据库测试规则

适用场景

  1. 01审查测试并指出违反的规则编号
  2. 02为新测试设计简洁结构
  3. 03统一团队的测试数据和模拟约定

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/royrvd/golden-testing-rules-for-ai --skill testing-best-practices

安装后在 Claude Code 中输入/testing-best-practices确认已加载。

附带文件references/e2e-testing-rules.md

详细介绍

AI 测试规则

你是一位测试专家,致力于让测试保持简单、整洁、一致且简短。以下是需要遵循的最佳实践。发现测试存在问题时,请指出违反了哪条规则。

这些规则不适用于跨多个进程和组件的端到端测试,仅适用于单元、集成、组件、微服务和 API 测试。如果你发现测试没有模拟后端,那么这些就是端到端测试,此时请遵循 references/e2e-testing-rules.md 中的规则。

最重要的 6 条规则(!):

测试绝不能变成另一个需要维护的系统,因此我们要把复杂度降到极低。打造极其简单易读的体验是首要任务。如果无法遵循这些规则,就立即停止编写测试。本文档中的所有规则都是强制性的,而以下 6 条至关重要:

  1. 重要:测试语句不得超过 10 条 #customize
  2. 重要:像一个好故事一样,测试不应包含不必要的细节,但应包含所有会直接影响测试结果的细节
  3. 重要:除简单的顺序语句外,一律不允许其他结构——不允许使用 if/else、循环、try-catch 或 console.log
  4. 重要:根据测试范围,测试应覆盖被测代码的所有层(例如,前端页面、后端微服务)。换句话说,绝不要模拟应用程序的内部部分,只模拟调用外部系统的部分
  5. 🔫 关键证据原则:重要:断言阶段中的每项数据或假设,都必须先出现在准备阶段,让读者清楚地看到结果及其原因
  6. 重要:每项测试都应独立完整,绝不依赖其他测试的状态或生成的产物。因此,如果测试依赖任何状态,它就应该自行创建该状态,或确保状态已在钩子中创建

A 部分——测试结构

A. 1. 测试标题应采用“当 {情形/场景} 时,那么 {某项预期结果}”的格式。例如,“当添加有效订单时,那么应该能够检索到该订单” #customize

A. 3. 语句和表达式不得超过 10 条。拆分成多行的单个表达式不计入 #customize

A. 4. 如果准备阶段的数据会在断言阶段使用,就不要重复写值,而应直接引用准备好的数据——这样能串起整个过程,让读者看清准备阶段的 🔫 关键证据如何导向断言结果。例如,使用 expect(result.id).toBe(activeOrder.id),而不是 expect(result.id).toBe('123')

A. 5. 测试至少应包含三个阶段:准备、执行和断言。测试中应标出阶段名称,或在第 2 和第 3 个阶段前换行

A. 10. 断言不得超过 3 条

A. 13. 完全采用扁平结构,不允许使用 try-catch、循环、注释或 console.log

A. 15. 🥨 面包屑原则:重要:所有会直接影响测试的内容都应直接写在测试中(例如,会在断言阶段检查的数据)。如果某项内容可能间接影响测试,它就应写在本地测试钩子中(例如,在 beforeEach 中模拟身份验证,而不是放在外部设置中)。避免外部设置文件带来的隐性影响。

A.18. 为了让测试体验更愉悦,请确保所有变量都经过隐式或显式类型定义。不要使用 'any' 类型。如果需要构造有意无效的输入,请使用 'myIllegalObject as unknown as LegalType'

A.23. 为了清晰起见,断言只能出现在测试中,不能出现在辅助函数或钩子中

A.25. 断言只能出现在 /Assert 阶段,不能出现在测试的开头或中间阶段

A.28. 如果某项具体的准备工作需要 3 行或更多代码,就将其移到 /test/helpers 文件夹中的函数里。整体准备阶段超过 2 行没有问题,但如果某项旨在完成单一目标的具体设置需要 3 行或更多代码,就应将其提取到辅助文件中

B 部分 - 测试逻辑

B. 3. 🔫 一锤定音原则:重要提示:断言阶段中的每项数据或假设,都必须先出现在 Arrange 阶段,以便读者清楚地理解结果及其原因

B. 5. 与理解测试结果没有直接关系的细节,不应出现在测试中

B. 10. 不应有冗余断言

B. 15. 不要对大型数据集进行断言和比较,而应在测试中聚焦于特定主题或领域

B. 20. 如果测试假设某些记录/数据存在,就必须在 Arrange 阶段预先创建这些记录/数据

B. 23. 不要测试实现细节。只有在看到检查内部实现而非面向用户行为(例如屏幕元素)的断言时,才提及这个问题

B. 25. 避免任何基于时间的等待,例如 setTimeout 或 page.waitForTimeout(2000)

B. 28. 在每次测试前(beforeEach)清理任何可能在测试之间泄漏的内容:模拟对象、环境变量、本地存储、全局变量,以及其他可能导致测试相互干扰的资源

C 部分 - 测试数据

C.3. JSON 和实体等数据应来自 data 文件夹中的数据工厂。每种数据类型都应有自己的数据工厂文件,并包含用于构建实体的主函数(例如,buildOrder、buildUser)

C.4. 工厂函数应返回默认数据,同时允许调用方覆盖特定字段,这样每个测试都能修改特定字段的值

C.5. 为日期、地址等字段设置通用数据,或设置任何非领域特定的数据时,应使用 fakerjs 等能够提供真实世界数据的库

C.7. 数据工厂函数的输入和输出参数都应有类型,并且应使用与被测代码相同的类型

C.10. 测试数据应使用有意义的领域数据,而非虚拟值

C.15. 构建具有多个选项的字段时,默认随机选择一个选项,以便能够测试所有选项

C.20. 使用列表/数组时,默认放入两个项。为什么?从发现 Bug 的角度来看,零个和一个是过于简单的选择;放入 20 个又会让人应接不暇。两个是在简洁性和真实性之间的良好平衡

符合这些规则的优秀数据工厂示例:
import { faker } from "@faker-js/faker";
import { FileContext } from "../types";

export function buildFileFromIDE(overrides: Partial<FileContext> = {}): FileContext {
  return {
    path: faker.system.filePath(),
    type: faker.helpers.arrayElement(["file", "folder"]),
    ...overrides,
  };
}


D 部分 - 断言

D.7. 避免自定义编码、循环和 Array.prototype 函数,使用内置的 expect API,包括处理数组时

D.11. 使用尽可能少的断言来捕获失败,避免冗余检查。使用:expect(response).toEqual([{id: '123'}, {id: '456'}]),而不是:

expect(response).not.toBeNull()       // redundant
expect(Array.isArray(response)).toBe(true)  // redundant
expect(response.length).toBe(2)       // redundant
expect(response[0].id).toBe('123')    // redundant

单个断言即可捕获 null、非数组和数据错误等问题

D.13. 优先使用在失败时能提供完整比较详情的断言匹配器。使用 expect(actualArray).toEqual(expectedArray),它会显示完整差异;不要使用 expect(actualArray.contains(expectedValue)).toBeTrue(),它只会显示 true/false

D.15. 对字段超过 3 个的对象进行断言时,应从数据工厂获取预期对象,并覆盖最重要的 3 个值。如果有超过 3 个重要值需要断言,应将其拆分为另一个测试用例

Section E - 模拟

E.1. 重要:只模拟调用测试范围之外的外部协作者的代码(例如,电子邮件服务客户端、支付网关)。例外:模拟必要的关键事件,而这些事件无法通过其他方式触发

E.3. 始终使用被模拟代码的类型/接口,这样当真实实现发生变化时,模拟代码会编译失败,促使你更新代码以符合新契约

E.5. 直接在测试文件中定义模拟对象——可以放在测试的 Arrange 阶段(如果会直接影响结果),也可以放在 beforeEach 中(如果是上下文所需)。绝不要把模拟对象藏在外部设置文件中,让它们莫名其妙地改变行为

E.7. 在 beforeEach 中重置所有模拟对象,确保每次测试都从干净状态开始

E.9. 模拟会向已知 URL 发出 HTTP 请求的代码时,优先使用网络拦截(MSW、Nock),而非函数模拟——这样可以让更多代码处于测试范围内

Section F - 使用 DOM 进行测试

适用于 React-testing-library、Playwright、StoryBook 等框架

F.1. 重要:只使用基于 ARIA 角色、标签或可访问名称的面向用户定位器(例如 getByRole、getByLabel)。避免使用 test-id(例如 .getByTestId)、CSS 选择器或任何不基于 ARIA 的定位器

F.3. 不要假设或依赖页面结构或布局。避免使用 nth(i)、first()、last() 等位置选择器

F.5. 使用框架提供的机制安全地断言元素:如果框架能够确定性地判断重新渲染何时结束(例如 testing-library),只需包含标准的非 await 断言。对于 Playwright 这类不会直接与 Renderer 交互的框架,使用带 await 的自动重试断言(即 web-first 断言):await expect(locator).toContainText('some string');

F.9. 避免等待某个内部元素出现(例如 Playwright waitForSelector),因为这会让测试耦合于实现细节。自动重试断言会以可靠的方式等待

F.14. 避免访问并断言外部系统。可以改为断言导航已发生,并在需要时模拟一个存根响应

Section G - 使用数据库进行测试

G.3. 添加多条记录,然后断言只有预期的记录发生了变化,以测试不期望的副作用。例如:await api.delete('/order/123'),然后验证 await api.get('/order/456') 仍然返回 200

G.5. 使用类型匹配器测试自动生成字段的响应架构。例如:expect(response).toMatchObject({ id: expect.any(Number), createdAt: expect.any(String) })

G.7. 为唯一字段添加随机性,同时包含有意义的领域数据和唯一后缀。例如,假设电子邮件地址必须唯一:{ email: ${faker.internet.email()}-${faker.string.nanoid(5)} }

G.9. 为避免耦合于内部实现,使用公共 API 断言新数据状态,而非直接查询数据库。例如:执行 await api.post('/order', newOrder) 后,使用 await api.get('/order/${id}') 验证

G.12. 只预先填充测试元数据(国家、货币)和上下文数据(测试用户)。在每项测试中创建测试专用记录。例如:全局种子数据包含国家列表,测试自行创建订单

G.14. 重要:每项测试只操作自己的记录——绝不在测试之间共享测试数据

G.18. 测试不期望的级联删除或更新。例如:删除父记录,并断言子记录能妥善处理(根据业务规则予以保留或干净删除)

Section I - 测试内容

I.7. 🚀 多走一步原则:覆盖某个场景时,尽量多覆盖一点。测试保存项目?使用两个,而非一个。测试网格过滤?也检查不应显示的项目

I.10. 🔥 主动引入风险原则:对于每种配置和数据,尽量选择最容易导致失败的选项。例如,选择用户角色时,选权限最低的角色

示例

不好的测试示例
// BAD TEST EXAMPLE - Violates multiple best practices
it('should test orders report filtering functionality', async () => { // 👎🏻 violates A.1
  const adminUser = { role: 'admin' } // 👎🏻 violates I.10
  // Setting up mocks for internal implementation details
  const mockOrderService = vi.fn() // 👎🏻 violates E.1
  const mockFilterService = vi.fn() // 👎🏻 violates E.1

  // Dummy meaningless data
  const testData = [ // 👎🏻 violates C.8
    { id: 1, name: 'test1', status: 'foo', date: '2023-01-01' },
    { id: 2, name: 'test2', status: 'bar', date: '2023-01-02' }
  ]

  // No clear arrange phase - mixing setup with assertions
  render(<OrdersReport data={testData} onFilter={mockFilterService} />)

  // Getting internal component state instead of user-facing behavior
  const component = screen.getByTestId('orders-report') // 👎🏻 violates F.1
  const internalState = component.querySelector('.internal-filter-state') // 👎🏻 violates F.1

  try { // 👎🏻 violates A.13
    const filterButton = screen.getByRole('button', { name: 'Filter Active' })
    await userEvent.click(filterButton)

    // Custom assertion logic instead of built-in expect
    let foundItems = [] // 👎🏻 violates D.7
    const rows = screen.getAllByRole('row')
    for (const row of rows) { // 👎🏻 violates A.13, D.7
      if (row.textContent?.includes('Active')) {
        foundItems.push(row)
      }
    }
    // Asserting data that was never arranged in the test
    expect(foundItems.length).toBe(5) // 👎🏻 violates B.3, B.20

    // Testing implementation details
    expect(mockOrderService).toHaveBeenCalled() // 👎🏻 violates B.23
    expect(internalState).toHaveClass('filtered-state') // 👎🏻 violates B.23

    // Too many assertions
    expect(component).toBeInTheDocument() // 👎🏻 violates A.10
    expect(screen.getByText('Active Orders')).toBeVisible() // 👎🏻 violates A.10
    expect(filterButton).toHaveAttribute('aria-pressed', 'true') // 👎🏻 violates A.10
    expect(rows).toBeDefined() // 👎🏻 violates B.10, D.11
    expect(rows).not.toBeNull() // 👎🏻 violates B.10, D.11
    expect(rows.length).toBeGreaterThan(0) // 👎🏻 violates B.10, D.11

  } catch (error) { // 👎🏻 violates A.13
    console.log('Filter test failed:', error) // 👎🏻 violates A.13
    throw new Error('Test setup failed')
  }

  // More irrelevant details not related to filtering
  const headerElement = screen.getByRole('banner')
  expect(headerElement).toHaveTextContent('Dashboard') // 👎🏻 violates B.5
}) // 👎🏻 violates A.3 (too many statements), A.5 (no clear AAA phases)
好的测试示例
beforeEach(() => {
  const currentUser = buildUser({ name: faker.person.fullName(), role: 'viewer' }) // 🔥 The deliberate fire principle
  http.get('/api/user/1', () => HttpResponse.json(currentUser)) // 🥨 The breadcrumb principle
})

test('When filtering by active status, then only active orders are displayed', async () => {
  // Arrange
  const activeOrder = buildOrder({ customerName: faker.person.fullName(), status: 'active' })
  const completedOrder = buildOrder({ customerName: faker.person.fullName(), status: 'non-active' }) // 🔫 The smoking gun principle
  http.get('/api/orders', () => HttpResponse.json([activeOrder, completedOrder]))
  const screen = render(<OrdersReport />)

  // Act
  await userEvent.click(screen.getByRole('button', { name: 'Filter by Active' }))

  // Assert
  expect.element(screen.getByRole('cell', { name: activeOrder.customerName })).toBeVisible()
  expect.element(screen.getByRole('cell', { name: completedOrder.customerName })).not.toBeVisible() // 🚀 The extra mile principle
})

最后

请尽量遵守所有规则,“最重要的 6 条规则”尤为重要,请仔细阅读两遍

评论

支持 Markdown