第23章 测试:让代码自己证明自己

第二十三章 测试:让代码自己证明自己

写代码不写测试,就像开车不系安全带——大多数时候没事,但一出事就是大事。测试是前端工程化中绕不开的话题。单元测试、集成测试、E2E 测试……这些词你可能听过无数次,但每次想动手写的时候,又不知道从哪儿开始。本章用 Vue 3 + Vitest + Vue Test Utils + Playwright 给你搭建一套完整的测试体系,保证你看完之后能从"完全不会写测试"直接进化到"测试写到手软"。

23.1 测试的基本概念

23.1.1 测试金字塔:单元 / 集成 / E2E

业界最著名的测试模型是测试金字塔

flowchart TB
    A["测试金字塔"]
    
    A --> B["🔺 E2E 测试(少量)<br/>端到端测试,模拟真实用户操作<br/>从浏览器到数据库的全链路"]
    A --> C["⚖️ 集成测试(中量)<br/>测试多个模块/组件的协作<br/>比如测试 Pinia store + 组件的联动"]
    A --> D["🔽 单元测试(大量)<br/>测试最小的代码单元<br///>一个函数、一个 composable、一个组件"]
    
    style B fill:#e63946,color:#fff
    style C fill:#f4a261,color:#fff
    style D fill:#42b883,color:#fff

单元测试(Unit Test)是最底层,测试对象是最小的代码单元,比如一个工具函数、一个 composable、一个组件的某个方法。单元测试应该是快速、隔离、独立的——不依赖外部系统(网络、数据库),测试结果稳定。

集成测试(Integration Test)测试多个组件或模块协作的正确性。比如测试用户点击登录按钮 -> Pinia store 调用登录 API -> 保存 token -> 跳转到首页这个完整流程。集成测试通常需要 mock 掉真正的网络请求。

E2E 测试(End-to-End Test)模拟真实用户在浏览器中的操作,从打开浏览器、输入账号密码、点击登录,到看到登录后的页面,整个链路走一遍。E2E 测试最接近真实用户体验,但运行最慢、最不稳定,通常只覆盖核心用户路径。

三种测试的特点对比

特性单元测试集成测试E2E 测试
运行速度极快(毫秒级)较快(秒级)慢(分钟级)
覆盖范围单个函数/模块多个模块协作整个应用
稳定性高(无外部依赖)低(依赖多,容易flaky)
编写成本
调试难度
数量占比最多(60-70%)中等(20-30%)最少(10-20%)

23.1.2 TDD 与 BDD

TDD(Test-Driven Development,测试驱动开发):先写测试,再写实现。流程是:Red(写一个失败的测试)-> Green(写最少的代码让测试通过)-> Refactor(重构代码,保持测试通过)。

BDD(Behavior-Driven Development,行为驱动开发):用自然语言描述期望行为,测试代码是这些描述的具体实现。BDD 的测试更像文档,能让非技术人员(产品经理、业务方)也能看懂测试在测什么。

Vue 生态里常用的测试库语法风格:

  • describe / it / expect —— Jest/Vitest 风格(BDD-like)
  • test / expect —— Jest 风格
  • context / should —— Mocha/Chai 风格

Vitest 兼容 Jest 的 API,所以用 describe / it / expect 就行。

23.2 Vitest 环境配置

23.2.1 安装与配置

Vitest 是 Vite 原生的测试框架,配置简单,速度飞快,是 Vue 3 项目测试的首选。

1
2
3
4
5
# 安装 Vitest 和相关依赖
npm install -D vitest @vue/test-utils happy-dom @vitest/ui jsdom

# Vue 3 + TypeScript 项目还需要
npm install -D @vitest/coverage-v8

vite.config.ts 中配置 Vitest:

 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
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],  // Vue 插件必须开启,Vitest 才能识别 .vue 文件
  resolve: {
    alias: {
      // 让测试代码也能用 @/ 路径别名
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  test: {
    // environment:测试环境的 DOM 模拟库
    // 'jsdom' → 用 jsdom 模拟浏览器 DOM(最常用,模拟 window/document 等)
    // 'happy-dom' → 比 jsdom 更轻量的 DOM 模拟(性能更好,但某些 API 不支持)
    // 'node' → Node.js 环境(测试纯 JS 逻辑时用)
    environment: 'jsdom',

    // globals:全局注入测试 API(不需要每次 import)
    // 开启后,describe、it、expect、vi 等函数可以直接用,不用 import
    // 不开启则需要手动 import { describe, it, expect } from 'vitest'
    globals: true,

    // watch:开启后,测试会持续监控文件变化,修改后自动重新运行
    // pnpm vitest → 开启 watch 模式(默认)
    // pnpm vitest run → 单次运行(CI/CD 构建时用这个)
    watch: true,

    // coverage:代码覆盖率配置
    coverage: {
      // provider:使用哪个覆盖率工具
      // 'v8' → 使用 V8 引擎内置的覆盖率(更快,推荐)
      // 'istanbul' → 使用 istanbul/nyc(更老,但生态更丰富)
      provider: 'v8',

      // reporter:覆盖率报告的输出格式
      // 'text' → 终端里直接显示(最常用)
      // 'json' → 输出到 coverage/coverage-final.json(供其他工具读取)
      // 'html' → 生成 HTML 报告到 coverage/ 目录(最直观,双击打开)
      reporter: ['text', 'json', 'html'],

      // exclude:排除哪些文件不计入覆盖率
      exclude: [
        'node_modules/**',     // 第三方库不计入覆盖率
        '*.config.*',          // 配置文件
        '**/*.d.ts',          // 类型声明文件
        '**/*.spec.ts',       // 测试文件本身不计入
        '**/*.test.ts'        // 测试文件本身不计入
      ]
    }
  }
})

package.json 中添加测试脚本:

1
2
3
4
5
6
7
8
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",        // 单次运行(CI/CD 用)
    "test:coverage": "vitest run --coverage",  // 带覆盖率报告
    "test:ui": "vitest --ui"         // 打开 Vitest 的图形界面
  }
}

23.2.2 第一个测试文件

创建测试文件的命名规范:.spec.ts.test.ts 都可以,Vitest 都能识别。通常把测试文件和源文件放在同一目录下:

src/
├── utils/
│   ├── formatDate.ts
│   └── formatDate.spec.ts     // 测试文件
├── composables/
│   ├── useCounter.ts
│   └── useCounter.spec.ts     // 测试文件
└── components/
    ├── Counter.vue
    └── Counter.spec.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
// src/utils/formatDate.spec.ts
import { describe, it, expect } from 'vitest'
import { formatDate, formatDateRelative } from './formatDate'

describe('formatDate 工具函数', () => {
  // 基础功能测试
  it('应该正确格式化日期', () => {
    const date = new Date('2024-01-15T10:30:00')
    const result = formatDate(date, 'YYYY-MM-DD')
    expect(result).toBe('2024-01-15')
  })

  it('应该支持自定义格式', () => {
    const date = new Date('2024-03-20T08:00:00')
    const result = formatDate(date, 'YYYY年MM月DD日 HH:mm')
    expect(result).toBe('2024年03月20日 08:00')
  })

  // 边界情况测试
  it('应该处理空值', () => {
    const result = formatDate(null as any, 'YYYY-MM-DD')
    expect(result).toBe('')
  })

  it('应该处理时间戳', () => {
    const timestamp = 1705315800000  // 2024-01-15 10:30:00
    const result = formatDate(timestamp, 'YYYY-MM-DD')
    expect(result).toBe('2024-01-15')
  })
})

describe('formatDateRelative 相对时间', () => {
  it('应该显示"刚刚"对于刚刚发生的事件', () => {
    const now = new Date()
    const result = formatDateRelative(now)
    expect(result).toBe('刚刚')
  })

  it('应该显示"X分钟前"对于几分钟前的事件', () => {
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
    const result = formatDateRelative(fiveMinutesAgo)
    expect(result).toBe('5分钟前')
  })

  it('应该显示"X小时前"对于几小时前的事件', () => {
    const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000)
    const result = formatDateRelative(threeHoursAgo)
    expect(result).toBe('3小时前')
  })

  it('应该显示具体日期对于更早的事件', () => {
    const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
    const result = formatDateRelative(oneWeekAgo)
    // 应该返回具体日期格式
    expect(result).toMatch(/\d{4}-\d{2}-\d{2}/)
  })
})

23.3 组件测试(Vue Test Utils)

23.3.1 基本 mount 与浅渲染

@vue/test-utils 是 Vue 官方出品的组件测试库,提供了 mountshallowMount 两个核心函数。

 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
<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <h2>计数器</h2>
    <div class="count">{{ count }}</div>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  initialCount?: number
}>()

const count = ref(props.initialCount ?? 0)

const increment = () => count.value++
const decrement = () => count.value--
const reset = () => { count.value = 0 }

defineExpose({ count })
</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
// src/components/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter 组件测试', () => {
  it('应该正确渲染初始值', () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 10 }
    })

    // 查找 .count 元素
    const countEl = wrapper.find('.count')
    expect(countEl.text()).toBe('10')
  })

  it('应该使用默认初始值 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.count').text()).toBe('0')
  })

  it('点击 +1 按钮应该增加计数', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 0 } })

    // 找到 +1 按钮并点击
    await wrapper.find('button:nth-child(2)').trigger('click')

    expect(wrapper.find('.count').text()).toBe('1')
  })

  it('点击 -1 按钮应该减少计数', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 5 } })

    await wrapper.find('button:nth-child(3)').trigger('click')

    expect(wrapper.find('.count').text()).toBe('4')
  })

  it('点击重置按钮应该归零', async () => {
    const wrapper = mount(Counter, { props: { initialCount: 100 } })

    await wrapper.find('button:nth-child(4)').trigger('click')

    expect(wrapper.find('.count').text()).toBe('0')
  })

  it('连续点击应该累加', async () => {
    const wrapper = mount(Counter)

    // 连续点击 3 次 +1
    const incrementBtn = wrapper.findAll('button')[0]
    await incrementBtn.trigger('click')
    await incrementBtn.trigger('click')
    await incrementBtn.trigger('click')

    expect(wrapper.find('.count').text()).toBe('3')
  })
})

23.3.2 测试 props、emit 和插槽

 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
<!-- src/components/UserCard.vue -->
<template>
  <div class="user-card">
    <img v-if="avatar" :src="avatar" :alt="name" class="avatar" />
    <div v-else class="avatar-placeholder">{{ name.charAt(0) }}</div>
    <div class="info">
      <h3>{{ name }}</h3>
      <p v-if="email" class="email">{{ email }}</p>
      <p v-if="bio" class="bio">{{ bio }}</p>
    </div>
    <div class="actions">
      <slot name="actions" :user="{ name, email }" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { defineProps } from 'vue'

defineProps<{
  name: string
  email?: string
  avatar?: string
  bio?: string
}>()

const emit = defineEmits<{
  (e: 'click', user: { name: string; email?: string }): void
}>()
</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
103
// src/components/UserCard.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

describe('UserCard 组件测试', () => {
  // Props 测试
  describe('Props', () => {
    it('应该正确显示用户名', () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三' }
      })
      expect(wrapper.find('h3').text()).toBe('张三')
    })

    it('当有邮箱时应该显示邮箱', () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三', email: 'zhangsan@example.com' }
      })
      expect(wrapper.find('.email').exists()).toBe(true)
      expect(wrapper.find('.email').text()).toBe('zhangsan@example.com')
    })

    it('当没有邮箱时不应该显示邮箱区域', () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三' }
      })
      expect(wrapper.find('.email').exists()).toBe(false)
    })

    it('当没有头像时应该显示名字首字母', () => {
      const wrapper = mount(UserCard, {
        props: { name: '李四' }
      })
      expect(wrapper.find('.avatar-placeholder').exists()).toBe(true)
      expect(wrapper.find('.avatar-placeholder').text()).toBe('李')
    })

    it('当有头像时应该显示头像图片', () => {
      const wrapper = mount(UserCard, {
        props: {
          name: '王五',
          avatar: 'https://example.com/avatar.jpg'
        }
      })
      const imgEl = wrapper.find('.avatar')
      expect(imgEl.exists()).toBe(true)
      expect(imgEl.attributes('src')).toBe('https://example.com/avatar.jpg')
    })
  })

  // Emit 测试
  describe('emit', () => {
    it('点击卡片应该触发 click 事件', async () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三', email: 'zhangsan@example.com' }
      })

      // 监听 click 事件
      const emitted = vi.fn()
      wrapper.vm.$on('click' as any, emitted)

      await wrapper.find('.user-card').trigger('click')

      // 或者用 wrapper.emitted()
      const clickEvents = wrapper.emitted('click')
      expect(clickEvents).toBeTruthy()
      expect(clickEvents![0][0]).toEqual({
        name: '张三',
        email: 'zhangsan@example.com'
      })
    })
  })

  // 插槽测试
  describe('插槽', () => {
    it('应该正确渲染 actions 插槽', () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三' },
        slots: {
          actions: '<button>编辑</button>'
        }
      })

      expect(wrapper.find('.actions button').exists()).toBe(true)
      expect(wrapper.find('.actions button').text()).toBe('编辑')
    })

    it('插槽应该能访问到 user 数据', () => {
      const wrapper = mount(UserCard, {
        props: { name: '张三', email: 'zhangsan@example.com' },
        slots: {
          actions: (props: any) => {
            // 在 Vue Test Utils 中,函数插槽接收 props
            return `<span>${props.user.name} - ${props.user.email}</span>`
          }
        }
      })

      expect(wrapper.find('.actions span').text()).toBe('张三 - zhangsan@example.com')
    })
  })
})

23.3.3 异步组件测试与 nextTick

Vue 的响应式更新是异步的,当修改了响应式数据后,DOM 不会立即更新。测试时需要用 nextTickflushPromises 来等待 DOM 更新。

 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
<!-- src/components/AsyncUser.vue -->
<template>
  <div class="async-user">
    <div v-if="loading" class="loading">加载中...</div>
    <div v-else-if="error" class="error">{{ error }}</div>
    <div v-else class="user-info">
      <p>用户名{{ user?.name }}</p>
      <p>邮箱{{ user?.email }}</p>
    </div>
    <button @click="loadUser">加载用户</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface User {
  name: string
  email: string
}

const loading = ref(false)
const error = ref('')
const user = ref<User | null>(null)

const loadUser = async () => {
  loading.value = true
  error.value = ''

  try {
    // 模拟 API 调用
    await new Promise(resolve => setTimeout(resolve, 100))
    user.value = { name: '测试用户', email: 'test@example.com' }
  } catch (e) {
    error.value = '加载失败'
  } finally {
    loading.value = false
  }
}
</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
// src/components/AsyncUser.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import AsyncUser from './AsyncUser.vue'

describe('AsyncUser 异步组件测试', () => {
  it('初始状态应该显示加载按钮', () => {
    const wrapper = mount(AsyncUser)
    expect(wrapper.find('.loading').exists()).toBe(false)
    expect(wrapper.find('.error').exists()).toBe(false)
    expect(wrapper.find('.user-info').exists()).toBe(false)
    expect(wrapper.find('button').exists()).toBe(true)
  })

  it('点击按钮后应该显示加载状态', async () => {
    vi.useFakeTimers()  // 使用假计时器,更可控

    const wrapper = mount(AsyncUser, {
      global: {
        mocks: {
          // mock setTimeout
        }
      }
    })

    // 开始计时器控制
    vi.useRealTimers()  // 先用真实的
    await wrapper.find('button').trigger('click')

    // 此时 loading 应该为 true
    // 注意:由于我们用的是真实的 setTimeout,需要用 flushPromises 等待
    // 或者用 nextTick

    vi.useFakeTimers()
    const promise = wrapper.find('button').trigger('click')

    // 快进 100ms
    vi.advanceTimersByTime(100)
    await flushPromises()

    expect(wrapper.find('.loading').exists()).toBe(true)

    vi.useRealTimers()
  })

  it('加载成功后应该显示用户信息', async () => {
    const wrapper = mount(AsyncUser)

    await wrapper.find('button').trigger('click')

    // 等待异步操作完成
    await flushPromises()

    expect(wrapper.find('.user-info').exists()).toBe(true)
    expect(wrapper.find('.user-info p:first-child').text()).toContain('测试用户')
  })
})

23.4 Composables 测试

Composables 是 Vue 3 的核心概念,它的测试其实比组件测试更简单——因为 composables 只是函数,直接调用函数、断言返回值即可。

 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/composables/useCounter.ts
import { ref, computed, readonly } from 'vue'

export interface UseCounterOptions {
  min?: number
  max?: number
}

export interface UseCounterReturn {
  count: Readonly<typeof count>
  increment: () => void
  decrement: () => void
  reset: () => void
  countPlusOne: () => number
}

export function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
  const { min = -Infinity, max = Infinity } = options

  const count = ref(initialValue)

  const increment = () => {
    if (count.value < max) {
      count.value++
    }
  }

  const decrement = () => {
    if (count.value > min) {
      count.value--
    }
  }

  const reset = () => {
    count.value = initialValue
  }

  const countPlusOne = computed(() => count.value + 1)

  return {
    count: readonly(count),
    increment,
    decrement,
    reset,
    countPlusOne
  } as UseCounterReturn
}
 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
// src/composables/useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter composable', () => {
  it('应该返回初始值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('默认值应该是 0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('increment 应该让计数 +1', () => {
    const { count, increment } = useCounter(0)
    increment()
    expect(count.value).toBe(1)
    increment()
    expect(count.value).toBe(2)
  })

  it('decrement 应该让计数 -1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })

  it('reset 应该恢复到初始值', () => {
    const { count, increment, reset } = useCounter(10)
    increment()
    increment()
    increment()
    expect(count.value).toBe(13)
    reset()
    expect(count.value).toBe(10)
  })

  it('不应该超过最大值', () => {
    const { count, increment } = useCounter(5, { max: 10 })
    for (let i = 0; i < 10; i++) {
      increment()
    }
    // 达到最大值后不应该继续增加
    expect(count.value).toBe(10)
    increment()  // 再点一次
    expect(count.value).toBe(10)  // 应该还是 10
  })

  it('不应该小于最小值', () => {
    const { count, decrement } = useCounter(5, { min: 0 })
    for (let i = 0; i < 10; i++) {
      decrement()
    }
    expect(count.value).toBe(0)
    decrement()
    expect(count.value).toBe(0)
  })

  it('countPlusOne 应该返回 count + 1', () => {
    const { count, countPlusOne } = useCounter(5)
    expect(countPlusOne.value).toBe(6)
    count.value = 100
    expect(countPlusOne.value).toBe(101)
  })

  it('count 应该是只读的,不能直接修改', () => {
    const { count } = useCounter(0)
    // 这里应该编译失败或运行时报错
    // count.value = 999  // 不应该能这样做
    expect(count.value).toBe(0)
  })
})

23.5 Router 与 Store 测试

23.5.1 Vue Router 测试

Vue Router 测试主要关注:路由跳转是否正确、query 参数是否正确传递、路由守卫是否按预期执行。

 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
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: { template: '<div>首页</div>' }
    },
    {
      path: '/user/:id',
      name: 'UserProfile',
      component: { template: '<div>用户页</div>' }
    },
    {
      path: '/login',
      name: 'Login',
      component: { template: '<div>登录页</div>' }
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'NotFound',
      component: { template: '<div>404</div>' }
    }
  ]
})

export default router
 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
// src/router/index.spec.ts
import { describe, it, expect } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import { mount } from '@vue/test-utils'

describe('Vue Router 测试', () => {
  // 创建测试用 router(使用 memory history 避免依赖浏览器环境)
  const createTestRouter = () => {
    return createRouter({
      history: createMemoryHistory(),
      routes: [
        { path: '/', name: 'Home', component: { template: '<div>首页</div>' } },
        { path: '/user/:id', name: 'UserProfile', component: { template: '<div>用户页</div>' } },
        { path: '/login', name: 'Login', component: { template: '<div>登录页</div>' } },
        { path: '/:pathMatch(.*)*', name: 'NotFound', component: { template: '<div>404</div>' } }
      ]
    })
  }

  it('应该正确跳转到首页', async () => {
    const router = createTestRouter()
    const wrapper = mount({ template: '<router-view />' }, {
      global: {
        plugins: [router]
      }
    })

    await router.push('/')
    await router.isReady()

    expect(wrapper.text()).toContain('首页')
  })

  it('应该正确传递路由参数', async () => {
    const router = createTestRouter()
    const wrapper = mount({ template: '<router-view />' }, {
      global: {
        plugins: [router]
      }
    })

    await router.push({ name: 'UserProfile', params: { id: '123' } })
    await router.isReady()

    expect(router.currentRoute.value.params.id).toBe('123')
  })

  it('访问不存在的路由应该跳转到 404', async () => {
    const router = createTestRouter()
    const wrapper = mount({ template: '<router-view />' }, {
      global: {
        plugins: [router]
      }
    })

    await router.push('/some/nonexistent/path')
    await router.isReady()

    expect(wrapper.text()).toContain('404')
  })

  it('router-link 应该正确渲染', async () => {
    const router = createTestRouter()
    const wrapper = mount({
      template: `
        <nav>
          <router-link to="/">首页</router-link>
          <router-link to="/login">登录</router-link>
        </nav>
        <router-view />
      `
    }, {
      global: {
        plugins: [router]
      }
    })

    await router.isReady()

    const links = wrapper.findAllComponents({ name: 'RouterLink' })
    expect(links.length).toBe(2)
  })
})

23.5.2 Pinia Store 测试

Pinia store 的测试重点在于:state 是否正确初始化、actions 是否按预期执行、getters 是否正确计算。

 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
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'

export const useUserStore = defineStore('user', () => {
  // State
  const userInfo = ref<{ id: number; name: string; email: string } | null>(null)
  const isLoggedIn = ref(false)
  const loading = ref(false)

  // Getters
  const userName = computed(() => userInfo.value?.name ?? '游客')
  const userId = computed(() => userInfo.value?.id ?? null)

  // Actions
  const setUserInfo = (info: typeof userInfo.value) => {
    userInfo.value = info
    isLoggedIn.value = !!info
  }

  const login = async (username: string, password: string) => {
    loading.value = true
    try {
      const response = await axios.post('/api/login', { username, password })
      setUserInfo(response.data.userInfo)
      return { success: true }
    } catch (error) {
      return { success: false, error: '登录失败' }
    } finally {
      loading.value = false
    }
  }

  const logout = () => {
    setUserInfo(null)
    localStorage.removeItem('token')
  }

  return {
    userInfo,
    isLoggedIn,
    loading,
    userName,
    userId,
    setUserInfo,
    login,
    logout
  }
})
  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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// src/stores/user.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from './user'

// Mock axios
vi.mock('axios', () => ({
  default: {
    post: vi.fn()
  }
}))

describe('User Store 测试', () => {
  beforeEach(() => {
    // 每个测试前创建新的 Pinia 实例
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  describe('State 初始化', () => {
    it('初始状态应该是未登录', () => {
      const store = useUserStore()
      expect(store.isLoggedIn).toBe(false)
      expect(store.userInfo).toBe(null)
      expect(store.loading).toBe(false)
    })

    it('初始用户名应该是"游客"', () => {
      const store = useUserStore()
      expect(store.userName).toBe('游客')
    })

    it('初始 userId 应该是 null', () => {
      const store = useUserStore()
      expect(store.userId).toBe(null)
    })
  })

  describe('setUserInfo action', () => {
    it('设置用户信息后应该自动登录', () => {
      const store = useUserStore()
      const userInfo = { id: 1, name: '张三', email: 'zhangsan@example.com' }

      store.setUserInfo(userInfo)

      expect(store.userInfo).toEqual(userInfo)
      expect(store.isLoggedIn).toBe(true)
      expect(store.userName).toBe('张三')
      expect(store.userId).toBe(1)
    })

    it('传入 null 应该登出', () => {
      const store = useUserStore()
      store.setUserInfo({ id: 1, name: '张三', email: 'zhangsan@example.com' })
      expect(store.isLoggedIn).toBe(true)

      store.setUserInfo(null)
      expect(store.isLoggedIn).toBe(false)
      expect(store.userName).toBe('游客')
    })
  })

  describe('login action', () => {
    it('登录成功应该设置用户信息并返回 success', async () => {
      const store = useUserStore()
      const axios = vi.mocked(await import('axios')).default

      axios.post.mockResolvedValue({
        data: {
          userInfo: { id: 1, name: '张三', email: 'zhangsan@example.com' }
        }
      })

      const result = await store.login('zhangsan', 'password123')

      expect(result.success).toBe(true)
      expect(store.isLoggedIn).toBe(true)
      expect(store.userName).toBe('张三')
    })

    it('登录失败应该返回错误', async () => {
      const store = useUserStore()
      const axios = vi.mocked(await import('axios')).default

      axios.post.mockRejectedValue(new Error('网络错误'))

      const result = await store.login('wrong', 'wrong')

      expect(result.success).toBe(false)
      expect(result.error).toBe('登录失败')
      expect(store.isLoggedIn).toBe(false)
    })

    it('登录时 loading 应该变为 true', async () => {
      const store = useUserStore()
      const axios = vi.mocked(await import('axios')).default

      let loadingDuringRequest = false
      axios.post.mockImplementation(() => {
        loadingDuringRequest = store.loading
        return Promise.resolve({ data: { userInfo: { id: 1, name: 'Test', email: 'test@example.com' } } })
      })

      const promise = store.login('test', 'test')
      expect(store.loading).toBe(true)
      await promise
      expect(store.loading).toBe(false)
    })
  })

  describe('logout action', () => {
    it('登出后应该清除用户信息并设置为未登录', () => {
      const store = useUserStore()
      store.setUserInfo({ id: 1, name: '张三', email: 'zhangsan@example.com' })
      expect(store.isLoggedIn).toBe(true)

      store.logout()
      expect(store.isLoggedIn).toBe(false)
      expect(store.userInfo).toBe(null)
      expect(store.userName).toBe('游客')
    })
  })
})

23.6 E2E 测试(Playwright)

23.6.1 Playwright 安装与配置

E2E 测试用 Playwright,它是目前最流行的浏览器自动化测试框架,支持 Chromium、Firefox、WebKit 三大浏览器引擎。

1
2
3
4
5
# 安装 Playwright
npm install -D @playwright/test

# 安装浏览器(Chromium、Firefox、WebKit)
npx playwright install --with-deps

在项目根目录创建 playwright.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
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',  // E2E 测试文件目录
  fullyParallel: true,     // 并行执行测试
  forbidOnly: !!process.env.CI,  // CI 环境下禁止只运行.skip 的测试
  retries: process.env.CI ? 2 : 0,  // 失败重试次数
  workers: process.env.CI ? 1 : undefined,  // 并行 worker 数量
  reporter: 'html',         // HTML 报告

  use: {
    baseURL: 'http://localhost:5173',  // 基础 URL
    trace: 'on-first-retry',  // 第一次失败时保存 trace
    screenshot: 'only-on-failure'  // 失败时截图
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] }
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] }
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] }
    }
  ],

  webServer: {
    command: 'npm run dev',
    port: 5173,
    reuseExistingServer: !process.env.CI
  }
})

23.6.2 第一个 E2E 测试

 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
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test.describe('登录页面', () => {
  test.beforeEach(async ({ page }) => {
    // 每个测试前访问登录页
    await page.goto('/login')
  })

  test('应该正确渲染登录页面元素', async ({ page }) => {
    // 检查标题
    await expect(page.locator('h1, h2, .title')).toContainText(['登录', 'Login', 'Sign in'])

    // 检查用户名输入框
    const usernameInput = page.getByPlaceholder(/用户名|账号|username|email/i)
    await expect(usernameInput).toBeVisible()

    // 检查密码输入框
    const passwordInput = page.getByPlaceholder(/密码|password/i)
    await expect(passwordInput).toBeVisible()

    // 检查登录按钮
    const loginButton = page.getByRole('button', { name: /登录|Login|Sign in/i })
    await expect(loginButton).toBeVisible()
  })

  test('空表单提交应该显示错误提示', async ({ page }) => {
    const loginButton = page.getByRole('button', { name: /登录|Login/i })
    await loginButton.click()

    // 检查是否有错误提示
    const errorMessages = page.locator('.error, .error-message, [role="alert"]')
    await expect(errorMessages.first()).toBeVisible()
  })

  test('输入错误密码应该显示错误提示', async ({ page }) => {
    await page.getByPlaceholder(/用户名|账号|username|email/i).fill('test@example.com')
    await page.getByPlaceholder(/密码|password/i).fill('wrongpassword')

    const loginButton = page.getByRole('button', { name: /登录|Login/i })
    await loginButton.click()

    // 等待响应(可能有 loading 状态)
    // 断言错误提示出现
    await expect(page.getByText(/密码错误|账号不存在|登录失败/i)).toBeVisible({ timeout: 5000 })
  })

  test('正确的凭据应该登录成功并跳转到首页', async ({ page }) => {
    // 使用测试账号
    await page.getByPlaceholder(/用户名|账号|username|email/i).fill('admin@example.com')
    await page.getByPlaceholder(/密码|password/i).fill('admin123')

    const loginButton = page.getByRole('button', { name: /登录|Login/i })
    await loginButton.click()

    // 等待跳转(通常登录成功后会自动跳转)
    await page.waitForURL('**/', { timeout: 5000 })

    // 检查是否跳转到了首页或仪表盘
    await expect(page).toHaveURL(/\/|home|dashboard/i)
  })
})

23.6.3 常用 Playwright API

  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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import { test, expect, Page, Locator } from '@playwright/test'

test.describe('Playwright 常用 API 示例', () => {
  test('选择器使用', async ({ page }) => {
    // 按文本查找
    await page.getByText('提交').click()

    // 按占位符查找
    await page.getByPlaceholder('请输入用户名').fill('test')

    // 按标签和属性查找
    await page.locator('button[type="submit"]').click()

    // 按测试 ID 查找(推荐,数据属性稳定)
    await page.getByTestId('submit-button').click()

    // CSS 选择器
    await page.locator('.form-group input').first().fill('value')

    // XPath(不推荐,不稳定)
    await page.locator('//input[@name="email"]').fill('test@example.com')
  })

  test('交互操作', async ({ page }) => {
    // 点击
    await page.click('#button')

    // 填写输入框(清空后填写)
    await page.fill('#input', 'hello')

    // 追加输入(不清空,直接追加)
    await page.type('#input', ' world')

    // 清空输入框
    await page.locator('#input').clear()

    // 下拉框选择
    await page.selectOption('#select', 'option-value')
    // 或者按文本选择
    await page.selectOption('#select', { label: 'Option Label' })

    // 勾选/取消勾选复选框
    await page.check('#checkbox')
    await page.uncheck('#checkbox')

    // 开关切换
    await page.locator('.el-switch').click()

    // 文件上传
    await page.setInputFiles('#file-input', './fixtures/test-image.png')

    // 悬停
    await page.hover('#dropdown-trigger')
    // 显示下拉菜单后点击
    await page.getByText('下拉选项').click()

    // 拖拽
    await page.dragAndDrop('#source', '#target')
  })

  test('断言', async ({ page }) => {
    // 文本断言
    await expect(page.locator('#title')).toHaveText('标题')
    await expect(page.locator('#title')).toContainText('标')

    // 可见性断言
    await expect(page.locator('#loading')).not.toBeVisible()
    await expect(page.locator('#modal')).toBeVisible()

    // URL 断言
    await expect(page).toHaveURL('**/home')
    await expect(page).toHaveURL(/\/user\/\d+/)

    // 标题断言
    await expect(page).toHaveTitle('页面标题')

    // 计数断言
    await expect(page.locator('.list-item')).toHaveCount(5)

    // 状态断言
    await expect(page.locator('#submit')).toBeEnabled()
    await expect(page.locator('#disabled')).toBeDisabled()
    await expect(page.locator('#checkbox')).toBeChecked()

    // CSS 属性断言
    await expect(page.locator('#box')).toHaveCSS('background-color', 'rgb(255, 0, 0)')
  })

  test('等待与超时', async ({ page }) => {
    // 等待元素可见
    await page.waitForSelector('#element', { state: 'visible', timeout: 5000 })

    // 等待 URL 变化
    await page.waitForURL('**/success', { timeout: 10000 })

    // 等待网络请求完成
    await page.waitForResponse(response => response.url().includes('/api/data'))

    // 等待函数返回 true
    await page.waitForFunction(() => document.querySelector('.loaded') !== null)
  })

  test('API 请求拦截', async ({ page }) => {
    // 拦截 API 请求并返回 mock 数据
    await page.route('/api/user/info', route => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          id: 1,
          name: 'Mock User',
          email: 'mock@example.com'
        })
      })
    })

    // 访问页面
    await page.goto('/profile')

    // 页面会显示 mock 的用户信息
    await expect(page.getByText('Mock User')).toBeVisible()
  })
})

23.7 CI 集成

23.7.1 GitHub Actions 配置

把测试集成到 CI/CD 流程中,每次 push 和 PR 都能自动运行测试:

 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
# .github/workflows/test.yml
name: Test

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

jobs:
  # 单元测试和集成测试
  unit-test:
    name: Unit & Integration Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npm run type-check

      - name: Run unit tests
        run: npm run test:run -- --coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
          fail_ci_if_error: false

  # E2E 测试
  e2e-test:
    name: E2E Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

      - name: Upload screenshots on failure
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-screenshots
          path: test-results/
          retention-days: 7

23.7.2 测试覆盖率

测试覆盖率衡量的是测试对代码的覆盖程度。Vitest 配合 @vitest/coverage-v8 可以生成漂亮的覆盖率报告:

1
2
3
4
# 运行带覆盖率的测试
npm run test:coverage

# 打开 HTML 报告(在 coverage/index.html)

覆盖率报告通常包括:

  • Line Coverage(行覆盖率):有多少行代码被执行了
  • Branch Coverage(分支覆盖率):条件分支(if/else)的各个分支是否都被覆盖
  • Function Coverage(函数覆盖率):有多少函数被调用了

好的覆盖率不等于高质量的测试。测试的目的是验证代码行为是否符合预期,而不是凑覆盖率数字。一个 100% 覆盖但全是无效断言的测试,不如一个 60% 覆盖但每个断言都有意义的测试。

23.8 本章小结

本章从测试金字塔讲起,依次介绍了单元测试、集成测试和 E2E 测试的区别和各自的价值。重点讲解了 Vitest 的配置和使用、Vue Test Utils 组件测试、Composables 测试、Router/Store 测试,以及 Playwright E2E 测试的写法。

核心要点回顾

  • 测试金字塔:单元测试最多最快,E2E 测试最少最慢但最接近真实用户体验
  • Vitest 是 Vue 3 项目首选的测试框架,配置简单,速度极快
  • 组件测试用 mount/shallowMount,异步操作用 flushPromises 等待
  • Composables 测试就是测试函数,直接调用函数断言返回值即可
  • Pinia store 测试用 createPinia()setActivePinia() 隔离测试环境
  • E2E 测试用 Playwright,关注核心用户路径,不要试图覆盖所有场景
  • CI 集成是保证代码质量的重要手段,每次 PR 都应该自动运行测试