写测试先找稳定逻辑,不要先测样式
你的测试在保护什么
上周我在 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 生成的代码基于概率而非确定性——它大概率写对,但不保证写对。测试就是你验证这些概率产出的唯一手段。
不过,这并不意味着所有代码都要写测试。我个人的实践是把单元测试的对象分成四个优先级:
四类优先测试对象
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 组件写一堆 getByRole、getByText 的断言,验证组件「渲染正确」。这类测试的问题在于:它们既不够快(相比纯函数测试),又不够真实(相比 E2E 测试),卡在中间不上不下。
对于纯展示组件,我推荐的验证方式是:
- TypeScript 类型检查 —— 保证 props 传递正确
- Build 通过 —— 保证没有语法和导入错误
- 浏览器人工验收 或 Playwright 截图对比 —— 保证视觉正确
- 关键路径 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 | <1ms | 10-50ms | 10-50ms |
| UI 改版时是否需要改 | 不需要 | 不需要 | 偶尔需要 | 几乎每次都要改 |
| 失败时定位难度 | 低(堆栈清晰) | 低(输入输出明确) | 中(需看 DOM) | 低但无意义 |
| 年度维护成本(估) | 几乎为零 | 随规则变化微调 | 随组件重构调整 | 高(每次设计改版都改) |
测试预算分配建议
根据我的经验,一个健康的前端项目测试预算大概这样分配:
| 测试类型 | 占比 | 覆盖目标 |
|---|---|---|
| 纯函数 / 数据转换测试 | 30-40% | 格式化工具、计算逻辑、映射函数 |
| 权限 / 状态机测试 | 20-25% | 角色权限、状态转换规则、业务规则 |
| 错误处理 / 边界测试 | 15-20% | API 错误映射、边界条件、异常路径 |
| 关键集成测试 | 10-15% | 核心业务流程(登录、下单、支付) |
| E2E 冒烟测试 | 5-10% | 首页加载、核心链路可用 |
其中前三类加起来占到 65-85%,都是稳定逻辑的测试。后两类占比小,但覆盖了用户真实使用场景。
写测试前的检查清单
每次写测试之前,我会过一遍这个清单。不是每条都必须满足,但它能帮你判断测试值不值得写:
- 这段逻辑三个月后还会存在吗? 如果很可能被重构或删掉,测试预算不如花在更稳定的地方。
- 测试失败时,我能立刻定位问题吗? 如果测试报错信息模糊,维护成本会很高。
- 这个测试能防止什么样的线上 bug? 如果答不出来,可能是在为覆盖率而写。
- 输入输出是否明确可枚举? 纯函数、状态机、映射表都是好对象。
- 是否涉及安全或资金? 权限、金额计算、数据校验——这些必须测。
- 是否曾经出过 bug? 有过回归历史的代码,补测试价值最大。
- 是否依赖 DOM 或浏览器环境? 如果是,考虑用集成测试或 E2E 代替。
- 是否只是转发 props 或调用第三方库? 简单转发不值得测,第三方库行为由库作者保证。
- UI 改版时这个测试需要改吗? 如果需要,它可能不够稳定。
- 测试运行时间是否在 10ms 以内? 过慢的单元测试会拖慢开发循环。
- 是否和另一个测试断言了同一件事? 重复测试不如删掉一个。
- 失败后 CI 会立刻中断吗? 如果测试不稳定(flaky),它会浪费所有人的时间。
让测试成为安全网而不是负担
回到文章开头那个按钮的例子。好的测试策略应该是:
- 按钮的权限控制 → 写单元测试(纯函数,稳定)
- 按钮点击后的表单提交逻辑 → 写单元测试或集成测试
- 按钮的文案和样式 → 不写单元测试,用浏览器验收
- 按钮所在的核心流程(如下单)→ 写 E2E 测试
这样的测试组合既能拦住真正重要的回归,又不会在每次 UI 改版时变成维护噩梦。
测试代码也是代码,也需要考虑投入产出比。把测试预算花在稳定逻辑上,用最少但最精准的测试覆盖最关键的风险点——这才是测试的真正价值所在。
好的测试让团队更敢改代码,而不是让大家害怕改测试。如果你发现团队开始绕过测试、跳过测试、或者因为测试失败而不敢合并 PR,那大概率不是测试太少,而是测错了东西。
先找稳定逻辑,再考虑其他。这是我在测试这件事上踩过坑之后得出的最重要的一条经验。
参考资料
- Do We Need Unit Tests in 2026? — Slavik Shynkareko 关于 AI 时代单元测试价值的分析
- Unit Testing vs. System Testing: Key Differences, Use Cases, and Best Practices for 2026 — IBM 报告引用:生产环境修 bug 成本是单元测试阶段的 15 倍
- Testing Chat-Generated Apps: What to Test First and Skip — Koder.ai 关于 AI 生成代码的测试策略
- 10 Essential Unit Testing Best Practice Principles for 2025 — 单元测试最佳实践原则
- The Testing Pyramid: Strategic Software Testing for Agile Teams — CircleCI 关于测试金字塔的现代解读
- Software Testing Strategies in 2026: An AI-Native, Budget-Based Framework — Bug0 提出的基于预算的测试策略框架
- Test Pyramid - Engineering Guidance and Standards — UK Home Office 工程标准中的测试金字塔指南