写测试先找稳定逻辑,不要先测样式

0阅读10分钟

你的测试在保护什么

上周我在 Review 一个 PR 时看到这样一幕:同事为一个按钮组件写了 12 个测试用例,其中 9 个在验证 CSS 类名是否正确、文字是否渲染、点击后有没有触发回调。真正涉及业务逻辑的——权限判断和错误状态处理——只有 1 个用例,而且写得含糊。

我问他:如果这个按钮的文案从「提交」改成「确认提交」,你的测试会挂吗?他说会。这就是问题所在——一个纯展示变更不应该让测试失败。测试不是用来验证「代码长什么样」的,而是用来验证「逻辑对不对」的。

很多团队在写测试时犯的错误不是测试太少,而是测错了对象。他们把有限的测试预算花在随时可能改动的样式和交互细节上,却对真正容易出问题的纯函数、权限规则和状态转换毫无覆盖。等真正出 bug 时,测试套件一片绿,什么都报不出来。

这篇文章要讨论的核心问题很简单:写测试时,应该先找什么来测,什么可以晚测甚至不测

测试预算有限,必须花在刀刃上

每个项目的测试资源都是有限的——开发时间、CI 运行时间、维护精力。把测试资源看作预算,你需要考虑投入产出比。

Kent C. Dodds 在《Testing Trophy》里提过一个观点:与其追求四个层级的测试数量均衡,不如把重心放在「集成测试」这一层。但我认为在前端项目中,还有一个更前置的问题需要回答——在写任何测试之前,先想清楚这段代码的哪些部分是稳定的、哪些是容易变的

稳定的逻辑值得投入测试预算,因为:

  • 它们很少随 UI 改版而变动
  • 输入输出边界清晰,测试容易写也容易维护
  • 一旦出错影响面大——权限漏判、金额算错、状态机卡死
  • 测试失败时定位成本低,不用开浏览器就能知道哪里挂了

相反,UI 展示细节天然不稳定。产品迭代时,按钮文案、样式类名、布局结构随时可能调整。给这些东西写测试,本质上是在给未来埋雷——每次改动都要同步改测试,测试变成负担而不是保障。

IBM 在 2025 年的一份报告中指出,生产环境修复一个 bug 的成本是在单元测试阶段发现它的 15 倍——早期阶段每个缺陷成本约 100 美元,到线上就变成了 1500 美元。这说明测试的价值不在于数量,而在于它拦截了什么级别的缺陷。

测试金字塔的现代解读

传统的测试金字塔把测试分为三层:底部的单元测试、中间的集成测试、顶部的 E2E 测试。这个模型在 2026 年依然有效,但需要结合 AI 时代的背景重新理解。

Slavik Shynkareko 在《Do We Need Unit Tests in 2026?》一文中提出了一个有趣的观察:当 AI 生成的代码越来越多时,单元测试的价值不是降低了,而是升高了。原因是 AI 生成的代码基于概率而非确定性——它大概率写对,但不保证写对。测试就是你验证这些概率产出的唯一手段。

不过,这并不意味着所有代码都要写测试。我个人的实践是把单元测试的对象分成四个优先级:

流程图画布 · 115%
Mermaid 流程图加载中...

四类优先测试对象

1. 纯函数和数据转换

纯函数是最理想的测试对象:给定输入,必然得到相同输出,没有副作用,不依赖外部状态。

典型的纯函数包括数据格式化、字段映射、金额计算、日期转换等。它们的测试写起来快,跑起来也快,维护成本几乎为零。

// ❌ 不值得测的:UI 渲染细节
test('renders button with correct class', () => {
  const { getByRole } = render(<Button variant="primary" />)
  expect(getByRole('button')).toHaveClass('bg-blue-500 text-white rounded-lg')
})
 
// ✅ 值得测的:纯函数逻辑
test('formatPrice formats number to currency string', () => {
  expect(formatPrice(1234.5, 'CNY')).toBe('¥1,234.50')
  expect(formatPrice(0, 'USD')).toBe('$0.00')
  expect(formatPrice(-100, 'EUR')).toBe('-€100.00')
})

上面第一组测试的问题是:一旦设计稿把 rounded-lg 改成 rounded-md,测试就挂了。但按钮功能没有任何问题。这种测试除了制造噪声没有别的作用。

2. 权限判断和状态机

权限判断是那种「平时看不出问题,一出就是安全事故」的逻辑。它通常可以写成纯函数或小型状态机,非常适合单元测试。

// permissions.ts
export function canAccessDocument(
  user: { role: string; department: string },
  document: { visibility: 'public' | 'internal' | 'confidential'; ownerDept: string }
): boolean {
  if (document.visibility === 'public') return true
  if (document.visibility === 'confidential') {
    return user.role === 'admin' || user.department === document.ownerDept
  }
  // internal
  return user.role !== 'guest'
}
 
// permissions.test.ts
test('guest cannot access internal documents', () => {
  expect(canAccessDocument(
    { role: 'guest', department: 'engineering' },
    { visibility: 'internal', ownerDept: 'engineering' }
  )).toBe(false)
})
 
test('admin can access confidential documents from any department', () => {
  expect(canAccessDocument(
    { role: 'admin', department: 'engineering' },
    { visibility: 'confidential', ownerDept: 'finance' }
  )).toBe(true)
})

这类测试的价值在于:权限规则一旦写错,后果不是页面丑了一点,而是数据泄露或越权操作。测试能在开发阶段就拦住这些逻辑错误。

3. API 错误映射

后端返回的错误码和前端展示的错误信息之间,通常有一层映射逻辑。这层逻辑看起来简单,但实际上很容易出问题——新加一个错误码忘了处理,用户就看到一个莫名的「未知错误」。

// error-mapping.ts
export function mapApiError(statusCode: number, body: { code?: string }): UserFacingError {
  switch (statusCode) {
    case 400:
      return { type: 'validation', message: '输入信息有误,请检查后重试' }
    case 401:
      return { type: 'auth', message: '登录已过期,请重新登录' }
    case 403:
      return { type: 'permission', message: '你没有权限执行此操作' }
    case 404:
      return { type: 'not_found', message: '请求的资源不存在' }
    case 429:
      return { type: 'rate_limit', message: '操作太频繁,请稍后再试' }
    default:
      if (body.code === 'PAYMENT_EXPIRED') {
        return { type: 'payment', message: '支付已过期,请重新发起' }
      }
      return { type: 'unknown', message: '服务暂时不可用,请稍后重试' }
  }
}

Koder.ai 的测试指南里也提到:「Make error mapping explicit. Your API should consistently translate common failures into the right status codes.」错误映射必须显式,测试必须覆盖每个分支——包括 default 分支。

4. 边界条件和失败路径

正常路径大家都会测,但真正出问题的往往是边界条件。数组为空时怎么办?数字溢出怎么办?网络超时时状态怎么回退?

// 分页计算
function calculatePagination(total: number, page: number, pageSize: number) {
  const safePageSize = Math.max(1, Math.min(pageSize, 100))
  const totalPages = Math.ceil(total / safePageSize)
  const safePage = Math.max(1, Math.min(page, totalPages || 1))
 
  return {
    page: safePage,
    pageSize: safePageSize,
    totalPages,
    hasNext: safePage < totalPages,
    hasPrev: safePage > 1,
  }
}
 
test('clamps page to valid range when total is 0', () => {
  const result = calculatePagination(0, 5, 20)
  expect(result.page).toBe(1)
  expect(result.totalPages).toBe(0)
  expect(result.hasNext).toBe(false)
  expect(result.hasPrev).toBe(false)
})
 
test('clamps pageSize to max 100', () => {
  const result = calculatePagination(500, 1, 999)
  expect(result.pageSize).toBe(100)
})

这些边界条件测试看似琐碎,但它们防止的是那种「线上跑了半年,某天突然炸了」的隐蔽 bug。

什么不该用单元测试覆盖

说完了该测什么,再来看什么不该测。这不是偷懒,而是工程取舍。

类型为什么不值得测替代方案
CSS 类名 / 样式属性设计稿随时改,测试变噪声视觉回归测试、浏览器验收
按钮文案 / 提示语产品迭代频繁调整E2E 冒烟测试覆盖关键文案
普通点击交互框架已保证事件绑定正确用户验收测试
组件 DOM 结构重构时必然变动快照测试(谨慎使用)或 E2E
第三方库内部行为库作者自己测过了只测你的集成点
简单 getter / setter没有逻辑,测了等于抄代码不需要测试
配置对象的字段名改了 TS 就报错,测试多余TypeScript 类型检查

有些团队喜欢给每个 React 组件写一堆 getByRolegetByText 的断言,验证组件「渲染正确」。这类测试的问题在于:它们既不够快(相比纯函数测试),又不够真实(相比 E2E 测试),卡在中间不上不下。

对于纯展示组件,我推荐的验证方式是:

  1. TypeScript 类型检查 —— 保证 props 传递正确
  2. Build 通过 —— 保证没有语法和导入错误
  3. 浏览器人工验收Playwright 截图对比 —— 保证视觉正确
  4. 关键路径 E2E —— 保证核心用户流程不被破坏

这套组合比堆砌组件单测更有效,维护成本更低。

如何判断一段逻辑值不值得测

不是所有代码都非黑即白。有些逻辑介于「稳定」和「易变」之间,需要具体判断。我总结了一个决策表格:

判断维度值得测试不值得测试
变更频率很少改动(基础设施级)频繁调整(UI 迭代级)
出错后果数据丢失、安全漏洞、资金错误界面略有偏差
输入输出明确、可枚举模糊、依赖运行时环境
逻辑复杂度有分支、有边界条件线性转发、无分支
可测试性纯函数、无副作用深度耦合 DOM、依赖全局状态
回归历史曾经出过 bug从未出过问题

如果你的代码同时满足「变更频率低」「出错后果严重」「输入输出明确」三个条件,那它就是最优先测试的对象。

代码对比:好的测试 vs 差的测试

下面通过几组具体对比,说明什么样的测试有价值,什么样的测试是在浪费预算。

对比 1:测试行为 vs 测试实现

// ❌ 测试实现细节:依赖组件内部结构
test('calls onSubmit when button is clicked', () => {
  const onSubmit = vi.fn()
  const { getByTestId } = render(<Form onSubmit={onSubmit} />)
  fireEvent.click(getByTestId('submit-button'))
  expect(onSubmit).toHaveBeenCalledTimes(1)
})
 
// ✅ 测试业务行为:验证表单提交后的状态变化
test('form submission creates a new record and shows success', async () => {
  const { user, getByText } = render(<Form />)
  await user.click(getByText('提交'))
  expect(getByText('提交成功')).toBeInTheDocument()
})

第一组测试绑定的是 DOM 结构和事件回调——如果按钮从 data-testid="submit-button" 改成别的,或者从 fireEvent.click 变成 user.click,测试就挂了。第二组测试关注的是用户能看到的结果,更稳定。

对比 2:测试边界 vs 测试正常路径

// ❌ 只测正常路径
test('calculates discount correctly', () => {
  expect(calculateDiscount(100, 0.1)).toBe(90)
})
 
// ✅ 覆盖边界和异常情况
test('calculateDiscount handles edge cases', () => {
  expect(calculateDiscount(100, 0)).toBe(100)       // 无折扣
  expect(calculateDiscount(100, 1)).toBe(0)         // 全额折扣
  expect(calculateDiscount(0, 0.5)).toBe(0)         // 原价为零
  expect(calculateDiscount(-50, 0.1)).toBe(-45)     // 负数金额
  expect(calculateDiscount(100, -0.1)).toBe(100)    // 负折扣率,应该当 0 处理
  expect(calculateDiscount(100, 1.5)).toBe(100)     // 超过 100% 折扣,应该封顶
})

对比 3:测试映射逻辑 vs 测试渲染结果

// ❌ 测试渲染结果:绑定 CSS 类名
test('error message displays in red', () => {
  const { getByText } = render(<ErrorMessage text="出错了" />)
  const el = getByText('出错了')
  expect(el).toHaveClass('text-red-500')
})
 
// ✅ 测试映射逻辑:验证错误码到消息的转换
test('mapErrorCode returns user-facing message for each status', () => {
  expect(mapApiError(400, {}).message).toContain('输入')
  expect(mapApiError(401, {}).message).toContain('登录')
  expect(mapApiError(500, {}).message).toContain('服务')
  expect(mapApiError(503, { code: 'MAINTENANCE' }).message).toContain('维护')
})

对比 4:测试状态转换 vs 测试 UI 切换

// ❌ 测试 UI 切换细节
test('toggles dropdown open and closed', () => {
  const { getByRole } = render(<Dropdown />)
  expect(getByRole('listbox')).not.toBeVisible()
  fireEvent.click(getByRole('button'))
  expect(getByRole('listbox')).toBeVisible()
  fireEvent.click(getByRole('button'))
  expect(getByRole('listbox')).not.toBeVisible()
})
 
// ✅ 测试状态机转换
test('order state transitions follow valid paths', () => {
  expect(transitionOrderState('draft', 'SUBMIT')).toBe('pending_review')
  expect(transitionOrderState('pending_review', 'APPROVE')).toBe('confirmed')
  expect(transitionOrderState('pending_review', 'REJECT')).toBe('draft')
  expect(() => transitionOrderState('confirmed', 'SUBMIT')).toThrow(
    'INVALID_TRANSITION'
  )
})

测试维护成本对比

不同类型的测试,维护成本差异巨大。下表从五个维度对比:

维度纯函数测试权限规则测试组件渲染测试CSS 类名测试
编写速度快(5 分钟)中(10 分钟)中(15 分钟)快(5 分钟)
运行速度<1ms<1ms10-50ms10-50ms
UI 改版时是否需要改不需要不需要偶尔需要几乎每次都要改
失败时定位难度低(堆栈清晰)低(输入输出明确)中(需看 DOM)低但无意义
年度维护成本(估)几乎为零随规则变化微调随组件重构调整高(每次设计改版都改)

测试预算分配建议

根据我的经验,一个健康的前端项目测试预算大概这样分配:

测试类型占比覆盖目标
纯函数 / 数据转换测试30-40%格式化工具、计算逻辑、映射函数
权限 / 状态机测试20-25%角色权限、状态转换规则、业务规则
错误处理 / 边界测试15-20%API 错误映射、边界条件、异常路径
关键集成测试10-15%核心业务流程(登录、下单、支付)
E2E 冒烟测试5-10%首页加载、核心链路可用

其中前三类加起来占到 65-85%,都是稳定逻辑的测试。后两类占比小,但覆盖了用户真实使用场景。

写测试前的检查清单

每次写测试之前,我会过一遍这个清单。不是每条都必须满足,但它能帮你判断测试值不值得写:

  1. 这段逻辑三个月后还会存在吗? 如果很可能被重构或删掉,测试预算不如花在更稳定的地方。
  2. 测试失败时,我能立刻定位问题吗? 如果测试报错信息模糊,维护成本会很高。
  3. 这个测试能防止什么样的线上 bug? 如果答不出来,可能是在为覆盖率而写。
  4. 输入输出是否明确可枚举? 纯函数、状态机、映射表都是好对象。
  5. 是否涉及安全或资金? 权限、金额计算、数据校验——这些必须测。
  6. 是否曾经出过 bug? 有过回归历史的代码,补测试价值最大。
  7. 是否依赖 DOM 或浏览器环境? 如果是,考虑用集成测试或 E2E 代替。
  8. 是否只是转发 props 或调用第三方库? 简单转发不值得测,第三方库行为由库作者保证。
  9. UI 改版时这个测试需要改吗? 如果需要,它可能不够稳定。
  10. 测试运行时间是否在 10ms 以内? 过慢的单元测试会拖慢开发循环。
  11. 是否和另一个测试断言了同一件事? 重复测试不如删掉一个。
  12. 失败后 CI 会立刻中断吗? 如果测试不稳定(flaky),它会浪费所有人的时间。

让测试成为安全网而不是负担

回到文章开头那个按钮的例子。好的测试策略应该是:

  • 按钮的权限控制 → 写单元测试(纯函数,稳定)
  • 按钮点击后的表单提交逻辑 → 写单元测试或集成测试
  • 按钮的文案和样式 → 不写单元测试,用浏览器验收
  • 按钮所在的核心流程(如下单)→ 写 E2E 测试

这样的测试组合既能拦住真正重要的回归,又不会在每次 UI 改版时变成维护噩梦。

测试代码也是代码,也需要考虑投入产出比。把测试预算花在稳定逻辑上,用最少但最精准的测试覆盖最关键的风险点——这才是测试的真正价值所在。

好的测试让团队更敢改代码,而不是让大家害怕改测试。如果你发现团队开始绕过测试、跳过测试、或者因为测试失败而不敢合并 PR,那大概率不是测试太少,而是测错了东西。

先找稳定逻辑,再考虑其他。这是我在测试这件事上踩过坑之后得出的最重要的一条经验。

参考资料

评论 0

0 / 1000