首页  |  学习总览  |  ← 上一章:性能优化 阶段八 · 第 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

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

2

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

3

写出一个「假定时器 + debounce」测试,解释为什么不能用真实等待。

4

Testing Library 的查询优先级是什么?为什么 getByRole 排在第一位?getBy / queryBy / findBy 的区别?

5

MSW 和 vi.mock 的本质区别是什么?为什么「在组件测试里用 MSW 拦截 fetch」比「mock 掉 fetch 函数」更接近真实?

6

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

7

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

8

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

9

ESLint、Prettier、TypeScript 的职责边界如何划分?为什么 ESLint 要配 eslint-config-prettier?

10

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

11

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

12

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