首页  |  学习总览  |  ← 上一章:性能优化 阶段八 · 第 8 章,共 10 章
阶段八 · 预计 60 小时

测试与代码质量

代码质量的底线不是「写得好」,而是「坏了能立刻知道、改了不敢出错」。本章从测试金字塔讲起,带你把单元测试、组件测试、E2E 测试、TDD、代码评审与重构全部落地成可执行的习惯。学完本章,你交付的不再是「能跑的代码」,而是「有人护航的代码」。

1为什么需要测试:测试金字塔

先建立全局观,再谈具体工具。

1.1 没有测试的代码,只证明「当前能跑」

软件的本质是「变化」。需求会变、框架会升、人会走。没有测试保护的代码,每改一行都要靠手动点页面验证,改动越大、回归成本越高,最后所有人都不敢动老代码——代码就「腐烂」了。测试的核心价值有三条:

  • 回归保护:重构、升级依赖、加功能时,测试替你确认「旧的没坏」。
  • 设计反馈:写测试时发现代码难以测试,往往是耦合过深、职责不清的信号——测试是免费的设计评审。
  • 文档作用:一个测试就是一份「这个函数/组件应该怎么用、什么输入什么输出」的可执行文档。
💡 判断测试好坏的唯一标准 不是覆盖率数字,而是:「我在不知道实现细节的情况下改代码,测试能不能告诉我哪里错了?」如果测试和实现绑得太死(连内部函数都被 mock),那测试保护的是实现,不是行为。

1.2 测试金字塔(以及它的进化版:测试奖杯)

层级是什么成本速度典型工具数量占比
单元测试测试一个函数/模块的纯逻辑低极快(毫秒级)Jest / Vitest最多(60%+)
组件/集成测试测试组件与组件、与 API、与 store 的协作中快(秒级)Testing Library / MSW较多(30%+)
端到端测试启动真浏览器,模拟用户完整操作高慢(分钟级)Playwright / Cypress少(5%~10%)

经典金字塔主张「底层多、顶层少」。Kent C. Dodds 提出「测试奖杯」:把集成测试放在最重要的位置——因为用户交互的大多数 bug 出在「模块协作」这一层,而不是单个函数内部。两种模型不冲突:你的绝大多数业务逻辑应该用单元测试兜底,但「这个页面能不能正常完成一次操作」必须靠组件/集成测试覆盖。

⚠️ 最常见的错误配置 团队只有两种测试:全栈 E2E(跑一遍要 20 分钟)或全是「给实现细节拍照」的脆弱单测。前者慢到没人跑,后者改了代码就碎。正确姿势:单测管逻辑、组件测管交互、E2E 只守护「核心用户旅程」(注册、下单、登录等少数几条黄金路径)。

1.3 测试替身(Test Double):Dummy / Fake / Stub / Spy / Mock

测试时我们要隔离外部依赖(网络、数据库、定时器)。「替身」有五种,用错会写出脆弱的测试:

替身作用例子
Dummy只为了占位,从不被真正调用函数参数里传个空对象
Fake有真实实现的简化版(内存版数据库)用内存 Map 实现一个假的用户仓库
Stub返回预设值,让被测代码走通某条分支fetch 恒返回 { ok:true, data:[] }
Spy记录「是否被调用、调用参数」,不改行为vi.spyOn(obj,'log') 断言被调了 3 次
Mock替换整个实现 + 断言调用(Spy 的加强版)vi.mock('./api') 后断言 api.fetchUser 被调用
✅ 选型口诀 能传真实值就传真实值 → 需要简化就上 Fake → 只要控制返回值就 Stub → 只关心调用就 Spy → 最后才 Mock(Mock 越多,测试离真实越远,越脆弱)。
2单元测试:Jest / Vitest 从零到熟

掌握框架语法不是重点,重点是「测什么、怎么断言、怎么隔离」。

2.1 环境搭建(Vitest 为例,Vite 项目原生友好)

# 安装
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"。

2.2 第一个测试:断言匹配器要背熟

被测代码——一个带边界条件的金额格式化函数:

// 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部分匹配
💡 写断言的纪律 一条断言覆盖一个行为,失败信息才精确。别写「巨型断言」——它崩的时候你根本不知道哪坏了。

2.3 异步测试:定时器、Promise、时间

测异步有三种场景,写法完全不同:

// 场景一: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)) 真等。

2.4 Mock 三种粒度:函数 / 模块 / 全局

// 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 }))
))
❌ 别 mock 你自己的代码,要 mock 边界 只 mock「外部世界」(网络、时钟、浏览器 API)。mock 自己团队写的模块 = 测试的是「Mock 的剧本」而不是真实逻辑,改内部实现测试就崩。这正是上一章学的「依赖注入」的用武之地:把依赖传进来,测试时传 Fake,就不需要 vi.mock 了。

2.5 覆盖率:看懂四个数字

指标含义要警惕的假象
行覆盖率被执行的代码行占比执行到 ≠ 断言到
分支覆盖率if/else、switch 各分支是否都走过最重要但常被忽略
函数覆盖率被调用的函数占比纯展示函数极易拉高
语句覆盖率所有语句执行占比与行覆盖率接近
💡 覆盖率策略 团队设「红线」防止倒退(比如 lines ≥ 80、branches ≥ 75),但别追 100%——最后 20% 往往是模板代码和错误分支,性价比极低。重点盯分支覆盖率:没覆盖到的 else 分支,就是线上才爆的雷。
3组件测试:从用户视角出发

Testing Library 的核心信条:测「用户怎么用」,不测「代码怎么写」。

3.1 三个 API 的区别:render / fireEvent / userEvent

// 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 的返回值传来传去。

3.2 查询优先级:从最像用户的方式开始

  1. getByRole(按钮、标题、链接都带语义角色)——最接近无障碍视角,首选。
  2. getByLabelText——表单输入框。
  3. getByPlaceholderText——只有 placeholder 的输入框。
  4. getByText——非交互文本。
  5. getByTestId——最后的手段,相当于给元素起测试专用名(data-testid)。
✅ 对应前缀的含义 getBy*(元素必须存在,否则立刻失败)/ queryBy*(可能不存在,返回 null)/ findBy*(异步等待出现,配合 await)。断言「消失」用 queryByText(...).not.toBeInTheDocument()——用 getBy 会先抛异常而不是给你断言机会。

3.3 异步加载组件测试 + MSW 拦截真实请求

测「组件 + 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()
})
⚠️ act() 警告是什么意思 组件里 setTimeout 之后才更新的状态,React 会警告「更新没有包在 act() 里」。这不是报错,但说明断言时机不对。解法:用 findBy* 等异步断言,而不是 getBy* 后立刻断言。别为了消警告随便套 act(),先检查是不是断言顺序错了。

3.4 快照测试:用,但只在「纯展示」场景

expect(renderer.create(<Badge kind="danger">删除</Badge>).toJSON()).toMatchSnapshot()
💡 快照的正确用法 快照的本质是「把输出存起来,变了就报警」。它极其敏感:加个注释、改个 className 都会碎。适用场景:代码生成器、配置序列化、纯静态组件。业务组件别用快照——用行为断言(存在、值、事件)才有保护力。
4端到端测试:Playwright 实战

E2E 是「最后一道防线」,只守护最重要的用户旅程。

4.1 Playwright 快速上手

# 安装(自带浏览器下载)
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,
  },
})

4.2 一条黄金路径测试:搜索 → 加入购物车 → 下单

// 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()
})
💡 E2E 的黄金法则 ① 选择器只认「用户看得见的东西」:文本、角色、label——绝不依赖 class 和 data-testid(那是实现细节,改样式就碎);② 用「等待可见」代替固定 sleep;③ 测试数据要自给自足(测试账号、测试商品),别依赖生产数据。

4.3 E2E 的数量与时机

  • 数量:一个团队维护 20~50 条黄金路径已经是极限,再多就变成「维护测试」而不是「用测试」。
  • 时机:关键流程(登录、支付、数据导出)必须覆盖;营销页、后台管理页这类低风险页面不值得 E2E。
  • CI:E2E 放 merge 后的流水线(或 nightly),别放每次 push——分钟级的等待会让大家开始绕过它。
5TDD:红 → 绿 → 重构

先写测试再写实现,用「失败」驱动设计。

5.1 TDD 循环与它的真正价值

  1. 红(Red):先写一个会失败的测试(描述期望行为)。
  2. 绿(Green):写最简实现让测试通过——不追求优雅,只求通过。
  3. 重构(Refactor):在测试保护下清理代码,让设计变好。

TDD 的价值不是「测试多」,而是倒逼你先想清楚行为:写测试之前你得定义「输入是什么、输出是什么、边界在哪」。这个思考过程本身就在帮你做接口设计——很多人在写测试时才发现「这个函数职责太杂了,没法测」,于是拆开,代码反而变好了。

⚠️ TDD 不是银弹 对「业务规则明确、可枚举输入输出」的逻辑(金额、日期、权限判断、状态机),TDD 性价比极高;对「UI 视觉、探索性原型、第三方联调」,先写测试会拖慢节奏。理性做法:核心业务逻辑用 TDD,外围胶水代码事后补测试。

5.2 一个完整 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
}
✅ 注意重构后测试没动 这就是 TDD 的回报:测试保护的是「行为」(100 元减 10),不是「写法」(if 阶梯)。你随便改实现,只要行为不变,测试永远绿——于是你才敢大胆重构。
6静态质量:Lint、格式化、类型,形成护城河

在代码进仓库前,把 90% 的低级错误挡在门外。

6.1 三层防线各自管什么

工具管什么典型错误属于
ESLint代码「语义」规则未使用变量、危险写法、React Hooks 依赖逻辑正确性
Prettier代码「格式」缩进、引号、换行、分号风格统一
TypeScript「类型」约束参数类型不匹配、undefined 访问、拼错属性编译期正确性
💡 分工铁律 ESLint 和 Prettier 的职责会重叠(比如引号风格),永远让 Prettier 管格式、ESLint 用 eslint-config-prettier 关掉格式类规则,避免打架。

6.2 值得单独立规则的 ESLint 配置

// 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。规则的价值在于「统一」,不在「多」。

6.3 TypeScript:把「可能出错的运行时」变成「编译期」

// 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) { /* 这里才是安全的访问点 */ }

6.4 Git Hooks:让质量检查自动发生

// 用 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
✅ 为什么用 lint-staged 全仓库 lint 在大型项目里要几十秒,没人受得了;lint-staged 只处理你这次改的文件,毫秒级完成——「快」是工具被长期使用的前提。
7代码评审(Code Review):质量的双保险

评审不是挑刺,是团队知识的流动管道。

7.1 评审看什么:一份可执行的检查清单

维度要问的问题
正确性边界条件(空、0、null、超长)处理了吗?并发/竞态考虑了吗?
可读性名字能自解释吗?一段逻辑超过 20 行了吗?我 3 个月后再看能懂吗?
可测试性新增逻辑有测试吗?测试断言的是行为还是实现?
性能循环里做了不必要的计算吗?重复渲染可以避免吗?
安全用户输入被转义了吗?权限校验在正确的位置吗?
一致性命名风格、错误处理方式与仓库其他代码一致吗?
范围这个 PR 只做了描述里的事吗?(scope creep 是最常见的评审杀手)

7.2 提意见的艺术:对事不对人

  • 用「提问」代替「命令」:「这里写错了」 → 「这里如果 list 是空数组会发生什么?我们考虑过这个情况吗?」
  • 区分意见等级:Blocking(必须改,否则线上有风险)/ Should(建议,明显更好)/ Nit(风格小点,不改也行)。等级不清,作者无法判断轻重。
  • 先肯定后建议:评审不是批斗会,指出好的设计同样重要——那是团队标准的来源。
  • 小 PR 原则:单个 PR 尽量 < 400 行,超过就拆。评审质量随 PR 变大急剧下降。
💡 评审的最佳实践节奏 提交 24 小时内完成评审(拖久了上下文就凉了);作者在 PR 描述里写清楚「为什么这么改、怎么测的、有什么取舍」——评审者把时间花在判断上,而不是考古上。
8重构:在不改变行为的前提下改善设计

重构不是重写。有测试护航的重构叫「重构」,没测试的叫「赌博」。

8.1 重构的时机与前提

  • 时机(三过原则):同样的代码出现第三次时(Rule of Three)就该抽出来了;改功能时顺手重构(童子军原则:离开时比来时干净);大重构配专项,别夹带在功能 PR 里。
  • 前提:目标代码有测试保护。没有测试?先写「特征测试」(characterization test)——记录当前行为,再动手。
  • 节奏:一步一测,每次改完立刻跑测试,红了就回滚这一步。重构不是「一次性大改」,是几十个安全的小步。

8.2 四个最高频的重构手法

// ① 提取函数(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,各自带测试
❌ 重构禁忌 ① 不要和「加功能」混在一起——两者一起做,出 bug 分不清是谁的锅;② 不要动「没有测试」的代码还不补测试;③ 不要顺手「优化」无关代码——范围失控是重构翻车的第一原因。
9坑清单:测试与质量的 10 个深坑

每一条都是真实事故的浓缩。

  • 测试依赖执行顺序:测试 A 改了全局状态影响测试 B。解法:beforeEach 重置一切,测试间零共享。
  • mock 了自己的内部函数:断言了假实现,真实逻辑出 bug 测试照样绿。解法:只 mock 边界(网络/时钟),内部逻辑走真实链路。
  • 快照测试当主力:改个 className 全仓库测试红。解法:行为断言为主,快照只留给纯生成器。
  • E2E 用 CSS 选择器:前端重构后 E2E 全崩。解法:getByRole/getByText 等「用户可见」选择器。
  • 测试里真等 setTimeout:让测试慢 10 倍。解法:假定时器 vi.useFakeTimers。
  • 覆盖率追 100%:最后 20% 花了 80% 的时间,测试全是「为覆盖而写」的僵尸断言。解法:红线 80% + 分支覆盖率重点盯。
  • lint 规则太多太严:新成员提交被规则淹没,最后绕过规则(disable 注释满天飞)。解法:少数 error 规则 + 持续治理。
  • any 滥用:as any 一多,TS 就退化成 JS。解法:no-explicit-any 规则 + 代码评审盯新增 any。
  • PR 太大:1500 行的 PR 没人认真看,评审流于形式。解法:小步提交,< 400 行一个 PR。
  • 把重构和功能混在一个 PR:出 bug 无法定位,回滚波及新功能。解法:严格分离,先重构(独立 PR、测试绿)再加功能。
10实战项目(选一个做透)

把本章所有武器在一个真实项目里串起来。

项目 A:给「待办清单」装上完整质量体系(推荐先做)

  1. 建 Vitest + Testing Library + MSW + Playwright 完整工程,lint-staged + husky 就位。
  2. 业务逻辑层(新增/编辑/过滤/状态切换/截止日期判断)用 TDD 写单元测试,覆盖所有边界。
  3. 组件层:用 Testing Library 测「用户操作 → UI 变化」完整交互,含异步加载与错误态。
  4. 用 MSW 模拟后端,写 3 条 E2E 黄金路径(新建 → 勾选完成 → 删除)。
  5. 配置覆盖率红线(lines ≥ 85、branches ≥ 80),CI 中强制执行。
  6. 故意引入一个隐蔽 bug(如时间边界差一天),验证测试能抓住它。
✅ 通关标准:改任何业务逻辑,3 秒内测试告诉你哪里坏了;新同学花 10 分钟看测试就能理解系统行为。

项目 B:给「团队旧项目」做一次受控重构

  1. 挑一个你熟悉的遗留模块,先用「特征测试」锁定当前行为(输入输出快照)。
  2. 按「提取函数 → 参数对象 → 卫语句 → 拆模块」顺序逐步重构,每步跑测试。
  3. 记录重构前后:行数、嵌套深度、测试覆盖、可测性评分的变化。
  4. 写一份《重构报告》:改了什么、为什么、如何验证没有行为变化。
✅ 通关标准:重构后所有特征测试零改动通过;原模块的 bug 率肉眼可见下降(有数据佐证)。
11通关自检题

每题都能讲清楚才算过关。

1

测试金字塔和测试奖杯有什么区别?为什么说「集成测试是最重要的层级」?

答案:金字塔强调底层多、顶层少(单元 > 集成 > E2E);奖杯模型主张集成测试应占最大比例。集成测试之所以最重要,是因为大多数 bug 出在「模块协作」这一层:单个函数内部很少出错,但组件之间传递数据、调用 API、状态同步这些交互点是高频出错区,而且集成测试比 E2E 快、比单元测试覆盖更真实的链路,综合性价比最高。

💡 深度分析
Kent C. Dodds 提出测试奖杯的核心洞察是:单元测试天然无法覆盖「集成 bug」(比如 A 输出格式改了但 B 没跟着改),E2E 虽然能覆盖但慢且脆弱。集成测试正好在这个中间地带——它跑的是「真实组件协作」,速度又是秒级,是性价比最高的投入区。实际落地时不必二选一:核心业务规则用单元测试精确覆盖,页面行为和接口协作用集成测试守护,少数黄金路径用 E2E。

2

Dummy、Fake、Stub、Spy、Mock 五种测试替身分别什么时候用?为什么「Mock 越多测试越脆弱」?

答案:Dummy 占位不调用(传空对象);Fake 有简化真实实现(内存版 Repository);Stub 返回预设值走通分支;Spy 记录调用信息不改行为;Mock 替换实现并断言调用方式。Mock 越多越脆弱的原因:Mock 把测试和「实现细节」(调了几次、传了什么参数)绑定,一旦内部重构(改函数名、加中间件),即使行为没变,测试也会碎——这意味着你保护的不是「行为」而是「写法」。

✅ 选型降级链
能用真实值就用真实值 → 需要简化就上 Fake → 控制返回值用 Stub → 记录行为用 Spy → 最后才 Mock。这条链离真实越远、离实现越近、越容易碎。比如测「用户注册后发短信」,可以用 Stub 返回 {sent:true} 走通注册流程,完全不需要 Mock 去断言「sendSms 被调了一次」。

3

写出一个「假定时器 + 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),混用会导致时序混乱。

4

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() 警告消失——组件异步更新后不再报警告。

5

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 不一致,测试「走通」了但线上照样报错。

✅ MSW 的优势类比
vi.mock 是「找人演戏」,MSW 是「搭个假舞台」。前者直接替换了演员,后者让真演员上台但搭了假布景——演员会做出真实反应(比如 404 会触发 catch 分支),这一点演戏完全模拟不到。

6

E2E 测试的选择器为什么不能用 class?Playwright 的自动等待和固定 sleep 有什么区别?

答案:class 是「实现细节」——改一次样式或重构一次 HTML,所有用 class 的选择器全崩。E2E 必须选「用户看得见」的东西:文本、ARIA 角色、可见 label。Playwright 自动等待是「断言级别」的轮询——它内部每隔一小段时间检查一次条件,要么超时失败要么继续;固定 sleep 是盲等一段固定时间。区别:① 自动等待更早完成(条件满足立刻往下走),固定 sleep 必须等满;② 自动等待消除了「时快时慢」导致的偶发失败,固定 sleep 在网络抖动时会随机崩;③ 自动等待传达意图(等这个元素出现),sleep 只是让代码「别跑太快」。

💡 Playwright 自动等待的三件事
① 元素在 DOM 中存在;② 元素可见(display/visibility 非 none/hidden);③ 元素稳定(不再被动画移动);④ 元素 enabled(未被 disabled)。这四个条件都满足才算「可交互」,然后才开始操作。所以 page.getByRole("button").click() 不用写任何 sleep,框架比你更懂什么时候该点。

7

TDD 的三步循环是什么?「红」这一步强迫你思考什么?TDD 不适用哪些场景?

答案:三步循环:Red(先写失败的测试,描述期望行为)→ Green(写最简实现让测试过,不求优雅)→ Refactor(在测试保护下清理代码改善设计)。「Red」强迫你思考:接口长什么样、输入输出是什么、边界条件如何。这一步其实就是「设计评审前置」——你会发现「这个函数职责太多没法测」,于是拆开,代码反而更好。不适用场景:① UI 视觉微调(先看效果再写逻辑);② 探索性原型(需求不确定、要快速试错);③ 与第三方联调(依赖外部系统响应不可控);④ 纯配置/纯展示组件(行为值不值得 TDD)。

⚠️ TDD 的最大误区
很多人把 TDD 当成「先写测试」的纪律,忘了核心是「先想清楚行为」。如果连期望行为都没想明白就写测试,那就是「先糊一层测试再糊一层实现」——双重浪费。正确做法是先在纸上/脑里定义「给定 100 块钱,期望减 10」,再写测试,再写实现。

8

覆盖率四个指标各是什么?为什么「分支覆盖率」最值得盯?追 100% 为什么是误区?

答案:四个指标:① 行覆盖率(被执行的行占比);② 分支覆盖率(if/else、switch 各分支是否都走过);③ 函数覆盖率(被调用的函数占比);④ 语句覆盖率(所有语句执行占比,与行覆盖率接近)。分支覆盖率最值得盯:因为「走进了 else 分支」和「没走进 else 分支」在覆盖率报告上看起来一样(代码行都跑过了),但逻辑完全不同——一个没覆盖的 else 分支就是一颗定时炸弹。追 100% 是误区因为:最后 20% 往往是模板代码和错误处理分支,性价比极低;而且 100% 覆盖 ≠ 100% 正确(你执行到了不等于断言对了)。

✅ 覆盖率红线策略
团队设「防倒退」红线而不是「冲数字」目标:lines ≥ 80、branches ≥ 75 是合理基线。重点盯新增代码不要低于红线,老代码改善靠「童子军原则」(改到哪覆盖到哪)自然回升。分支覆盖率达到 80%+ 意味着大多数条件分歧都经过测试,这是最有价值的区间。

9

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 管。

💡 分工铁律
ESLint = 逻辑对错;Prettier = 视觉统一;TS = 类型安全。三者顺序:先 TS 编译(类型错直接报错)→ ESLint 检查语义 → Prettier 统一格式。在 lint-staged 里可以并行跑 ESLint 和 Prettier,但它们分别解决的问题完全不同——混为一谈会导致规则越多越混乱。

10

Code Review 时如何区分 Blocking / Should / Nit?为什么 PR 超过 400 行评审质量会急剧下降?

答案:三个等级:① Blocking(必须改,否则有线上风险——安全漏洞、数据边界、并发问题等);② Should(建议改,明显更好但不改也不出 bug——命名优化、重复提取);③ Nit(小建议,不改也行——风格偏好、可选优化)。不提等级的问题,作者无法判断轻重缓急。PR 超过 400 行评审质量下降的原因:① 人类工作记忆有限,超过 400 行难以在脑中构建完整上下文,容易忽略隐藏问题;② 长 PR 评审耗时翻倍,催生「先 approve 再说」的心态;③ 大 PR = 多职责混杂,可能把重构和功能修改混在一起,回滚风险高。

✅ 小 PR 原则落地
不是教条式地「不超过 400 行」,而是「一个 PR 只做一件事」。如果新功能需要 800 行改动,先提一个重构 PR(拆模块、加测试),再提一个功能 PR。两个 400 行的 PR 比一个 800 行的 PR 评审效率高 3 倍以上——因为评审者可以在第一个 PR 里专注「结构对不对」,在第二个里专注「逻辑对不对」。

11

重构的三大禁忌是什么?「特征测试」是什么、为什么重构前要补它?

答案:三大禁忌:① 不写测试就动代码(没测试的重构是赌博,不是重构);② 重构和加功能混在一个 PR(出 bug 分不清是谁的锅、回滚牵连新功能);③ 顺手「优化」无关代码(范围失控是重构翻车第一原因)。特征测试(Characterization Test):输入一个值,把当前输出记录下来并断言「以后永远等于这个值」。作用:它不验证「正确性」,只锁定「当前行为」。重构前补特征测试的原因:重构的定义是「不改行为只改善设计」,如果你连「当前行为是什么」都没锁定,你凭什么说「行为没变」?有了特征测试,重构后它零改动通过 = 行为没有变化。

❌ 典型翻车场景
看到一段「看起来奇怪」的代码,觉得能优化,改了,「结果一切正常」——提交。两个月后用户反馈少了 0.5 块钱。复盘发现那段「奇怪」的代码是在兼容老移动端浮点 bug,被「重构」掉了。如果当时写了特征测试,这种「看似无害」的改动当场就会被测试拦下来。

12

你所在(或假设的)团队要建立测试体系,从零开始你会按什么顺序落地(工具 → 规范 → 习惯)?给出理由。

答案:

  1. 第 1 周:工具就位。TS strict 模式 + ESLint(errors-only 起步)+ Prettier + lint-staged + husky 四件套。理由:这是「防倒退」最低成本动作——不改任何行为,只拦住低级错误和格式分歧。一旦团队尝到「提交不被 CI 打回」的甜头,就愿意投入下一步。
  2. 第 2 周:单元测试框架。Vitest / Jest 接入,挑一个纯工具函数模块用 TDD 跑通第一个红色→绿色→重构循环。理由:先让团队「眼见为实」——测试抓住一个 bug 的案例比任何说教都有说服力。工具函数性价比高:逻辑单一、无依赖、易写易跑。
  3. 第 3~4 周:组件测试。Testing Library + MSW,覆盖核心业务页面。理由:集成测试守护的是「用户最经常遇到的页面状态」——空白态、加载态、错误态、正常展示态。这一层投入到 20% 就能避免 60% 的线上 bug。
  4. 第 5 周:E2E 纳入 CI。Playwright 3~5 条黄金路径,merge 后触发。理由:E2E 是最后防线,慢所以不放每次 push;只覆盖「登录、支付、核心流程」等一旦线上就是 P0 事故的场景。
  5. 第 6 周:覆盖率红线 + CR 清单。lines ≥ 80、branches ≥ 75 作为 CI 红线;团队讨论并固化 CR 检查清单(正确性、性能、安全、范围等维度)。理由:红线让「测试不退化」,CR 清单让「评审不流于形式」——两者共同把个人自律变成团队契约。
  6. 第 7 周起:师徒配对 Review。每轮 PR 至少一个老人 + 一个新人结对 review。理由:测试和代码质量最终靠「习惯」而不是「工具」。师徒配对让新人通过实际 PR 学会「什么场景写什么测试、CR 时看哪里」——这些隐性知识文档教不了,必须靠人传人。
💡 核心原则:每一步都能独立产生价值
如果第 1 周的工具就位就被团队骂「卡提交」,第 2 周就不会有人配合。所以顺序遵循「最小阻力 + 最快正反馈」:先跑起来 → 抓到 bug → 建立信心 → 再加重量。测试体系的建设本质上是「行为改变」,急不得,但每一步都要让人尝到甜头。