第13章 测试与调试

Chapter-13-Testing-And-Debugging

第13章:测试与调试

“测试?那是什么?能吃吗?”

如果你也是这么想的,那这章就是你的"觉醒时刻"。没有测试的代码就像没有安全带的赛车——可能跑得很快,但一出事就是大事。

这一章我们要聊的话题:Vitest 怎么写单元测试?Playwright 怎么写 E2E 测试?VS Code 怎么调试?Chrome DevTools 怎么用?GitHub Actions 怎么配置 CI?

准备好了吗?让我们一起告别"改完代码手动刷新浏览器"的时代!🧪


13.1 单元测试

13.1.1 Vitest 简介与安装

Vitest 是一个由 Vite 驱动的单元测试框架,由 Vue 官方团队成员 Anthony Fu 创建。它的特点是:

  • 极速:和 Vite 一样快,因为使用了相同的 transform 机制
  • 开箱即用:零配置,和 Vite 项目无缝集成
  • TypeScript 支持:原生 TypeScript 支持
  • HMR 支持:测试文件修改后自动重新运行

Vitest vs Jest vs Mocha

框架特点与 Vite 集成
Vitest极速,Vite 原生,Vue 官方推荐⭐⭐⭐⭐⭐
Jest老牌稳定,但较慢⭐⭐
Mocha灵活,但配置复杂⭐⭐

安装 Vitest

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Vue 项目
pnpm add -D vitest @vue/test-utils

# React 项目
pnpm add -D vitest @testing-library/react @testing-library/jest-dom

# 如果需要 JSX 支持
pnpm add -D @vitejs/plugin-react

# 如果需要覆盖率报告
pnpm add -D @vitest/coverage-v8

13.1.2 编写第一个测试

Vitest 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import jsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [
    vue(),
    jsx(),
  ],
  test: {
    // 全局配置
    globals: true,  // 使用全局 describe/it/expect 等函数(不需要 import)
    environment: 'jsdom',  // 测试环境:jsdom / node / happy-dom
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
    exclude: ['node_modules', 'dist'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// vitest.config.ts(更完整的配置)
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import jsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [vue(), jsx()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/tests/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/tests/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/',
      ],
    },
  },
})

第一个测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/utils/math.test.ts

// 导入要测试的函数
import { describe, it, expect, test } from 'vitest'
import { add, multiply, divide, factorial } from './math'

// describe:测试套件,把相关的测试组织在一起
describe('数学工具函数', () => {
  // it 或 test:定义一个测试用例
  it('add 函数:两个数相加', () => {
    // expect:断言,验证实际值是否符合预期
    expect(add(1, 2)).toBe(3)
    expect(add(-1, 1)).toBe(0)
    expect(add(0, 0)).toBe(0)
  })

  test('multiply 函数:两个数相乘', () => {
    expect(multiply(3, 4)).toBe(12)
    expect(multiply(-2, 3)).toBe(-6)
    expect(multiply(0, 100)).toBe(0)
  })

  test('divide 函数:两个数相除', () => {
    expect(divide(10, 2)).toBe(5)
    expect(divide(9, 3)).toBe(3)
    expect(divide(5, 2)).toBe(2.5)
  })

  test('divide 函数:除以 0 应该抛出错误', () => {
    // toThrow:断言函数会抛出错误
    expect(() => divide(10, 0)).toThrow('不能除以 0')
  })

  test('factorial 函数:阶乘', () => {
    expect(factorial(0)).toBe(1)
    expect(factorial(1)).toBe(1)
    expect(factorial(5)).toBe(120)
    expect(factorial(10)).toBe(3628800)
  })

  // 异步测试
  test('asyncAdd:异步加法', async () => {
    // 注意:这里的 add 是同步函数
    // 如果要测试异步加法,需要先 resolve Promise
    const result = await add(await Promise.resolve(1), await Promise.resolve(2))
    expect(result).toBe(3)
  })
})

运行测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 运行所有测试
pnpm test

# 监听模式(文件修改后自动重新运行)
pnpm test:watch

# 只运行一次
pnpm test:run

# 运行指定文件
pnpm test src/utils/math.test.ts

# 运行指定测试
pnpm test --grep "add 函数"

13.1.3 测试命令配置

package.json 脚本配置

1
2
3
4
5
6
7
8
9
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",          // 单次运行(CI 常用)
    "test:watch": "vitest --watch",     // 监听模式
    "test:coverage": "vitest run --coverage",  // 带覆盖率
    "test:ui": "vitest --ui"           // 浏览器 UI
  }
}

13.1.4 组件测试(Vue Testing Library)

Vue Testing Library 专注于测试组件的行为,而不是实现细节。

安装

1
pnpm add -D @vue/test-utils vitest

Vue 组件测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <h1>计数器{{ count }}</h1>
    <p>双倍计数{{ doubleCount }}</p>
    
    <button @click="decrement" :disabled="count <= 0">-</button>
    <button @click="increment">+</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  initialCount: {
    type: Number,
    default: 0,
  },
})

const emit = defineEmits(['update', 'reset'])

const count = ref(props.initialCount)

const doubleCount = computed(() => count.value * 2)

function increment() {
  count.value++
  emit('update', count.value)
}

function decrement() {
  if (count.value > 0) {
    count.value--
    emit('update', count.value)
  }
}

function reset() {
  count.value = 0
  emit('reset')
}

defineExpose({ count, increment, decrement, reset })
</script>

测试文件

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
// src/components/Counter.test.ts
import { describe, it, expect, test, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import Counter from './Counter.vue'

describe('Counter 组件', () => {
  it('初始渲染', () => {
    const wrapper = mount(Counter)
    
    // 查找文本内容
    expect(wrapper.text()).toContain('计数器:0')
    expect(wrapper.text()).toContain('双倍计数:0')
  })

  test('初始值配置', () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 10 },
    })
    
    expect(wrapper.text()).toContain('计数器:10')
    expect(wrapper.text()).toContain('双倍计数:20')
  })

  test('增加按钮', async () => {
    const wrapper = mount(Counter)
    const incrementBtn = wrapper.findAll('button')[1]  // 第二个按钮是 +
    
    await incrementBtn.trigger('click')
    await incrementBtn.trigger('click')
    await incrementBtn.trigger('click')
    
    expect(wrapper.text()).toContain('计数器:3')
    expect(wrapper.text()).toContain('双倍计数:6')
  })

  test('减少按钮', async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 5 },
    })
    
    const decrementBtn = wrapper.find('button:first-child')  // 第一个按钮是 -
    await decrementBtn.trigger('click')
    
    expect(wrapper.text()).toContain('计数器:4')
  })

  test('减少到 0 后禁用', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 0 } })
    
    const decrementBtn = wrapper.find('button:first-child')
    
    // 初始时应该禁用
    expect(decrementBtn.attributes('disabled')).toBeDefined()
    
    // 点击增加
    await wrapper.findAll('button')[1].trigger('click')
    
    // 取消禁用
    expect(decrementBtn.attributes('disabled')).toBeUndefined()
  })

  test('重置按钮', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 10 } })
    
    // 点击增加几次
    await wrapper.findAll('button')[1].trigger('click')
    await wrapper.findAll('button')[1].trigger('click')
    
    // 期望是 12
    expect(wrapper.text()).toContain('计数器:12')
    
    // 点击重置
    await wrapper.find('button:last-child').trigger('click')
    
    // 回到初始值 10
    expect(wrapper.text()).toContain('计数器:10')
  })

  test('emit 事件', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 0 } })
    
    await wrapper.findAll('button')[1].trigger('click')
    
    // 检查 emit 的事件
    expect(wrapper.emitted('update')).toBeTruthy()
    expect(wrapper.emitted('update')![0]).toEqual([1])  // 参数是 [1]
  })

  test('暴露的方法', async () => {
    const wrapper = mount(Counter)
    
    // 获取组件实例(Vue 3 + @vue/test-utils v2 使用 componentVM)
    const vm = wrapper.componentVM as any
    
    // 调用暴露的方法
    vm.increment()
    vm.increment()
    
    expect(wrapper.text()).toContain('计数器:2')
  })
})

13.1.5 组件测试(React Testing Library)

React Testing Library 的理念和 Vue Testing Library 一样:测试行为,而不是实现。

安装

1
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest

React 组件测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/components/Counter.tsx
import { useState } from 'react'

interface CounterProps {
  initialCount?: number
  onUpdate?: (count: number) => void
  onReset?: () => void
}

export function Counter({ 
  initialCount = 0, 
  onUpdate, 
  onReset 
}: CounterProps) {
  const [count, setCount] = useState(initialCount)

  const doubleCount = count * 2

  function increment() {
    const newCount = count + 1
    setCount(newCount)
    onUpdate?.(newCount)
  }

  function decrement() {
    if (count > 0) {
      const newCount = count - 1
      setCount(newCount)
      onUpdate?.(newCount)
    }
  }

  function reset() {
    setCount(initialCount)
    onReset?.()
  }

  return (
    <div className="counter">
      <h1>计数器:{count}</h1>
      <p>双倍计数:{doubleCount}</p>
      
      <button onClick={decrement} disabled={count <= 0}>-</button>
      <button onClick={increment}>+</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

测试文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// src/components/Counter.test.tsx
import { describe, it, expect, test, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'

describe('Counter 组件', () => {
  it('初始渲染', () => {
    render(<Counter />)
    
    expect(screen.getByText('计数器:0')).toBeInTheDocument()
    expect(screen.getByText('双倍计数:0')).toBeInTheDocument()
  })

  test('初始值配置', () => {
    render(<Counter initialCount={10} />)
    
    expect(screen.getByText('计数器:10')).toBeInTheDocument()
    expect(screen.getByText('双倍计数:20')).toBeInTheDocument()
  })

  test('增加按钮', async () => {
    const user = userEvent.setup()
    render(<Counter />)
    
    const incrementBtn = screen.getByRole('button', { name: '+' })
    await user.click(incrementBtn)
    await user.click(incrementBtn)
    await user.click(incrementBtn)
    
    expect(screen.getByText('计数器:3')).toBeInTheDocument()
  })

  test('减少按钮', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={5} />)
    
    const decrementBtn = screen.getByRole('button', { name: '-' })
    await user.click(decrementBtn)
    
    expect(screen.getByText('计数器:4')).toBeInTheDocument()
  })

  test('减少到 0 后禁用', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} />)
    
    const decrementBtn = screen.getByRole('button', { name: '-' })
    expect(decrementBtn).toBeDisabled()
    
    const incrementBtn = screen.getByRole('button', { name: '+' })
    await user.click(incrementBtn)
    
    expect(decrementBtn).not.toBeDisabled()
  })

  test('onUpdate 回调', async () => {
    const onUpdate = vi.fn()
    const user = userEvent.setup()
    render(<Counter onUpdate={onUpdate} />)
    
    await user.click(screen.getByRole('button', { name: '+' }))
    
    expect(onUpdate).toHaveBeenCalledWith(1)
    expect(onUpdate).toHaveBeenCalledTimes(1)
  })
})

13.1.6 覆盖率报告

配置覆盖率

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      reportsDirectory: './coverage',
      exclude: [
        'node_modules/**',
        'src/tests/**',
        '**/*.d.ts',
        '**/*.config.*',
        '**/dist/**',
        '**/virtual:**',
        '**/__xhm__/**',
      ],
      thresholds: {
        // 设置覆盖率门槛
        statements: 80,
        branches: 80,
        functions: 80,
        lines: 80,
      },
    },
  },
})

覆盖率报告输出

---------------------------|---------|----------|---------|---------|
File                       | % Stmts | % Branch | % Funcs | % Lines |
---------------------------|---------|----------|---------|---------|
src/utils/math.ts          |   100.00|    100.00|   100.00|   100.00|
src/components/Counter.vue |    85.71|    100.00|    66.67|    85.71|
---------------------------|---------|----------|---------|---------|
All files                  |    91.67|    100.00|    80.00|    91.67|
---------------------------|---------|----------|---------|---------|

13.1.7 Mock 函数与模块

Mock 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/utils/api.test.ts
import { describe, it, expect, test, vi, beforeEach } from 'vitest'
import { fetchUser, fetchUsers } from './api'

// Mock fetch
const mockFetch = vi.fn()
global.fetch = mockFetch

describe('API 函数', () => {
  beforeEach(() => {
    mockFetch.mockReset()  // 每个测试前重置
  })

  test('fetchUser 返回用户数据', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: '小明', age: 25 }),
    })

    const user = await fetchUser(1)
    
    expect(user).toEqual({ id: 1, name: '小明', age: 25 })
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1')
  })

  test('fetchUsers 返回用户列表', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [
        { id: 1, name: '小明' },
        { id: 2, name: '小红' },
      ],
    })

    const users = await fetchUsers()
    
    expect(users).toHaveLength(2)
    expect(users[0].name).toBe('小明')
  })

  test('fetchUser 处理错误', async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
    })

    await expect(fetchUser(999)).rejects.toThrow('用户不存在')
  })
})

Mock 模块

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/utils/date.test.ts
import { describe, it, expect, vi } from 'vitest'

// Mock dayjs
vi.mock('dayjs', () => ({
  default: vi.fn(() => ({
    format: vi.fn().mockReturnValue('2024-03-27'),
    add: vi.fn().mockReturnThis(),
    subtract: vi.fn().mockReturnThis(),
  })),
}))

import dayjs from 'dayjs'
import { formatDate, addDays } from './date'

describe('日期工具', () => {
  it('formatDate 格式化日期', () => {
    const result = formatDate(new Date('2024-03-27'))
    
    expect(dayjs).toHaveBeenCalledWith(new Date('2024-03-27'))
    expect(result).toBe('2024-03-27')
  })
})

13.1.8 Vitest 配置详解

vitest.config.ts 完整配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // 全局 API
    globals: true,
    
    // 测试环境
    // 'jsdom' | 'node' | 'happy-dom'
    environment: 'jsdom',
    
    // 全局 setup 文件
    setupFiles: ['./src/tests/setup.ts'],
    
    // 包含的文件
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    
    // 排除的文件
    exclude: ['node_modules', 'dist', '**/*.md'],
    
    // 依赖反转
    // transformMode: {},
    
    // 覆盖
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/'],
    },
    
    // 线程数
    // workers: 4,
    
    // 测试超时
    testTimeout: 5000,
    
    // 断言超时
    expectTimeout: 5000,
    
    // 序列执行
    // sequence: { type: 'serial' },
    
    // 并行执行
    // sequence: { type: 'parallel' },
    
    // 慢测试报告
    // slowTestThreshold: 200,
    
    // 序列
    // sequence: { shuffle: false },
    
    // 重试次数
    // retry: 0,
    
    // 单独运行
    // isolate: true,
    
    // 报告器
    // reporters: ['default', 'verbose'],
    
    // 输出
    outputFile: {
      json: './coverage/test-results.json',
    },
  },
})

13.1.9 快照测试

快照测试:保存组件渲染结果,下次运行时对比。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/components/Header.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Header } from './Header'

describe('Header 组件', () => {
  it('渲染结果与快照匹配', () => {
    const { container } = render(<Header title="测试标题" />)
    
    // toMatchSnapshot:与上次保存的快照对比
    expect(container).toMatchSnapshot()
  })

  it('更新快照', async () => {
    // 当组件确实改变了,需要更新快照
    // 运行:vitest --update
    // 或者:vitest -u
  })
})

13.1.10 测试覆盖率门槛

设置强制覆盖率门槛

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        // 文件级别
        perFile: true,
        
        // 全局门槛
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
        
        // 特定文件的门槛
        'src/utils/**/*.ts': {
          statements: 90,
          branches: 90,
        },
      },
    },
  },
})

13.1.11 测试并行执行

Vitest 默认并行执行测试,可以提高速度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// vitest.config.ts
export default defineConfig({
  test: {
    // 并行线程数
    workers: 4,
    
    // 使用 V8 并行引擎(更快)
    pool: 'forks',  // 'threads' | 'forks' | 'vmForks'
    
    // 序列执行(调试用)
    poolOptions: {
      threads: {
        // 线程数
        singleThread: false,
      },
      forks: {
        // 最大并行进程数
        maxForks: 4,
      },
    },
  },
})

13.2 E2E 测试

13.2.1 Playwright 集成

Playwright 是微软出品的 E2E 测试框架,支持所有现代浏览器。

安装

1
2
pnpm add -D @playwright/test
pnpm exec playwright install chromium

13.2.2 Playwright 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  // 测试目录
  testDir: './tests/e2e',
  
  // 并行执行
  fullyParallel: true,
  
  // 失败时重试
  retries: process.env.CI ? 2 : 0,
  
  // 工作线程数
  workers: process.env.CI ? 4 : undefined,
  
  // 报告器
  reporter: [
    ['html'],
    ['list'],
  ],
  
  // 全局超时
  timeout: 30 * 1000,
  
  // 期望超时
  expect: {
    timeout: 5000,
  },
  
  // 全局预处理
  use: {
    // 基础 URL
    baseURL: 'http://localhost:5173',
    
    // 截图模式
    screenshot: 'only-on-failure',
    
    // 视频录制
    video: 'retain-on-failure',
    
    // 跟踪
    trace: 'on-first-retry',
    
    // 截图
    headless: true,
  },
  
  // 项目配置
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
  
  // WebServer 配置
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
})

13.2.3 测试用例编写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// tests/e2e/home.spec.ts
import { test, expect } from '@playwright/test'

test.describe('首页', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('页面标题正确', async ({ page }) => {
    await expect(page).toHaveTitle(/我的应用/)
  })

  test('导航链接存在', async ({ page }) => {
    await expect(page.locator('nav')).toContainText('首页')
    await expect(page.locator('nav')).toContainText('关于')
    await expect(page.locator('nav')).toContainText('用户')
  })

  test('点击导航到关于页', async ({ page }) => {
    await page.click('text=关于')
    await expect(page).toHaveURL(/\/about/)
    await expect(page.locator('h1')).toContainText('关于')
  })

  test('计数器增加功能', async ({ page }) => {
    // 查找 + 按钮
    const incrementBtn = page.locator('button', { hasText: '+' })
    
    // 初始值应该是 0
    await expect(page.locator('text=计数器:0')).toBeVisible()
    
    // 点击 + 按钮
    await incrementBtn.click()
    await expect(page.locator('text=计数器:1')).toBeVisible()
    
    // 双倍计数应该是 2
    await expect(page.locator('text=双倍计数:2')).toBeVisible()
  })

  test('表单提交', async ({ page }) => {
    await page.fill('input[name="username"]', 'testuser')
    await page.fill('input[name="password"]', 'password123')
    await page.click('button[type="submit"]')
    
    // 等待成功消息
    await expect(page.locator('.success-message')).toBeVisible({ timeout: 5000 })
    await expect(page.locator('.success-message')).toContainText('登录成功')
  })

  test('404 页面', async ({ page }) => {
    await page.goto('/non-existent-page')
    await expect(page.locator('h1')).toContainText('404')
  })
})

13.2.4 Cypress 集成

Cypress 是另一个流行的 E2E 测试框架。

安装

1
pnpm add -D cypress

13.2.5 Nightwatch 集成

Nightwatch.js 是基于 Selenium 的 E2E 测试框架。

安装

1
pnpm add -D nightwatch @nightwatch/browserstack

13.2.6 测试报告与分析

Playwright HTML 报告

1
2
3
4
5
# 运行测试,生成报告
pnpm playwright test

# 查看 HTML 报告
pnpm playwright show-report

13.3 调试技巧

13.3.1 VS Code 调试配置

launch.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug: Vite Dev Server",
      "type": "pwa-node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "pnpm",
      "runtimeArgs": ["dev"],
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal",
      "env": {
        "NODE_ENV": "development"
      }
    },
    {
      "name": "Debug: Vitest",
      "type": "pwa-node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "pnpm",
      "runtimeArgs": ["test", "--run"],
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    },
    {
      "name": "Debug: Vite Build",
      "type": "pwa-node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "pnpm",
      "runtimeArgs": ["build"],
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    },
    {
      "name": "Debug: Current Test File",
      "type": "pwa-node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "pnpm",
      "runtimeArgs": ["test", "--run", "${relativeFile}"],
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    }
  ]
}

tasks.json(辅助任务)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Vite: Dev Server",
      "type": "shell",
      "command": "pnpm dev",
      "problemMatcher": [],
      "group": "none",
      "presentation": {
        "reveal": "always",
        "panel": "new"
      }
    },
    {
      "label": "Vite: Build",
      "type": "shell",
      "command": "pnpm build",
      "problemMatcher": [],
      "group": "build",
      "presentation": {
        "reveal": "always",
        "panel": "new"
      }
    },
    {
      "label": "Vitest: Run",
      "type": "shell",
      "command": "pnpm test:run",
      "problemMatcher": [],
      "group": "test"
    },
    {
      "label": "Vitest: Watch",
      "type": "shell",
      "command": "pnpm test",
      "problemMatcher": [],
      "group": "test"
    }
  ]
}

13.3.2 Chrome DevTools 调试

Sources 面板

  1. 打开 DevTools(F12)
  2. 切换到 Sources 标签
  3. 找到你的源码文件(在 vite:// 目录下,这是 Vite 的源码标识)
  4. 在代码行号上点击设置断点

Vue DevTools

1
2
# Vue 项目安装 Vue DevTools
# Chrome 扩展商店搜索 "Vue DevTools"

React DevTools

1
# Chrome 扩展商店搜索 "React DevTools"

13.3.3 源码映射问题排查

sourcemap 不生效?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// vite.config.js
export default defineConfig({
  build: {
    // 生成 sourcemap
    sourcemap: true,
    
    // 或者只生成,不内联
    sourcemap: 'hidden',
  },
})

13.3.4 Network 网络请求调试

Network 面板使用技巧

  1. 过滤请求:输入 URL 或关键字过滤
  2. 查看请求详情:点击请求,查看 Headers/Payload/Response
  3. 复制请求:右键请求,选择 Copy → Copy as fetch/cURL
  4. 重发请求:右键请求,选择 Replay XHR/fetch

13.3.5 性能分析

Performance 面板

  1. 打开 DevTools(F12)
  2. 切换到 Performance 标签
  3. 点击录制,执行操作,然后停止
  4. 查看火焰图,分析性能瓶颈

13.3.6 内存泄漏排查

Memory 面板

  1. 打开 DevTools(F12)
  2. 切换到 Memory 标签
  3. 选择 Heap Snapshot
  4. 点击 Take Snapshot
  5. 执行一些操作
  6. 再拍一张快照
  7. 对比两张快照,查找内存增长

13.4 CI 环境测试

13.4.1 GitHub Actions 集成

.github/workflows/test.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        node-version: [18.x, 20.x]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 9
      
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
      
      - name: Run type check
        run: pnpm type-check
      
      - name: Run unit tests
        run: pnpm test:run --coverage
      
      - name: Run e2e tests
        run: pnpm test:e2e
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: false

13.4.2 GitLab CI 集成

.gitlab-ci.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
stages:
  - install
  - test
  - build

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/
    - .pnpm-store/

install:
  stage: install
  image: node:20
  script:
    - corepack enable
    - pnpm install --frozen-lockfile

unit-test:
  stage: test
  image: node:20
  script:
    - pnpm test:run --coverage
  coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

e2e-test:
  stage: test
  image: cypress/base:18
  script:
    - pnpm test:e2e:run
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/

build:
  stage: build
  image: node:20
  script:
    - pnpm build
  artifacts:
    paths:
      - dist/

13.4.3 Jenkins 集成

Jenkinsfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
pipeline {
  agent any
  
  stages {
    stage('Install') {
      steps {
        sh 'corepack enable'
        sh 'pnpm install --frozen-lockfile'
      }
    }
    
    stage('Test') {
      steps {
        sh 'pnpm test:run --coverage'
        junit 'junit.xml'
        publishHTML([
          reportDir: 'coverage',
          reportFiles: 'index.html',
          reportName: 'Coverage Report'
        ])
      }
    }
    
    stage('Build') {
      steps {
        sh 'pnpm build'
      }
    }
  }
  
  post {
    always {
      cleanWs()
    }
  }
}

13.4.4 测试命令优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:headed": "playwright test --headed",
    "test:all": "pnpm test:run && pnpm test:e2e"
  }
}

13.4.5 测试覆盖率集成

Codecov 集成

1
pnpm add -D @codecov/vite-plugin
1
2
3
4
5
6
7
# GitHub Actions 中
- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v4
  with:
    token: ${{ secrets.CODECOV_TOKEN }}
    files: ./coverage/lcov.info
    fail_ci_if_error: false

13.5 本章小结

🎉 本章总结

这一章我们学习了 Vite 项目中的测试与调试技能:

  1. 单元测试:Vitest 安装与配置、第一个测试用例、Vue Testing Library(Vue 组件测试)、React Testing Library(React 组件测试)、覆盖率报告、Mock 函数与模块、快照测试

  2. E2E 测试:Playwright 安装与配置、测试用例编写、Cypress 集成、测试报告分析

  3. 调试技巧:VS Code launch.json 配置、Chrome DevTools 调试、源码映射、网络请求调试、性能分析、内存泄漏排查

  4. CI 环境测试:GitHub Actions 配置、GitLab CI 配置、Jenkins 配置、测试命令优化、覆盖率集成

📝 本章练习

  1. Vitest 实战:为你的工具函数编写 3-5 个单元测试

  2. 组件测试:为你的 Vue/React 组件编写测试用例

  3. Playwright E2E:编写 3 个 E2E 测试用例,覆盖主要功能流程

  4. GitHub Actions:配置一个完整的 CI 流程

  5. 覆盖率提升:把测试覆盖率提升到 80% 以上


📌 预告:下一章我们将进入 完整项目实战,从零开始搭建一个完整的 Vue/React 项目,包含需求分析、技术选型、项目搭建、功能开发、移动端适配、国际化、部署上线。敬请期待!

最后修改 March 28, 2026: 新增 esbuild 和 rollup 教程 (4c5b06f)