代码质量的底线不是「写得好」,而是「坏了能立刻知道、改了不敢出错」。本章从测试金字塔讲起,带你把单元测试、组件测试、E2E 测试、TDD、代码评审与重构全部落地成可执行的习惯。学完本章,你交付的不再是「能跑的代码」,而是「有人护航的代码」。
先建立全局观,再谈具体工具。
软件的本质是「变化」。需求会变、框架会升、人会走。没有测试保护的代码,每改一行都要靠手动点页面验证,改动越大、回归成本越高,最后所有人都不敢动老代码——代码就「腐烂」了。测试的核心价值有三条:
| 层级 | 是什么 | 成本 | 速度 | 典型工具 | 数量占比 |
|---|---|---|---|---|---|
| 单元测试 | 测试一个函数/模块的纯逻辑 | 低 | 极快(毫秒级) | Jest / Vitest | 最多(60%+) |
| 组件/集成测试 | 测试组件与组件、与 API、与 store 的协作 | 中 | 快(秒级) | Testing Library / MSW | 较多(30%+) |
| 端到端测试 | 启动真浏览器,模拟用户完整操作 | 高 | 慢(分钟级) | Playwright / Cypress | 少(5%~10%) |
经典金字塔主张「底层多、顶层少」。Kent C. Dodds 提出「测试奖杯」:把集成测试放在最重要的位置——因为用户交互的大多数 bug 出在「模块协作」这一层,而不是单个函数内部。两种模型不冲突:你的绝大多数业务逻辑应该用单元测试兜底,但「这个页面能不能正常完成一次操作」必须靠组件/集成测试覆盖。
测试时我们要隔离外部依赖(网络、数据库、定时器)。「替身」有五种,用错会写出脆弱的测试:
| 替身 | 作用 | 例子 |
|---|---|---|
| Dummy | 只为了占位,从不被真正调用 | 函数参数里传个空对象 |
| Fake | 有真实实现的简化版(内存版数据库) | 用内存 Map 实现一个假的用户仓库 |
| Stub | 返回预设值,让被测代码走通某条分支 | fetch 恒返回 { ok:true, data:[] } |
| Spy | 记录「是否被调用、调用参数」,不改行为 | vi.spyOn(obj,'log') 断言被调了 3 次 |
| Mock | 替换整个实现 + 断言调用(Spy 的加强版) | vi.mock('./api') 后断言 api.fetchUser 被调用 |
掌握框架语法不是重点,重点是「测什么、怎么断言、怎么隔离」。
# 安装 npm i -D vitest @vitest/coverage-v8 // vite.config.ts 里加配置 import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', // 组件测试需要 DOM 环境 globals: true, // 直接写 describe/it,不用 import setupFiles: ['./src/test/setup.ts'], coverage: { provider: 'v8', include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.test.*', 'src/main.tsx', 'src/types/**'], thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 }, }, }, })
package.json 脚本:"test": "vitest"、"test:coverage": "vitest run --coverage"。
被测代码——一个带边界条件的金额格式化函数:
// src/utils/money.ts export function formatMoney(amount: number, currency = 'CNY'): string { if (!Number.isFinite(amount)) throw new Error('amount must be a finite number') const sign = amount < 0 ? '-' : '' const abs = Math.abs(amount) const [int, dec] = abs.toFixed(2).split('.') const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',') return `${sign}${grouped}.${dec} ${currency}` }
// src/utils/money.test.ts import { describe, it, expect } from 'vitest' import { formatMoney } from './money' describe('formatMoney', () => { it('普通金额:千分位 + 两位小数', () => { expect(formatMoney(1234567.8)).toBe('1,234,567.80 CNY') }) it('负数保留符号', () => { expect(formatMoney(-42.5)).toBe('-42.50 CNY') }) it('整数补零', () => { expect(formatMoney(5)).toBe('5.00 CNY') }) it('非法输入抛错', () => { expect(() => formatMoney(NaN)).toThrow('finite number') }) })
高频匹配器清单(Vitest 与 Jest 完全一致):
| 匹配器 | 用途 |
|---|---|
| toBe / toEqual | 原始值 / 深比较对象数组(toBe 用 Object.is) |
| toMatchObject | 只关心对象的部分字段 |
| toContain / toContainEqual | 数组含某元素 |
| toMatch | 字符串匹配正则 |
| toBeCloseTo | 浮点比较(0.1+0.2 === 0.30000000000000004) |
| toThrow | 断言抛错 |
| toHaveBeenCalledWith | 断言 Spy/Mock 的调用参数 |
| .resolves / .rejects | 断言 Promise 的成败 |
| expect.arrayContaining / objectContaining | 部分匹配 |
测异步有三种场景,写法完全不同:
// 场景一:Promise 异步 async function fetchUser(id: number) { const res = await fetch(`/api/user/${id}`) if (!res.ok) throw new Error('fetch failed') return res.json() } it('成功返回用户', async () => { // 用 .resolves/.rejects 避免 try/catch 迷宫 await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Tom' }) }) it('失败抛错', async () => { await expect(fetchUser(999)).rejects.toThrow('fetch failed') }) // 场景二:假定时器(防抖、轮询) vi.useFakeTimers() it('debounce 只在停止输入后触发', () => { const fn = vi.fn() const debounced = debounce(fn, 300) debounced(); debounced(); debounced() expect(fn).not.toHaveBeenCalled() // 300ms 内不触发 vi.advanceTimersByTime(299) expect(fn).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(fn).toHaveBeenCalledTimes(1) // 只触发一次 vi.useRealTimers() })
vi.useRealTimers() 还原,否则污染其他测试;② 测试里混用 await 和假定时器会死锁(Promise 微任务不受假定时器控制,但宏任务会);③ 用 vi.advanceTimersByTime 精确推进,不要 await new Promise(r => setTimeout(r, 300)) 真等。
// 1) 函数级:vi.fn / vi.spyOn const logger = vi.fn() logger('hello', 42) expect(logger).toHaveBeenCalledWith('hello', 42) // 2) 模块级:替换整个依赖模块 vi.mock('./api', () => ({ fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Tom' }), })) // 3) 全局级:拦截 fetch(组件测试常用) vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })) ))
| 指标 | 含义 | 要警惕的假象 |
|---|---|---|
| 行覆盖率 | 被执行的代码行占比 | 执行到 ≠ 断言到 |
| 分支覆盖率 | if/else、switch 各分支是否都走过 | 最重要但常被忽略 |
| 函数覆盖率 | 被调用的函数占比 | 纯展示函数极易拉高 |
| 语句覆盖率 | 所有语句执行占比 | 与行覆盖率接近 |
Testing Library 的核心信条:测「用户怎么用」,不测「代码怎么写」。
// npm i -D @testing-library/react @testing-library/user-event @testing-library/jest-dom import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' it('点击按钮后显示问候语', async () => { render(<GreetForm />) const input = screen.getByLabelText('你的名字') // 按 label 找输入框 await userEvent.type(input, '小明') // 模拟真实逐键输入 await userEvent.click(screen.getByRole('button', { name: '提交' })) expect(screen.getByText('你好,小明')).toBeInTheDocument() })
render:把组件挂到测试用的 DOM。userEvent:模拟「真实用户」——它自动处理聚焦、按键序列、事件组合(click 会先触发 mousedown/mouseup)。永远优先 userEvent,fireEvent 只是底层逃生门。screen:全局查询对象,不用把 render 的返回值传来传去。await)。断言「消失」用 queryByText(...).not.toBeInTheDocument()——用 getBy 会先抛异常而不是给你断言机会。
测「组件 + API」的协作,正确姿势是 MSW(Mock Service Worker):它在网络层拦截请求,组件里真的调用 fetch,但数据是假的——测试覆盖的是「真实链路」,而不是「mock 过的调用」。
// src/test/server.ts —— 建立 MSW 服务 import { setupServer } from 'msw/node' import { http, HttpResponse } from 'msw' export const server = setupServer( http.get('/api/users', () => HttpResponse.json([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ])), ) // src/test/setup.ts —— 全局启停 import { server } from './server' beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) // 清掉单测里覆盖的 handler afterAll(() => server.close())
// UserList.test.tsx it('加载后展示用户列表', async () => { render(<UserList />) expect(screen.getByText('加载中…')).toBeInTheDocument() // 初始 loading expect(await screen.findByText('Alice')).toBeInTheDocument() // 等待异步结果 expect(screen.getByText('Bob')).toBeInTheDocument() }) it('接口失败时显示错误并可以重试', async () => { server.use(http.get('/api/users', () => HttpResponse.error())) // 单测级覆盖 render(<UserList />) expect(await screen.findByText('加载失败')).toBeInTheDocument() })
findBy* 等异步断言,而不是 getBy* 后立刻断言。别为了消警告随便套 act(),先检查是不是断言顺序错了。
expect(renderer.create(<Badge kind="danger">删除</Badge>).toJSON()).toMatchSnapshot()
E2E 是「最后一道防线」,只守护最重要的用户旅程。
# 安装(自带浏览器下载) npm i -D @playwright/test npx playwright install chromium // playwright.config.ts import { defineConfig } from '@playwright/test' export default defineConfig({ testDir: './e2e', timeout: 30_000, use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', // 失败自动留 trace,可回放每一步 }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'mobile', use: { browserName: 'chromium', viewport: { width: 390, height: 844 } } }, ], webServer: { command: 'npm run dev', // 测试前自动起服务 port: 5173, reuseExistingServer: !process.env.CI, }, })
// e2e/checkout.spec.ts import { test, expect } from '@playwright/test' test('用户能完成一次完整购买', async ({ page }) => { await page.goto('/') // 搜索(用 getByRole 而不是 CSS 选择器) await page.getByRole('searchbox').fill('机械键盘') await page.getByRole('button', { name: '搜索' }).click() // 等待结果出现(自动重试,代替手工 sleep) await page.getByText('机械键盘 87 键').click() await page.getByRole('button', { name: '加入购物车' }).click() await page.getByRole('link', { name: '去结算' }).click() // 断言最终结果 await expect(page.getByText('订单提交成功')).toBeVisible() })
先写测试再写实现,用「失败」驱动设计。
TDD 的价值不是「测试多」,而是倒逼你先想清楚行为:写测试之前你得定义「输入是什么、输出是什么、边界在哪」。这个思考过程本身就在帮你做接口设计——很多人在写测试时才发现「这个函数职责太杂了,没法测」,于是拆开,代码反而变好了。
// 第 1 步:先写测试(红) import { calcDiscount } from './cart' describe('calcDiscount', () => { it('满 100 减 10', () => expect(calcDiscount(100)).toBe(10)) it('满 300 减 50', () => expect(calcDiscount(300)).toBe(50)) it('满 500 减 100', () => expect(calcDiscount(500)).toBe(100)) it('不满 100 不减', () => expect(calcDiscount(99)).toBe(0)) it('边界:刚好 100 也减', () => expect(calcDiscount(100)).toBe(10)) }) // 第 2 步:最简实现(绿)—— 先把测试跑过 export function calcDiscount(total: number): number { if (total >= 500) return 100 if (total >= 300) return 50 if (total >= 100) return 10 return 0 } // 第 3 步:重构 —— 用查表法消除重复的阶梯 if const TIERS: Array<[min: number, discount: number]> = [ [500, 100], [300, 50], [100, 10], ] export function calcDiscount(total: number): number { for (const [min, discount] of TIERS) { if (total >= min) return discount } return 0 }
在代码进仓库前,把 90% 的低级错误挡在门外。
| 工具 | 管什么 | 典型错误 | 属于 |
|---|---|---|---|
| ESLint | 代码「语义」规则 | 未使用变量、危险写法、React Hooks 依赖 | 逻辑正确性 |
| Prettier | 代码「格式」 | 缩进、引号、换行、分号 | 风格统一 |
| TypeScript | 「类型」约束 | 参数类型不匹配、undefined 访问、拼错属性 | 编译期正确性 |
eslint-config-prettier 关掉格式类规则,避免打架。
// eslint.config.js(flat config) import js from '@eslint/js' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import prettier from 'eslint-config-prettier' export default tseslint.config( { ignores: ['dist', 'coverage', 'node_modules'] }, js.configs.recommended, ...tseslint.configs.recommended, { files: ['**/*.{ts,tsx}'], plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh }, rules: { ...reactHooks.configs.recommended.rules, // 含 rules-of-hooks、exhaustive-deps 'react-refresh/only-export-components': 'warn', '@typescript-eslint/no-explicit-any': 'warn', // any 是烟雾弹,留下警告 '@typescript-eslint/consistent-type-imports': 'error', }, }, prettier, )
recommended + 少数强规则即可,别一上来就上几百条。成熟团队:把「线上事故级」的规则设为 error(如 react-hooks/exhaustive-deps),把风格类设为 warn。规则的价值在于「统一」,不在「多」。
// tsconfig.json 里这些开关一定要开 { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, // arr[i] 可能是 undefined "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true } }
用 type 表达可能性,而不是用 any 逃避:
// 坏:any 把类型错误推到运行时 function getFirst(items: any[]) { return items[0] } // 好:把「可能没有」写进类型,调用方被迫处理 function getFirst<T>(items: T[]): T | undefined { return items[0] } const user = getFirst(users) // user: User | undefined if (user) { /* 这里才是安全的访问点 */ }
// 用 husky + lint-staged 只检查暂存区,快且不烦人 npm i -D husky lint-staged npx husky init // .lintstagedrc.json { "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"], "*.{json,md,css,html}": ["prettier --write"] } // .husky/pre-commit(lint-staged 处理暂存文件) npx lint-staged // .husky/pre-push(提交前跑全量测试 + 类型检查) npm run typecheck && npm run test:run
评审不是挑刺,是团队知识的流动管道。
| 维度 | 要问的问题 |
|---|---|
| 正确性 | 边界条件(空、0、null、超长)处理了吗?并发/竞态考虑了吗? |
| 可读性 | 名字能自解释吗?一段逻辑超过 20 行了吗?我 3 个月后再看能懂吗? |
| 可测试性 | 新增逻辑有测试吗?测试断言的是行为还是实现? |
| 性能 | 循环里做了不必要的计算吗?重复渲染可以避免吗? |
| 安全 | 用户输入被转义了吗?权限校验在正确的位置吗? |
| 一致性 | 命名风格、错误处理方式与仓库其他代码一致吗? |
| 范围 | 这个 PR 只做了描述里的事吗?(scope creep 是最常见的评审杀手) |
list 是空数组会发生什么?我们考虑过这个情况吗?」重构不是重写。有测试护航的重构叫「重构」,没测试的叫「赌博」。
// ① 提取函数(Extract Function):把「一段有名字的意图」抽出来 // 重构前 function sendInvoice(user: User) { const lines = user.orders.map(o => `${o.name} x${o.qty}: ¥${o.total}`) const total = user.orders.reduce((s, o) => s + o.total, 0) email(user.email, ['=== 账单 ===', ...lines, `合计: ¥${total}`].join('\n')) } // 重构后:账单组装有自己的名字,可单独测试 function buildInvoiceText(user: User): string { const lines = user.orders.map(o => `${o.name} x${o.qty}: ¥${o.total}`) const total = user.orders.reduce((s, o) => s + o.total, 0) return ['=== 账单 ===', ...lines, `合计: ¥${total}`].join('\n') } function sendInvoice(user: User) { email(user.email, buildInvoiceText(user)) } // ② 用对象传参(Introduce Parameter Object):消灭 5+ 参数的函数 function filterProducts(category: string, priceMax: number, inStock: boolean, sortBy: string, page: number) {} interface Filter { category?: string; priceMax?: number; inStock?: boolean sortBy?: 'price' | 'sales'; page?: number } function filterProducts(filter: Filter) {} // ③ 用三元/提前返回拆掉嵌套(Replace Nested Conditional) function getStatus(user: User): string { if (!user.active) return 'blocked' // 卫语句提前返回,主路径留在最后 if (user.vip && user.expireAt > Date.now()) return 'vip' return 'normal' } // ④ 拆分模块(Extract Module):文件超过 300 行且职责超过一个,就拆 // utils.ts 拆成 money.ts / date.ts / validate.ts,各自带测试
每一条都是真实事故的浓缩。
beforeEach 重置一切,测试间零共享。vi.useFakeTimers。as any 一多,TS 就退化成 JS。解法:no-explicit-any 规则 + 代码评审盯新增 any。把本章所有武器在一个真实项目里串起来。
每题都能讲清楚才算过关。
测试金字塔和测试奖杯有什么区别?为什么说「集成测试是最重要的层级」?
Dummy、Fake、Stub、Spy、Mock 五种测试替身分别什么时候用?为什么「Mock 越多测试越脆弱」?
写出一个「假定时器 + debounce」测试,解释为什么不能用真实等待。
Testing Library 的查询优先级是什么?为什么 getByRole 排在第一位?getBy / queryBy / findBy 的区别?
MSW 和 vi.mock 的本质区别是什么?为什么「在组件测试里用 MSW 拦截 fetch」比「mock 掉 fetch 函数」更接近真实?
E2E 测试的选择器为什么不能用 class?Playwright 的自动等待和固定 sleep 有什么区别?
TDD 的三步循环是什么?「红」这一步强迫你思考什么?TDD 不适用哪些场景?
覆盖率四个指标各是什么?为什么「分支覆盖率」最值得盯?追 100% 为什么是误区?
ESLint、Prettier、TypeScript 的职责边界如何划分?为什么 ESLint 要配 eslint-config-prettier?
Code Review 时如何区分 Blocking / Should / Nit?为什么 PR 超过 400 行评审质量会急剧下降?
重构的三大禁忌是什么?「特征测试」是什么、为什么重构前要补它?
你所在(或假设的)团队要建立测试体系,从零开始你会按什么顺序落地(工具 → 规范 → 习惯)?给出理由。