代码质量的底线不是「写得好」,而是「坏了能立刻知道、改了不敢出错」。本章从测试金字塔讲起,带你把单元测试、组件测试、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。把本章所有武器在一个真实项目里串起来。
每题都能讲清楚才算过关。
测试金字塔和测试奖杯有什么区别?为什么说「集成测试是最重要的层级」?
答案:金字塔强调底层多、顶层少(单元 > 集成 > E2E);奖杯模型主张集成测试应占最大比例。集成测试之所以最重要,是因为大多数 bug 出在「模块协作」这一层:单个函数内部很少出错,但组件之间传递数据、调用 API、状态同步这些交互点是高频出错区,而且集成测试比 E2E 快、比单元测试覆盖更真实的链路,综合性价比最高。
Dummy、Fake、Stub、Spy、Mock 五种测试替身分别什么时候用?为什么「Mock 越多测试越脆弱」?
答案:Dummy 占位不调用(传空对象);Fake 有简化真实实现(内存版 Repository);Stub 返回预设值走通分支;Spy 记录调用信息不改行为;Mock 替换实现并断言调用方式。Mock 越多越脆弱的原因:Mock 把测试和「实现细节」(调了几次、传了什么参数)绑定,一旦内部重构(改函数名、加中间件),即使行为没变,测试也会碎——这意味着你保护的不是「行为」而是「写法」。
写出一个「假定时器 + debounce」测试,解释为什么不能用真实等待。
答案:
vi.useFakeTimers()
const fn = vi.fn()
const debounced = debounce(fn, 300)
debounced(); debounced(); debounced()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
不能用真实等待的原因:① 测试慢(每个 debounce 用例要真等几百毫秒,100 个用例几十秒);② 不精确——真实定时器有 ±几毫秒抖动,边界断言(刚好 300ms)永远不稳定;③ 时间相关测试在 CI 偶发失败会破坏「测试即真相」的信任。假定时器让时钟可控,推进到毫秒级且结果 100% 可重复。
vi.useRealTimers() 还原,否则污染后续测试;② 不要在同一测试里混用 await Promise 和假定时器——假定时器控制宏任务(setTimeout),但不控制微任务(Promise),混用会导致时序混乱。Testing Library 的查询优先级是什么?为什么 getByRole 排在第一位?getBy / queryBy / findBy 的区别?
答案:优先级:getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId。getByRole 排第一是因为它匹配无障碍语义(用户按角色感知页面:按钮、标题、链接),且能倒逼开发者写有意义的 ARIA 角色,是「最接近用户视角 + 最有利于无障碍」的双赢选择。三个前缀区别:getBy 是「必须存在,不存在就崩溃」;queryBy 是「可能不存在,返回 null,用来断言消失」;findBy 是「异步等待出现直到超时,需要 await」。
queryBy:expect(queryByText("加载中")).not.toBeInTheDocument()。用了 getBy 的话,元素不存在时它自己先抛异常,你的断言根本没机会跑。findBy 配合 act() 警告消失——组件异步更新后不再报警告。MSW 和 vi.mock 的本质区别是什么?为什么「在组件测试里用 MSW 拦截 fetch」比「mock 掉 fetch 函数」更接近真实?
答案:MSW 是网络层拦截(Service Worker 级别),vi.mock 是模块替换(代码级别)。MSW 更接近真实的原因:① 组件内代码完全不变——真实的 fetch 调用、错误处理、headers 组装都跑了,测试覆盖了整条链路;② MSW 可以模拟真实网络错误(连接失败、500 超时),而 vi.mock 只能控制「返回值」;③ MSW 的 handler 可以在单测级别用 server.use() 覆盖,非常灵活。vi.mock 的问题是你 mock 的函数形状如果和真实 API 不一致,测试「走通」了但线上照样报错。
E2E 测试的选择器为什么不能用 class?Playwright 的自动等待和固定 sleep 有什么区别?
答案:class 是「实现细节」——改一次样式或重构一次 HTML,所有用 class 的选择器全崩。E2E 必须选「用户看得见」的东西:文本、ARIA 角色、可见 label。Playwright 自动等待是「断言级别」的轮询——它内部每隔一小段时间检查一次条件,要么超时失败要么继续;固定 sleep 是盲等一段固定时间。区别:① 自动等待更早完成(条件满足立刻往下走),固定 sleep 必须等满;② 自动等待消除了「时快时慢」导致的偶发失败,固定 sleep 在网络抖动时会随机崩;③ 自动等待传达意图(等这个元素出现),sleep 只是让代码「别跑太快」。
page.getByRole("button").click() 不用写任何 sleep,框架比你更懂什么时候该点。TDD 的三步循环是什么?「红」这一步强迫你思考什么?TDD 不适用哪些场景?
答案:三步循环:Red(先写失败的测试,描述期望行为)→ Green(写最简实现让测试过,不求优雅)→ Refactor(在测试保护下清理代码改善设计)。「Red」强迫你思考:接口长什么样、输入输出是什么、边界条件如何。这一步其实就是「设计评审前置」——你会发现「这个函数职责太多没法测」,于是拆开,代码反而更好。不适用场景:① UI 视觉微调(先看效果再写逻辑);② 探索性原型(需求不确定、要快速试错);③ 与第三方联调(依赖外部系统响应不可控);④ 纯配置/纯展示组件(行为值不值得 TDD)。
覆盖率四个指标各是什么?为什么「分支覆盖率」最值得盯?追 100% 为什么是误区?
答案:四个指标:① 行覆盖率(被执行的行占比);② 分支覆盖率(if/else、switch 各分支是否都走过);③ 函数覆盖率(被调用的函数占比);④ 语句覆盖率(所有语句执行占比,与行覆盖率接近)。分支覆盖率最值得盯:因为「走进了 else 分支」和「没走进 else 分支」在覆盖率报告上看起来一样(代码行都跑过了),但逻辑完全不同——一个没覆盖的 else 分支就是一颗定时炸弹。追 100% 是误区因为:最后 20% 往往是模板代码和错误处理分支,性价比极低;而且 100% 覆盖 ≠ 100% 正确(你执行到了不等于断言对了)。
ESLint、Prettier、TypeScript 的职责边界如何划分?为什么 ESLint 要配 eslint-config-prettier?
答案:ESLint 管「语义规则」(未使用变量、危险写法、React hooks 依赖正确性)——答案是「逻辑上的对错」;Prettier 管「代码格式」(缩进、引号、换行、分号)——答案是「视觉上的统一」;TypeScript 管「类型约束」(参数类型匹配、undefined 访问、拼错属性)——答案是「编译期的正确性」。三者职责不重叠时最高效,但 ESLint 内置了一些格式化规则(如 max-len、no-mixed-spaces-and-tabs),会和 Prettier 打架。eslint-config-prettier 的作用:关掉所有和 Prettier 冲突的 ESLint 格式类规则,让格式完全交给 Prettier 管。
Code Review 时如何区分 Blocking / Should / Nit?为什么 PR 超过 400 行评审质量会急剧下降?
答案:三个等级:① Blocking(必须改,否则有线上风险——安全漏洞、数据边界、并发问题等);② Should(建议改,明显更好但不改也不出 bug——命名优化、重复提取);③ Nit(小建议,不改也行——风格偏好、可选优化)。不提等级的问题,作者无法判断轻重缓急。PR 超过 400 行评审质量下降的原因:① 人类工作记忆有限,超过 400 行难以在脑中构建完整上下文,容易忽略隐藏问题;② 长 PR 评审耗时翻倍,催生「先 approve 再说」的心态;③ 大 PR = 多职责混杂,可能把重构和功能修改混在一起,回滚风险高。
重构的三大禁忌是什么?「特征测试」是什么、为什么重构前要补它?
答案:三大禁忌:① 不写测试就动代码(没测试的重构是赌博,不是重构);② 重构和加功能混在一个 PR(出 bug 分不清是谁的锅、回滚牵连新功能);③ 顺手「优化」无关代码(范围失控是重构翻车第一原因)。特征测试(Characterization Test):输入一个值,把当前输出记录下来并断言「以后永远等于这个值」。作用:它不验证「正确性」,只锁定「当前行为」。重构前补特征测试的原因:重构的定义是「不改行为只改善设计」,如果你连「当前行为是什么」都没锁定,你凭什么说「行为没变」?有了特征测试,重构后它零改动通过 = 行为没有变化。
你所在(或假设的)团队要建立测试体系,从零开始你会按什么顺序落地(工具 → 规范 → 习惯)?给出理由。
答案: