Skip to content

Lesson 25:单元测试 — Vitest + Testing Library ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:120~180 分钟(首次学习)
  • 先修要求:完成 L23–24,保留购物车 Store、订单状态工具与商品 Actions
  • 学习产出:测试组件行为、真实购物车接口与订单状态规则,区分单测和集成验证
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:为组件、购物车 Store 和纯函数编写单元测试,学习隔离 Server Action 的外部依赖。

📦 本节产出:覆盖本课列出的行为与失败分支的测试套件;这些测试不能证明整个支付或认证流程正确。

一、为什么要写测试? ​

项目越大,每次修改代码都可能引发"蝴蝶效应"——改了 A 模块,B 模块莫名崩了。 测试的核心价值是:给你修改代码的勇气。

本节课聚焦 单元测试 (金字塔底层),下节课做 E2E 测试 (金字塔顶层)。


二、安装 Vitest 与 Testing Library ​

bash
npm install -D vitest@4 @vitest/coverage-v8@4 vite@7 @vitejs/plugin-react@5 jsdom@27 @testing-library/react@16 @testing-library/jest-dom@6 @testing-library/user-event@14

本课使用 Node.js 24。vitest 与 @vitest/coverage-v8 保持相同版本并提交 lockfile。Vitest 不能直接渲染异步 Server Component;L22 的 async 商品页应在运行中的 Next.js 应用里做 E2E。本节只渲染同步组件,Server Actions 则按函数边界隔离依赖。Next.js 15 测试说明。

创建 vitest.config.mts,明确使用 ESM 配置:

ts
// vitest.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',      // 模拟浏览器环境
    globals: false,            // 每个文件显式导入测试 API
    include: ['src/**/*.{test,spec}.{ts,tsx}'], // 不收集下节的 Playwright tests/
    setupFiles: ['./src/test/setup.ts'],
  },
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    }
  }
})
ts
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(cleanup) // globals:false 时显式清理每次渲染

在 package.json 的现有 scripts 中合并,保留 build/lint 等脚本:

json
"scripts": {
  "test": "vitest",
  "test:run": "vitest run",
  "test:coverage": "vitest run --coverage"
}

三、测试组件 ​

3.1 测试一个简单的组件 ​

先用独立小组件练习查询和断言;它不等于已经测试了实际商品列表。金额仍按课程约定接收整数分。

tsx
// src/components/__tests__/ProductCard.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'

// 假设我们有一个 ProductCard 组件
function ProductCard({ name, price }: { name: string; price: number }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>¥{(price / 100).toFixed(2)}</p>
    </div>
  )
}

describe('ProductCard', () => {
  it('应该正确显示商品名称和价格', () => {
    render(<ProductCard name="React 手册" price={9900} />)
    
    expect(screen.getByText('React 手册')).toBeInTheDocument()
    expect(screen.getByText('¥99.00')).toBeInTheDocument()
  })
})

运行测试:

bash
npm test

3.2 测试用户交互 ​

下面仍是独立练习按钮,用于学习事件与间谍断言;L23 的真实加购按钮还涉及 Store 与持久化恢复,需要另写集成测试。

tsx
// src/components/__tests__/AddToCartButton.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'

function AddToCartButton({ onAdd }: { onAdd: () => void }) {
  return <button onClick={onAdd}>加入购物车</button>
}

describe('AddToCartButton', () => {
  it('点击时应该调用 onAdd 回调', async () => {
    const user = userEvent.setup()
    const mockOnAdd = vi.fn()  // 创建一个间谍函数
    render(<AddToCartButton onAdd={mockOnAdd} />)
    
    await user.click(screen.getByRole('button', { name: '加入购物车' }))
    
    expect(mockOnAdd).toHaveBeenCalledTimes(1)
  })
})

四、测试自定义 Hook ​

React Hook 应在组件或自定义 Hook 中调用,测试可用 renderHook 建立组件环境。Zustand 还提供 getState/setState 等非 Hook API;这里通过渲染验证订阅更新,并使用 L23 的真实接口(items[].productId,没有 totalPrice() 方法):

ts
// src/hooks/__tests__/useCartStore.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from '@/store/useCartStore'

beforeEach(() => {
  useCartStore.getState().clearCart()
  useCartStore.persist.clearStorage()
})

describe('useCartStore', () => {
  it('新增商品并生成结算标识', () => {
    const { result } = renderHook(() => useCartStore())
    act(() => result.current.addItem({ id: '1', name: 'React 手册', price: 9900 }))
    expect(result.current.items).toEqual([
      { productId: '1', name: 'React 手册', price: 9900, quantity: 1 },
    ])
    expect(result.current.checkoutKey).toEqual(expect.any(String))
  })

  it('相同商品合并数量,购物车变化后更新结算标识', () => {
    const { result } = renderHook(() => useCartStore())
    const product = { id: '1', name: 'React 手册', price: 9900 }
    act(() => result.current.addItem(product))
    const firstKey = result.current.checkoutKey
    act(() => result.current.addItem(product))
    expect(result.current.items).toHaveLength(1)
    expect(result.current.items[0].quantity).toBe(2)
    expect(result.current.checkoutKey).not.toBe(firstKey)
    expect(result.current.items.reduce((sum, item) => sum + item.price * item.quantity, 0)).toBe(19800)
  })

  it('拒绝无效数量,并在清空时清除结算标识', () => {
    const { result } = renderHook(() => useCartStore())
    act(() => result.current.addItem({ id: '1', name: 'A', price: 10000 }))
    act(() => result.current.updateQuantity('1', -1))
    expect(result.current.items[0].quantity).toBe(1)
    act(() => result.current.clearCart())
    expect(result.current.items).toEqual([])
    expect(result.current.checkoutKey).toBeNull()
  })
})

五、测试纯函数 (工具函数) ​

纯函数是最容易测试的,因为相同输入永远产生相同输出:

先提取一个用于展示金额的纯函数;它的输入单位是分:

ts
// src/lib/format-price.ts
export function formatPrice(cents: number): string {
  return `¥${(cents / 100).toFixed(2)}`
}

测试导入实际模块,同时复用 L23 的状态转换表。不要在测试文件中复制一份被测实现,否则生产代码变坏时测试仍可能通过:

ts
// src/lib/__tests__/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from '@/lib/format-price'
import { canTransition } from '@/lib/order-status'

describe('formatPrice', () => {
  it('将整数分格式化为元', () => {
    expect(formatPrice(9900)).toBe('¥99.00')
    expect(formatPrice(990)).toBe('¥9.90')
  })
})

describe('canTransition', () => {
  it('先进入支付处理中,不能从未发起支付直接跳到已支付', () => {
    expect(canTransition('pending', 'payment_pending')).toBe(true)
    expect(canTransition('pending', 'paid')).toBe(false)
    expect(canTransition('payment_pending', 'paid')).toBe(true)
  })
  it('拒绝未知状态和越级发货', () => {
    expect(canTransition('unknown', 'paid')).toBe(false)
    expect(canTransition('pending', 'shipped')).toBe(false)
  })
})

六、练习 ​

  1. 为商品列表页的搜索栏组件编写测试:模拟用户输入"React"并点击搜索,验证 router.push 被调用且包含正确的查询参数。
  2. 为 L20 的 deleteProduct 编写测试,模拟 Prisma、requireAdmin 与 next/cache。至少断言未授权时不会执行删除、删除失败返回错误、成功后刷新路径。server-only 标记也需在 Vitest 环境中显式 mock。单元测试不验证 Next.js 的网络调用协议或数据库外键;这些留给集成/E2E。

📌 本节小结 ​

你做了什么你学到了什么
配置了 Vitest + Testing Library 测试环境测试金字塔与各层测试的定位
编写了组件渲染和交互测试render / screen / userEvent API
测试了 Zustand Store 的行为renderHook + act 测试自定义 Hook
测试了纯函数和工具逻辑实际模块导入与间谍函数 vi.fn()

七、覆盖率与测试分层策略 ​

覆盖率可帮助发现未执行的分支,但不能衡量断言质量。先运行 npm run test:coverage 建立基线,再逐步设置门槛。下面是合并到已有 test 配置中的可选配置,不要覆盖 plugins、alias、setupFiles 和 include:

ts
coverage: {
  provider: 'v8',
  reporter: ['text', 'html'],
  include: ['src/store/useCartStore.ts', 'src/lib/order-status.ts', 'src/lib/format-price.ts'],
  thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
},

阈值位于 coverage.thresholds,不是 coverage.lines 等字段。当前示例未覆盖购物车所有分支,直接启用上述目标可能使检查失败;应补测试后再设为 CI 门槛。@vitest/coverage-v8 与 Vitest 版本要一致。Vitest 覆盖率配置。

建议分层目标:

  • 工具函数 / 状态机:高覆盖(85%+)
  • 业务组件:关注关键交互与回归路径
  • E2E:只覆盖关键主流程(登录、下单、支付回调)

八、测试夹具(Fixtures)与可维护性 ​

把重复测试数据抽到 fixtures,降低重复代码与维护成本:

ts
// src/test/fixtures/products.ts
export const productA = { id: 'p1', name: 'React 进阶', price: 19900 }
export const productB = { id: 'p2', name: 'Next.js 实战', price: 29900 }

将下面的导入加入已有购物车测试,并在用例中使用这些样本;不要创建只有导入、没有测试用例的 .test.ts 文件:

ts
// 合并到 src/hooks/__tests__/useCartStore.test.ts
import { productA, productB } from '@/test/fixtures/products'

实践建议:

  1. fixtures/ 放稳定样本数据
  2. factories/ 放可定制数据生成器
  3. 每个测试围绕一个行为,可用多个断言描述结果;不要为追求单个断言把同一流程拆散

九、常见测试反模式 ​

反模式风险改进方式
过度依赖快照(snapshot)UI 微调导致大量无效变更以行为断言为主(可见文本、按钮状态、回调触发)
测试实现细节(内部 state)重构即破坏测试只验证用户可观察行为
Mock 一切外部依赖与真实环境偏差大保留关键集成点,按边界选择性 Mock
单测替代 E2E关键流程未被端到端验证对登录/下单/支付等主链路补 E2E

十、进阶练习(补充) ​

  1. 为实际的 checkoutAction 增加失败分支测试:非法数量应在事务前被拒绝,事务异常应返回可读错误。数据库集成测试再验证同一 checkoutKey 不重复建单、并发购买不超卖。
  2. 为商品卡片组件添加可访问性断言:按钮是否具备可读名称(aria-label / 文本)。
  3. 输出覆盖率报告(HTML),识别最低覆盖模块并提出改进清单。

项目驱动 · 边写边学 · React 19