Skip to content

测试

说明

共 10 题,难度 ⭐ ~ ⭐⭐⭐,覆盖单元测试、集成测试、E2E 测试、TDD、Mock、覆盖率、组件测试、Hooks 测试等前端测试体系。

1. 单元测试、集成测试、E2E 测试的区别?测试金字塔? ⭐⭐

理解不同测试层级的定位和价值。

考察点:测试分层

测试金字塔

                    /\
                   /  \
                  / E2E \          少量(慢、贵、模拟真实用户)
                 /________\
                /          \
               / 集成测试    \      适量(模块间协作)
              /______________\
             /                \
            /    单元测试       \   大量(快、便宜、隔离)
           /____________________\

三种测试对比

维度单元测试集成测试E2E 测试
测试对象单个函数/组件多个模块协作完整用户流程
运行速度⚡ 毫秒级🔶 秒级🐌 分钟级
隔离程度完全隔离(Mock 外部依赖)部分集成无隔离(真实环境)
发现的问题逻辑错误接口不匹配、集成问题用户体验问题
维护成本
置信度低(不测集成)(最接近真实)
工具Vitest / JestVitest + MSWPlaywright / Cypress

各层测试示例

typescript
// 单元测试: 测试纯函数
describe('formatPrice', () => {
  it('格式化整数价格', () => {
    expect(formatPrice(1000)).toBe('¥10.00')
  })
  it('处理 0 值', () => {
    expect(formatPrice(0)).toBe('¥0.00')
  })
})

// 集成测试: 测试组件 + Hook + API 交互
describe('UserProfile', () => {
  it('加载并显示用户信息', async () => {
    server.use(
      http.get('/api/user/1', () => {
        return HttpResponse.json({ name: 'Alice', email: 'alice@test.com' })
      })
    )
    render(<UserProfile userId={1} />)
    expect(await screen.findByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('alice@test.com')).toBeInTheDocument()
  })
})

// E2E 测试: 测试完整登录流程
test('用户登录流程', async ({ page }) => {
  await page.goto('/login')
  await page.fill('[name="email"]', 'alice@test.com')
  await page.fill('[name="password"]', 'password123')
  await page.click('button[type="submit"]')
  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('.welcome')).toContainText('Welcome, Alice')
})

推荐比例

单元测试 : 集成测试 : E2E = 70% : 20% : 10%

但实际项目中:
  - 重业务逻辑 → 多写单元测试(工具函数、状态管理)
  - 重交互体验 → 多写集成测试(组件渲染 + 用户交互)
  - 重关键路径 → 多写 E2E(登录、支付、核心流程)

追问延伸

  • 测试奖杯(Testing Trophy)模型和测试金字塔有什么区别?(Kent C. Dodds 提出,更重视集成测试)
  • 什么是契约测试(Contract Testing)?前后端如何用 Pact 做契约测试?
  • 如何决定一个功能该写什么级别的测试?

2. Vitest 和 Jest 的区别?为什么 Vitest 越来越流行? ⭐⭐

对比主流测试框架。

考察点:测试工具

Vitest vs Jest

维度JestVitest
构建工具自带转换器复用 Vite 配置
速度较慢(每个文件独立 VM)更快(ESM + HMR)
ESM 支持🟡 实验性✅ 原生支持
TypeScript需要 ts-jest / babel✅ 开箱即用
配置jest.config.js(独立)vite.config.ts(复用)
兼容性API 成熟、生态最大兼容 Jest API
热更新✅ watch 模式极快
UI 界面vitest --ui

Vitest 配置

typescript
// vitest.config.ts (或直接在 vite.config.ts 中)
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'src/test/'],
    },
    css: true,
  },
})
typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup()
})

Vitest 独有特性

typescript
// ① 内联快照
it('生成问候语', () => {
  expect(greet('Alice')).toMatchInlineSnapshot(`"Hello, Alice!"`)
  // 快照直接写在测试文件中 → 更直观
})

// ② 类型测试
import { assertType, expectTypeOf } from 'vitest'

test('类型检查', () => {
  expectTypeOf(formatPrice).toBeFunction()
  expectTypeOf(formatPrice).parameter(0).toBeNumber()
  expectTypeOf(formatPrice).returns.toBeString()
})

// ③ 并发执行
describe.concurrent('并发测试', () => {
  it.concurrent('测试 A', async () => { /* ... */ })
  it.concurrent('测试 B', async () => { /* ... */ })
  // A 和 B 并行执行 → 速度更快
})

// ④ 基准测试
import { bench, describe } from 'vitest'

describe('排序性能', () => {
  bench('Array.sort', () => {
    [3, 1, 2].sort()
  })
  bench('自定义排序', () => {
    customSort([3, 1, 2])
  })
})

追问延伸

  • 从 Jest 迁移到 Vitest 需要改什么?(大部分代码不用改,改配置即可)
  • jsdomhappy-dom 环境有什么区别?
  • Vitest 的 --pool=forks--pool=threads 有什么区别?

3. 什么是 TDD?如何在实际项目中实践? ⭐⭐

理解测试驱动开发的流程和价值。

考察点:开发方法论

TDD 三步法

TDD = Test-Driven Development(测试驱动开发)

红 → 绿 → 重构:

  ① 🔴 Red: 先写一个失败的测试
     → 明确"这个功能该做什么"

  ② 🟢 Green: 写最少的代码让测试通过
     → 不要过度设计,只实现测试要求的功能

  ③ 🔵 Refactor: 在测试保护下重构代码
     → 优化代码质量,测试确保行为不变

  循环: Red → Green → Refactor → Red → ...

TDD 实战:实现 Todo List

typescript
// ① Red: 先写测试
describe('TodoList', () => {
  it('初始状态为空列表', () => {
    const todoList = new TodoList()
    expect(todoList.getAll()).toEqual([])
  })
})
// 运行 → ❌ TodoList is not defined

// ② Green: 最小实现
class TodoList {
  private todos: Todo[] = []
  getAll() { return [...this.todos] }
}
// 运行 → ✅ 通过

// ① Red: 添加下一个测试
it('添加一条 todo', () => {
  const todoList = new TodoList()
  todoList.add('学习 TDD')
  expect(todoList.getAll()).toEqual([
    { id: expect.any(String), text: '学习 TDD', completed: false }
  ])
})
// 运行 → ❌ add is not a function

// ② Green
add(text: string) {
  this.todos.push({ id: crypto.randomUUID(), text, completed: false })
}
// 运行 → ✅

// 继续: 完成功能、切换状态、删除...
it('切换 todo 完成状态', () => {
  const todoList = new TodoList()
  todoList.add('学习 TDD')
  const [todo] = todoList.getAll()
  todoList.toggle(todo.id)
  expect(todoList.getAll()[0].completed).toBe(true)
})

// ③ Refactor: 提取接口、优化命名
interface Todo {
  id: string
  text: string
  completed: boolean
}

TDD 的适用场景

✅ 适合 TDD:
  - 纯函数 / 工具库(明确输入输出)
  - 状态管理逻辑(Redux reducer / Zustand actions)
  - API 接口(请求 → 响应的映射)
  - 算法题

🟡 可选 TDD:
  - React 组件(先写 UI 再补测试也可以)
  - 复杂交互(有时先原型再测试更高效)

❌ 不太适合 TDD:
  - 探索性开发(需求不明确)
  - 纯 UI 样式(视觉测试更合适)
  - 第三方 API 集成(需要先了解 API 行为)

追问延伸

  • TDD 和 BDD(行为驱动开发)有什么区别?
  • TDD 在敏捷开发中的角色?
  • 如何说服团队采用 TDD?投入产出比如何衡量?

4. Mock 和 Stub 的区别?如何 Mock API 请求? ⭐⭐

掌握测试中的 Mock 技术。

考察点:Mock

Mock vs Stub vs Spy

Stub (桩):
  → 替换函数,返回预设值
  → 不关心调用方式,只控制返回值

Mock (模拟):
  → 替换函数,返回预设值 + 验证调用
  → 关心被调用了几次、用什么参数

Spy (间谍):
  → 不替换函数,保留原始行为
  → 只记录调用信息
typescript
// Stub
const getUser = vi.fn().mockReturnValue({ name: 'Alice' })

// Mock
const sendEmail = vi.fn()
await registerUser({ email: 'alice@test.com' })
expect(sendEmail).toHaveBeenCalledWith('alice@test.com', expect.any(String))
expect(sendEmail).toHaveBeenCalledTimes(1)

// Spy
const consoleSpy = vi.spyOn(console, 'log')
doSomething()
expect(consoleSpy).toHaveBeenCalledWith('done')
consoleSpy.mockRestore()

Mock 模块

typescript
// 模拟整个模块
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ name: 'Alice' }),
  fetchPosts: vi.fn().mockResolvedValue([]),
}))

// 部分模拟(保留其他导出)
vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    formatDate: vi.fn().mockReturnValue('2024-01-01'),
  }
})

// 模拟第三方库
vi.mock('axios', () => ({
  default: {
    get: vi.fn().mockResolvedValue({ data: { name: 'Alice' } }),
    post: vi.fn().mockResolvedValue({ data: { id: 1 } }),
  }
}))

MSW(Mock Service Worker)— 推荐

typescript
// MSW: 在网络层拦截请求 → 不需要 Mock fetch/axios
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ])
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({ id: 3, ...body }, { status: 201 })
  }),

  http.get('/api/users/:id', ({ params }) => {
    if (params.id === '999') {
      return HttpResponse.json({ message: 'Not found' }, { status: 404 })
    }
    return HttpResponse.json({ id: params.id, name: 'Alice' })
  }),
]

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// 测试中可以临时覆盖
it('处理服务器错误', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ message: 'Server Error' }, { status: 500 })
    })
  )
  render(<UserList />)
  expect(await screen.findByText('加载失败')).toBeInTheDocument()
})

MSW 的优势

vs 直接 Mock fetch/axios:
  ✅ 不侵入应用代码(拦截在网络层)
  ✅ 测试代码和生产代码走相同的 fetch/axios 路径
  ✅ 可以在浏览器和 Node.js 中使用(开发 + 测试)
  ✅ 支持 REST 和 GraphQL
  ✅ handler 可复用(测试 + Storybook + 开发环境)

追问延伸

  • vi.fn()vi.spyOn() 应该在什么时候用哪个?
  • 如何 Mock 定时器?(vi.useFakeTimers() + vi.advanceTimersByTime()
  • MSW 在 Storybook 中怎么用?(msw-storybook-addon

5. 如何测试 React 组件?Testing Library 的核心原则? ⭐⭐⭐

掌握 React 组件的测试方法。

考察点:组件测试

Testing Library 核心原则

"The more your tests resemble the way your software is used,
 the more confidence they can give you."

测试越像用户使用软件的方式 → 给你的信心越大

核心思想:
  ❌ 不要测试实现细节(state 的值、生命周期、内部方法)
  ✅ 测试用户看到什么、做了什么(渲染结果、交互行为)

查询优先级

Testing Library 推荐的查询优先级:

  ① getByRole       ← 最推荐(语义化,可访问性友好)
  ② getByLabelText  ← 表单元素
  ③ getByPlaceholderText
  ④ getByText       ← 按文本查找
  ⑤ getByDisplayValue
  ⑥ getByAltText    ← 图片
  ⑦ getByTitle
  ⑧ getByTestId     ← 最后手段(添加 data-testid)

// ✅ 好的查询
screen.getByRole('button', { name: '提交' })
screen.getByRole('heading', { level: 1 })
screen.getByLabelText('邮箱')

// ❌ 不好的查询
container.querySelector('.submit-btn')
screen.getByTestId('submit-button')

完整组件测试示例

typescript
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e) => {
    e.preventDefault()
    if (!email || !password) {
      setError('请填写所有字段')
      return
    }
    await onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">邮箱</label>
      <input id="email" type="email" value={email}
        onChange={e => setEmail(e.target.value)} />

      <label htmlFor="password">密码</label>
      <input id="password" type="password" value={password}
        onChange={e => setPassword(e.target.value)} />

      {error && <p role="alert">{error}</p>}
      <button type="submit">登录</button>
    </form>
  )
}

// 测试
describe('LoginForm', () => {
  const user = userEvent.setup()

  it('提交表单调用 onSubmit', async () => {
    const onSubmit = vi.fn()
    render(<LoginForm onSubmit={onSubmit} />)

    await user.type(screen.getByLabelText('邮箱'), 'alice@test.com')
    await user.type(screen.getByLabelText('密码'), 'password123')
    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'alice@test.com',
      password: 'password123',
    })
  })

  it('空表单提交显示错误', async () => {
    render(<LoginForm onSubmit={vi.fn()} />)

    await user.click(screen.getByRole('button', { name: '登录' }))

    expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段')
  })

  it('初始状态没有错误提示', () => {
    render(<LoginForm onSubmit={vi.fn()} />)
    expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  })
})

异步组件测试

typescript
it('加载用户列表', async () => {
  // MSW 已配置 /api/users 返回数据
  render(<UserList />)

  // 等待加载状态消失
  expect(screen.getByText('加载中...')).toBeInTheDocument()

  // 等待数据出现
  expect(await screen.findByText('Alice')).toBeInTheDocument()
  expect(screen.getByText('Bob')).toBeInTheDocument()

  // 验证加载状态消失
  expect(screen.queryByText('加载中...')).not.toBeInTheDocument()
})

追问延伸

  • userEventfireEvent 的区别?为什么推荐 userEvent
  • 如何测试 Portal 组件?(render 自动挂载到 document.body)
  • screen.getByRole 的 ARIA Role 有哪些?怎么查?

6. 如何测试自定义 Hooks? ⭐⭐

测试 React 自定义 Hooks。

考察点:Hooks 测试

renderHook

typescript
import { renderHook, act } from '@testing-library/react'

// 被测试的 Hook
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)
  return { count, increment, decrement, reset }
}

// 测试
describe('useCounter', () => {
  it('初始值为 0', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('支持自定义初始值', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('increment 增加计数', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('reset 重置到初始值', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.increment()
      result.current.increment()
    })
    expect(result.current.count).toBe(7)

    act(() => {
      result.current.reset()
    })
    expect(result.current.count).toBe(5)
  })
})

测试异步 Hook

typescript
function useUser(userId: string) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => { setUser(data); setLoading(false) })
      .catch(err => { setError(err); setLoading(false) })
  }, [userId])

  return { user, loading, error }
}

// 测试(配合 MSW)
describe('useUser', () => {
  it('加载用户数据', async () => {
    const { result } = renderHook(() => useUser('1'))

    expect(result.current.loading).toBe(true)
    expect(result.current.user).toBeNull()

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.user).toEqual({ id: '1', name: 'Alice' })
    expect(result.current.error).toBeNull()
  })

  it('userId 变化时重新请求', async () => {
    const { result, rerender } = renderHook(
      ({ userId }) => useUser(userId),
      { initialProps: { userId: '1' } }
    )

    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.user.name).toBe('Alice')

    rerender({ userId: '2' })

    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.user.name).toBe('Bob')
  })
})

测试需要 Context 的 Hook

typescript
function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

// 测试: 用 wrapper 提供 Context
it('从 ThemeContext 获取主题', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider value={{ theme: 'dark', toggle: vi.fn() }}>
      {children}
    </ThemeProvider>
  )

  const { result } = renderHook(() => useTheme(), { wrapper })
  expect(result.current.theme).toBe('dark')
})

it('没有 Provider 时抛出错误', () => {
  expect(() => {
    renderHook(() => useTheme())
  }).toThrow('useTheme must be used within ThemeProvider')
})

追问延伸

  • act() 是什么?为什么需要包裹状态更新?(确保 React 完成所有渲染和副作用)
  • 如何测试 useEffect 的清理函数?
  • Hooks 测试和直接渲染使用该 Hook 的组件测试,哪种更好?

7. 测试覆盖率该追求多少?覆盖率高就代表质量好吗? ⭐⭐

正确理解测试覆盖率的意义。

考察点:测试质量

覆盖率类型

四种覆盖率指标:

① 语句覆盖 (Statement Coverage):
   代码中有多少语句被执行过
   → 最基本的指标

② 分支覆盖 (Branch Coverage):
   if/else/switch 的每个分支是否都被执行
   → 比语句覆盖更严格

③ 函数覆盖 (Function Coverage):
   有多少函数被调用过

④ 行覆盖 (Line Coverage):
   有多少行代码被执行过

覆盖率的陷阱

javascript
// 100% 覆盖率但测试毫无价值:
function add(a, b) {
  return a + b
}

test('调用 add', () => {
  add(1, 2)    // ← 100% 覆盖!但没有 expect 断言
  // 不检查返回值 → 即使 add 返回 "hello" 也会通过
})

// 高覆盖率 ≠ 高质量
// → 覆盖率衡量的是"哪些代码被执行了"
// → 不衡量"执行的结果是否正确"

// 真正有价值的测试:
test('add 返回两数之和', () => {
  expect(add(1, 2)).toBe(3)
  expect(add(-1, 1)).toBe(0)
  expect(add(0, 0)).toBe(0)
})

推荐的覆盖率策略

通用建议:
  总体覆盖率 → 80% 以上(不追求 100%)
  关键业务逻辑 → 90%+ (支付、认证、核心算法)
  工具函数 → 95%+ (纯函数,容易测试)
  UI 组件 → 70%+ (关注交互行为,不测样式)
  配置代码 → 可以跳过

Vitest 覆盖率配置:
  // vitest.config.ts
  test: {
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        branches: 75,
        functions: 80,
        statements: 80,
      },
      exclude: [
        'src/**/*.stories.tsx',
        'src/**/*.d.ts',
        'src/test/',
      ],
    }
  }

CI 中强制覆盖率:
  → 覆盖率低于阈值 → CI 失败 → 不允许合并

比覆盖率更重要的指标

① 变异测试 (Mutation Testing):
   工具自动修改源代码(如 + 改成 -)
   → 如果测试仍然通过 → 说明测试不够好
   → 工具: Stryker

② 测试的可读性:
   好的测试 = 好的文档
   → 别人读测试就能理解功能规格

③ 测试的稳定性:
   Flaky Test(偶尔通过偶尔失败)比没有测试更糟
   → 浪费 CI 时间、降低团队信任度

追问延伸

  • 如何处理 Flaky Test?(隔离环境、固定时间、重试机制)
  • 变异测试具体怎么做?(Stryker 修改操作符/条件/返回值)
  • 如何在团队中推行测试文化?从哪里开始?

8. E2E 测试用 Playwright 还是 Cypress?如何编写稳定的 E2E 测试? ⭐⭐

对比 E2E 测试工具并掌握最佳实践。

考察点:E2E 测试

Playwright vs Cypress

维度PlaywrightCypress
开发者MicrosoftCypress.io
浏览器Chromium + Firefox + WebKitChrome + Firefox + Edge
多标签页
iframe🟡 有限支持
并行执行✅ 内置需要付费 Dashboard
速度更快较慢
API 风格async/await链式调用
调试体验好(Trace Viewer)极好(Time Travel)
上手难度简单

Playwright 示例

typescript
import { test, expect } from '@playwright/test'

test.describe('购物车', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
    await page.getByRole('link', { name: '登录' }).click()
    await page.getByLabel('邮箱').fill('test@example.com')
    await page.getByLabel('密码').fill('password123')
    await page.getByRole('button', { name: '登录' }).click()
    await expect(page).toHaveURL('/dashboard')
  })

  test('添加商品到购物车', async ({ page }) => {
    await page.goto('/products')
    await page.getByText('MacBook Pro').click()
    await page.getByRole('button', { name: '加入购物车' }).click()

    await expect(page.getByTestId('cart-count')).toHaveText('1')

    await page.getByRole('link', { name: '购物车' }).click()
    await expect(page.getByText('MacBook Pro')).toBeVisible()
  })

  test('修改商品数量', async ({ page }) => {
    await page.goto('/cart')
    await page.getByLabel('数量').fill('3')
    await page.getByLabel('数量').press('Enter')

    await expect(page.getByTestId('total-price')).toContainText('¥29,997')
  })
})

E2E 最佳实践

typescript
// ① 使用 data-testid 而非 CSS 选择器
// ❌ page.locator('.btn-primary.submit-form')  → 样式变了就挂
// ✅ page.getByRole('button', { name: '提交' }) → 语义化、稳定

// ② 等待而非 sleep
// ❌ await page.waitForTimeout(3000)
// ✅ await expect(page.getByText('加载完成')).toBeVisible()

// ③ 独立的测试数据
test.beforeEach(async ({ request }) => {
  await request.post('/api/test/reset')
  await request.post('/api/test/seed', { data: { users: testUsers } })
})

// ④ Page Object 模式(复杂项目)
class LoginPage {
  constructor(private page: Page) {}

  async login(email: string, password: string) {
    await this.page.getByLabel('邮箱').fill(email)
    await this.page.getByLabel('密码').fill(password)
    await this.page.getByRole('button', { name: '登录' }).click()
  }

  async expectLoginSuccess() {
    await expect(this.page).toHaveURL('/dashboard')
  }
}

test('登录', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await page.goto('/login')
  await loginPage.login('test@example.com', 'password')
  await loginPage.expectLoginSuccess()
})

Playwright 配置

typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
})

追问延伸

  • Playwright 的 codegen 怎么用?(自动生成测试代码)
  • 如何在 CI 中运行 E2E 测试?(Docker / GitHub Actions + webServer)
  • 视觉回归测试(Visual Regression)怎么做?(Playwright 截图对比 / Percy)

9. 如何测试异步代码?定时器 / Promise / API 请求? ⭐⭐

掌握异步测试的各种模式。

考察点:异步测试

测试 Promise

typescript
// 被测函数
async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('User not found')
  return res.json()
}

// ① async/await(推荐)
it('获取用户信息', async () => {
  const user = await fetchUser('1')
  expect(user.name).toBe('Alice')
})

// ② 测试异步错误
it('用户不存在时抛出错误', async () => {
  await expect(fetchUser('999')).rejects.toThrow('User not found')
})

// ③ resolves / rejects 匹配器
it('返回用户对象', async () => {
  await expect(fetchUser('1')).resolves.toEqual(
    expect.objectContaining({ name: 'Alice' })
  )
})

测试定时器

typescript
// 被测函数
function debounce(fn: Function, delay: number) {
  let timer: number
  return function(...args: any[]) {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
}

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('延迟执行', () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 300)

    debouncedFn()
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(299)
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1)
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('多次调用只执行最后一次', () => {
    const fn = vi.fn()
    const debouncedFn = debounce(fn, 300)

    debouncedFn('a')
    debouncedFn('b')
    debouncedFn('c')

    vi.advanceTimersByTime(300)
    expect(fn).toHaveBeenCalledTimes(1)
    expect(fn).toHaveBeenCalledWith('c')
  })
})

测试 setInterval

typescript
function createPoller(fn: () => Promise<void>, interval: number) {
  let timer: number | null = null
  return {
    start() { timer = setInterval(fn, interval) },
    stop() { if (timer) clearInterval(timer) },
  }
}

it('按间隔轮询', async () => {
  vi.useFakeTimers()
  const fn = vi.fn().mockResolvedValue(undefined)
  const poller = createPoller(fn, 1000)

  poller.start()
  expect(fn).not.toHaveBeenCalled()

  vi.advanceTimersByTime(1000)
  expect(fn).toHaveBeenCalledTimes(1)

  vi.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(4)

  poller.stop()
  vi.advanceTimersByTime(5000)
  expect(fn).toHaveBeenCalledTimes(4)

  vi.useRealTimers()
})

测试 waitFor

typescript
import { waitFor } from '@testing-library/react'

it('等待异步状态更新', async () => {
  render(<AsyncComponent />)

  await waitFor(() => {
    expect(screen.getByText('Data loaded')).toBeInTheDocument()
  }, { timeout: 3000 })
})

it('等待元素消失', async () => {
  render(<AsyncComponent />)

  await waitFor(() => {
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
  })
})

追问延伸

  • vi.runAllTimers()vi.advanceTimersByTime() 的区别?
  • 如何测试 requestAnimationFrame
  • 异步测试超时了怎么调试?(增加 timeout、检查 Promise 是否正确 resolve)

10. 如何在 CI 中集成测试?测试策略设计? ⭐⭐

设计适合团队的测试策略和 CI 集成。

考察点:测试工程化

CI 中的测试流水线

yaml
# .github/workflows/test.yml
name: Test

on:
  pull_request:
    branches: [main]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/

  e2e-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: npx playwright install --with-deps
      - run: pnpm build
      - run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

测试策略

按项目阶段:

新项目 / MVP:
  → 只写关键路径的 E2E 测试
  → 核心业务逻辑的单元测试
  → 不追求覆盖率

成长期项目:
  → 新代码必须有测试(PR 卡覆盖率)
  → 增加集成测试(组件 + API)
  → 关键流程 E2E

成熟项目:
  → 完善的测试金字塔
  → 视觉回归测试
  → 性能基准测试
  → 变异测试

该测什么,不该测什么

✅ 该测:
  - 业务逻辑函数(输入输出明确)
  - 用户交互流程(表单提交、导航)
  - 边界情况(空值、超长输入、并发)
  - 错误处理路径(网络失败、权限不足)
  - 回归 Bug(每个 Bug 修复都加一个测试)

❌ 不该测:
  - 第三方库的内部实现
  - 纯样式(用视觉测试)
  - 实现细节(内部 state、私有方法)
  - 一次性脚本
  - 常量配置文件

前端测试完整工具链

单元 + 集成测试:
  框架: Vitest
  断言: expect (Vitest 内置)
  DOM: @testing-library/react
  交互: @testing-library/user-event
  Mock: MSW (API) + vi.mock (模块)

E2E 测试:
  框架: Playwright
  模式: Page Object Model
  报告: HTML Reporter

辅助工具:
  覆盖率: @vitest/coverage-v8
  视觉回归: Chromatic / Percy
  可访问性: jest-axe
  类型测试: expectTypeOf (Vitest)

CI 集成:
  GitHub Actions / GitLab CI
  → PR 触发: lint + typecheck + unit test + e2e test
  → 合并触发: build + deploy + smoke test

测试命名规范

typescript
// 推荐: 描述行为而非实现
describe('购物车', () => {
  it('添加商品后数量增加 1', () => {})
  it('删除唯一商品后显示空购物车提示', () => {})
  it('超过库存数量时显示库存不足提示', () => {})
  it('未登录时跳转到登录页', () => {})
})

// 格式: "当 [条件] 时,应该 [行为]"
// 或: "[操作] 后 [预期结果]"

追问延伸

  • 如何衡量测试的 ROI?什么测试性价比最高?
  • 如何处理测试代码的重复?测试也需要 DRY 吗?(适度 DRY,但测试的可读性优先)
  • Snapshot 测试的优缺点?什么时候该用?(API 响应、组件输出固化;但容易成为"批准"测试)

用心学习,用代码说话 💻