Vue 3 测试策略与最佳实践:从单元测试到端到端测试
Orion K Lv6

测试是现代前端开发中不可或缺的一环,特别是在大型项目中,完善的测试策略能够显著提高代码质量和开发效率。本文将深入探讨 Vue 3 应用的测试策略,包括单元测试、组件测试、集成测试和端到端测试的最佳实践。

测试金字塔与策略

1. 测试分层架构

测试金字塔理论

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
// 测试策略配置
export const testingStrategy = {
// 单元测试 (70%)
unit: {
tools: ['Vitest', 'Jest'],
scope: ['纯函数', '工具类', 'Composables', '简单组件'],
characteristics: ['快速', '隔离', '大量']
},

// 集成测试 (20%)
integration: {
tools: ['Vue Test Utils', 'Testing Library'],
scope: ['组件交互', 'API集成', '状态管理'],
characteristics: ['中等速度', '真实环境', '适量']
},

// 端到端测试 (10%)
e2e: {
tools: ['Playwright', 'Cypress'],
scope: ['用户流程', '关键路径', '跨浏览器'],
characteristics: ['慢速', '完整环境', '少量']
}
}

// 测试覆盖率目标
export const coverageTargets = {
statements: 80,
branches: 75,
functions: 80,
lines: 80
}

2. 测试环境配置

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
55
56
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
plugins: [vue()],

test: {
// 测试环境
environment: 'jsdom',

// 全局设置
globals: true,

// 设置文件
setupFiles: ['./tests/setup.ts'],

// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts'
],
thresholds: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80
}
}
},

// 并发配置
threads: true,
maxThreads: 4,
minThreads: 1,

// 超时配置
testTimeout: 10000,
hookTimeout: 10000
},

resolve: {
alias: {
'@': resolve(__dirname, './src'),
'~': resolve(__dirname, './tests')
}
}
})

测试设置文件

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
// tests/setup.ts
import { vi } from 'vitest'
import { config } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'

// 全局测试配置
config.global.plugins = [
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
]

// 模拟全局对象
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})

// 模拟 ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))

// 模拟 IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))

// 模拟 localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
})

// 模拟 fetch
global.fetch = vi.fn()

// 测试工具函数
export const createMockRouter = () => ({
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
currentRoute: {
value: {
path: '/',
query: {},
params: {},
meta: {}
}
}
})

export const createMockStore = (initialState = {}) => {
return createTestingPinia({
initialState,
createSpy: vi.fn
})
}

单元测试最佳实践

1. 工具函数测试

纯函数测试

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/utils/format.ts
export function formatCurrency(amount: number, currency = 'CNY'): string {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new Error('Amount must be a valid number')
}

return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency
}).format(amount)
}

export function formatDate(date: Date | string, format = 'YYYY-MM-DD'): string {
const d = new Date(date)

if (isNaN(d.getTime())) {
throw new Error('Invalid date')
}

const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')

return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
}

export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout

return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func(...args), delay)
}
}

对应的测试文件

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
// tests/utils/format.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { formatCurrency, formatDate, debounce } from '@/utils/format'

describe('formatCurrency', () => {
it('应该正确格式化人民币', () => {
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
expect(formatCurrency(0)).toBe('¥0.00')
expect(formatCurrency(1000000)).toBe('¥1,000,000.00')
})

it('应该支持不同货币', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('US$1,234.56')
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56')
})

it('应该处理边界情况', () => {
expect(formatCurrency(0.01)).toBe('¥0.01')
expect(formatCurrency(-1234.56)).toBe('-¥1,234.56')
})

it('应该抛出错误对于无效输入', () => {
expect(() => formatCurrency(NaN)).toThrow('Amount must be a valid number')
expect(() => formatCurrency('invalid' as any)).toThrow('Amount must be a valid number')
})
})

describe('formatDate', () => {
it('应该正确格式化日期', () => {
const date = new Date('2023-06-10')
expect(formatDate(date)).toBe('2023-06-10')
})

it('应该处理字符串日期', () => {
expect(formatDate('2023-06-10')).toBe('2023-06-10')
expect(formatDate('2023-01-01')).toBe('2023-01-01')
})

it('应该支持自定义格式', () => {
const date = new Date('2023-06-10')
expect(formatDate(date, 'DD/MM/YYYY')).toBe('10/06/2023')
expect(formatDate(date, 'MM-DD')).toBe('06-10')
})

it('应该抛出错误对于无效日期', () => {
expect(() => formatDate('invalid-date')).toThrow('Invalid date')
expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date')
})
})

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

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

it('应该延迟执行函数', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 100)

debouncedFn('test')
expect(mockFn).not.toHaveBeenCalled()

vi.advanceTimersByTime(100)
expect(mockFn).toHaveBeenCalledWith('test')
expect(mockFn).toHaveBeenCalledTimes(1)
})

it('应该取消之前的调用', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 100)

debouncedFn('first')
vi.advanceTimersByTime(50)
debouncedFn('second')

vi.advanceTimersByTime(100)
expect(mockFn).toHaveBeenCalledWith('second')
expect(mockFn).toHaveBeenCalledTimes(1)
})

it('应该保持函数参数类型', () => {
const mockFn = vi.fn((a: number, b: string) => a + b.length)
const debouncedFn = debounce(mockFn, 100)

debouncedFn(1, 'test')
vi.advanceTimersByTime(100)

expect(mockFn).toHaveBeenCalledWith(1, 'test')
})
})

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

export function useCounter(initialValue = 0) {
const count = ref(initialValue)

const isEven = computed(() => count.value % 2 === 0)
const isPositive = computed(() => count.value > 0)

const increment = (step = 1) => {
count.value += step
}

const decrement = (step = 1) => {
count.value -= step
}

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

const set = (value: number) => {
count.value = value
}

return {
count: readonly(count),
isEven,
isPositive,
increment,
decrement,
reset,
set
}
}

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
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
// tests/composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'

describe('useCounter', () => {
it('应该初始化为默认值', () => {
const { count, isEven, isPositive } = useCounter()

expect(count.value).toBe(0)
expect(isEven.value).toBe(true)
expect(isPositive.value).toBe(false)
})

it('应该初始化为指定值', () => {
const { count, isEven, isPositive } = useCounter(5)

expect(count.value).toBe(5)
expect(isEven.value).toBe(false)
expect(isPositive.value).toBe(true)
})

it('应该正确递增', () => {
const { count, increment } = useCounter(0)

increment()
expect(count.value).toBe(1)

increment(3)
expect(count.value).toBe(4)
})

it('应该正确递减', () => {
const { count, decrement } = useCounter(10)

decrement()
expect(count.value).toBe(9)

decrement(5)
expect(count.value).toBe(4)
})

it('应该正确重置', () => {
const { count, increment, reset } = useCounter(5)

increment(10)
expect(count.value).toBe(15)

reset()
expect(count.value).toBe(5)
})

it('应该正确设置值', () => {
const { count, set } = useCounter()

set(42)
expect(count.value).toBe(42)
})

it('计算属性应该正确响应', () => {
const { count, isEven, isPositive, set } = useCounter()

set(2)
expect(isEven.value).toBe(true)
expect(isPositive.value).toBe(true)

set(3)
expect(isEven.value).toBe(false)
expect(isPositive.value).toBe(true)

set(-1)
expect(isEven.value).toBe(false)
expect(isPositive.value).toBe(false)
})
})

异步 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
// src/composables/useApi.ts
import { ref } from 'vue'

export function useApi<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)

const execute = async () => {
loading.value = true
error.value = null

try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
loading.value = false
}
}

return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
execute
}
}

异步测试

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
// tests/composables/useApi.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useApi } from '@/composables/useApi'

// 模拟 fetch
const mockFetch = vi.fn()
global.fetch = mockFetch

describe('useApi', () => {
beforeEach(() => {
mockFetch.mockClear()
})

it('应该初始化为默认状态', () => {
const { data, loading, error } = useApi('/api/test')

expect(data.value).toBeNull()
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
})

it('应该成功获取数据', async () => {
const mockData = { id: 1, name: 'Test' }
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockData)
})

const { data, loading, error, execute } = useApi('/api/test')

const promise = execute()
expect(loading.value).toBe(true)

await promise

expect(loading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBeNull()
expect(mockFetch).toHaveBeenCalledWith('/api/test')
})

it('应该处理HTTP错误', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404
})

const { data, loading, error, execute } = useApi('/api/test')

await execute()

expect(loading.value).toBe(false)
expect(data.value).toBeNull()
expect(error.value).toBe('HTTP error! status: 404')
})

it('应该处理网络错误', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))

const { data, loading, error, execute } = useApi('/api/test')

await execute()

expect(loading.value).toBe(false)
expect(data.value).toBeNull()
expect(error.value).toBe('Network error')
})
})

组件测试最佳实践

1. 基础组件测试

Button 组件测试

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
// tests/components/Button.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/Button/Button.vue'

describe('Button', () => {
it('应该渲染默认按钮', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
})

expect(wrapper.text()).toBe('Click me')
expect(wrapper.classes()).toContain('btn')
expect(wrapper.classes()).toContain('btn--primary')
expect(wrapper.classes()).toContain('btn--md')
})

it('应该应用正确的变体类', () => {
const wrapper = mount(Button, {
props: {
variant: 'secondary',
size: 'lg',
color: 'success'
}
})

expect(wrapper.classes()).toContain('btn--secondary')
expect(wrapper.classes()).toContain('btn--lg')
expect(wrapper.classes()).toContain('btn--success')
})

it('应该处理禁用状态', () => {
const wrapper = mount(Button, {
props: {
disabled: true
}
})

expect(wrapper.attributes('disabled')).toBeDefined()
expect(wrapper.classes()).toContain('btn--disabled')
})

it('应该处理加载状态', () => {
const wrapper = mount(Button, {
props: {
loading: true
}
})

expect(wrapper.classes()).toContain('btn--loading')
expect(wrapper.find('.btn-loading').exists()).toBe(true)
expect(wrapper.attributes('disabled')).toBeDefined()
})

it('应该触发点击事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: {
onClick
}
})

await wrapper.trigger('click')
expect(onClick).toHaveBeenCalledTimes(1)
})

it('禁用时不应该触发点击事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: {
disabled: true,
onClick
}
})

await wrapper.trigger('click')
expect(onClick).not.toHaveBeenCalled()
})

it('加载时不应该触发点击事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: {
loading: true,
onClick
}
})

await wrapper.trigger('click')
expect(onClick).not.toHaveBeenCalled()
})

it('应该渲染图标插槽', () => {
const wrapper = mount(Button, {
slots: {
icon: '<i class="icon-test"></i>',
default: 'Button'
}
})

expect(wrapper.find('.icon-test').exists()).toBe(true)
expect(wrapper.find('.btn-icon').exists()).toBe(true)
})

it('应该支持块级按钮', () => {
const wrapper = mount(Button, {
props: {
block: true
}
})

expect(wrapper.classes()).toContain('btn--block')
})
})

2. 复杂组件测试

Form 组件测试

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
// tests/components/Form.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Form from '@/components/Form/Form.vue'
import FormItem from '@/components/Form/FormItem.vue'
import Input from '@/components/Input/Input.vue'

describe('Form', () => {
const createFormWrapper = (props = {}, slots = {}) => {
return mount(Form, {
props,
slots: {
default: `
<FormItem name="username" label="用户名" :rules="[{ required: true }]">
<Input v-model="formData.username" />
</FormItem>
<FormItem name="email" label="邮箱" :rules="[{ required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }]">
<Input v-model="formData.email" type="email" />
</FormItem>
`,
...slots
},
global: {
components: {
FormItem,
Input
}
}
})
}

it('应该渲染表单', () => {
const wrapper = createFormWrapper()

expect(wrapper.find('form').exists()).toBe(true)
expect(wrapper.classes()).toContain('form')
expect(wrapper.classes()).toContain('form--vertical')
})

it('应该应用布局类', () => {
const wrapper = createFormWrapper({
layout: 'horizontal',
size: 'lg'
})

expect(wrapper.classes()).toContain('form--horizontal')
expect(wrapper.classes()).toContain('form--lg')
})

it('应该验证表单', async () => {
const onSubmit = vi.fn()
const wrapper = createFormWrapper({
onSubmit
})

// 提交空表单
await wrapper.find('form').trigger('submit')

expect(onSubmit).toHaveBeenCalledWith(
expect.any(Object),
false // 验证失败
)
})

it('应该处理字段变化', async () => {
const onFieldChange = vi.fn()
const wrapper = createFormWrapper({
'onField-change': onFieldChange
})

const usernameInput = wrapper.find('input[type="text"]')
await usernameInput.setValue('testuser')

expect(onFieldChange).toHaveBeenCalledWith('username', 'testuser')
})

it('应该显示验证错误', async () => {
const wrapper = createFormWrapper()

// 触发验证
await wrapper.find('form').trigger('submit')
await nextTick()

// 检查错误信息
const errorMessages = wrapper.findAll('.form-item-error')
expect(errorMessages.length).toBeGreaterThan(0)
})

it('应该重置表单', async () => {
const wrapper = createFormWrapper()
const formInstance = wrapper.vm

// 设置一些值
formInstance.setFieldValue('username', 'testuser')
formInstance.setFieldValue('email', 'test@example.com')

// 重置表单
formInstance.reset()
await nextTick()

expect(formInstance.getFormData()).toEqual({
username: '',
email: ''
})
})
})

3. 状态管理测试

Pinia Store 测试

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

export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}

export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null)
const isAuthenticated = computed(() => currentUser.value !== null)
const isAdmin = computed(() => currentUser.value?.role === 'admin')

const login = async (credentials: { email: string; password: string }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})

if (!response.ok) {
throw new Error('Login failed')
}

const user = await response.json()
currentUser.value = user
return user
} catch (error) {
throw error
}
}

const logout = () => {
currentUser.value = null
}

const updateProfile = (updates: Partial<User>) => {
if (currentUser.value) {
currentUser.value = { ...currentUser.value, ...updates }
}
}

return {
currentUser: readonly(currentUser),
isAuthenticated,
isAdmin,
login,
logout,
updateProfile
}
})

Store 测试

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
// tests/stores/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'

// 模拟 fetch
const mockFetch = vi.fn()
global.fetch = mockFetch

describe('User Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockFetch.mockClear()
})

it('应该初始化为未认证状态', () => {
const store = useUserStore()

expect(store.currentUser).toBeNull()
expect(store.isAuthenticated).toBe(false)
expect(store.isAdmin).toBe(false)
})

it('应该成功登录', async () => {
const mockUser = {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user' as const
}

mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser)
})

const store = useUserStore()
const credentials = { email: 'test@example.com', password: 'password' }

const result = await store.login(credentials)

expect(result).toEqual(mockUser)
expect(store.currentUser).toEqual(mockUser)
expect(store.isAuthenticated).toBe(true)
expect(store.isAdmin).toBe(false)

expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})
})

it('应该处理登录失败', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401
})

const store = useUserStore()
const credentials = { email: 'test@example.com', password: 'wrong' }

await expect(store.login(credentials)).rejects.toThrow('Login failed')
expect(store.currentUser).toBeNull()
expect(store.isAuthenticated).toBe(false)
})

it('应该正确识别管理员', async () => {
const mockAdmin = {
id: 1,
name: 'Admin User',
email: 'admin@example.com',
role: 'admin' as const
}

mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAdmin)
})

const store = useUserStore()
await store.login({ email: 'admin@example.com', password: 'password' })

expect(store.isAdmin).toBe(true)
})

it('应该正确登出', () => {
const store = useUserStore()

// 先设置用户
store.currentUser = {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user'
}

store.logout()

expect(store.currentUser).toBeNull()
expect(store.isAuthenticated).toBe(false)
})

it('应该更新用户资料', () => {
const store = useUserStore()

// 先设置用户
store.currentUser = {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user'
}

store.updateProfile({ name: 'Updated Name' })

expect(store.currentUser?.name).toBe('Updated Name')
expect(store.currentUser?.email).toBe('test@example.com') // 其他字段保持不变
})
})

集成测试与端到端测试

1. 集成测试

页面级组件测试

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
// tests/integration/LoginPage.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import LoginPage from '@/pages/LoginPage.vue'
import { useUserStore } from '@/stores/user'

describe('LoginPage Integration', () => {
let wrapper: any
let userStore: any

beforeEach(() => {
wrapper = mount(LoginPage, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
]
}
})

userStore = useUserStore()
})

it('应该渲染登录表单', () => {
expect(wrapper.find('form').exists()).toBe(true)
expect(wrapper.find('input[type="email"]').exists()).toBe(true)
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
})

it('应该验证必填字段', async () => {
const submitButton = wrapper.find('button[type="submit"]')
await submitButton.trigger('click')

expect(wrapper.text()).toContain('邮箱为必填项')
expect(wrapper.text()).toContain('密码为必填项')
})

it('应该验证邮箱格式', async () => {
const emailInput = wrapper.find('input[type="email"]')
await emailInput.setValue('invalid-email')

const submitButton = wrapper.find('button[type="submit"]')
await submitButton.trigger('click')

expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})

it('应该成功提交登录表单', async () => {
// 模拟成功登录
vi.spyOn(userStore, 'login').mockResolvedValue({
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user'
})

const emailInput = wrapper.find('input[type="email"]')
const passwordInput = wrapper.find('input[type="password"]')
const submitButton = wrapper.find('button[type="submit"]')

await emailInput.setValue('test@example.com')
await passwordInput.setValue('password123')
await submitButton.trigger('click')

expect(userStore.login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})

it('应该处理登录错误', async () => {
// 模拟登录失败
vi.spyOn(userStore, 'login').mockRejectedValue(new Error('Invalid credentials'))

const emailInput = wrapper.find('input[type="email"]')
const passwordInput = wrapper.find('input[type="password"]')
const submitButton = wrapper.find('button[type="submit"]')

await emailInput.setValue('test@example.com')
await passwordInput.setValue('wrongpassword')
await submitButton.trigger('click')

await wrapper.vm.$nextTick()

expect(wrapper.text()).toContain('登录失败')
})
})

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
// 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 ? 1 : undefined,

// 报告器
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }]
],

use: {
// 基础 URL
baseURL: 'http://localhost:3000',

// 截图设置
screenshot: 'only-on-failure',

// 视频录制
video: 'retain-on-failure',

// 追踪
trace: 'on-first-retry'
},

// 项目配置
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: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})

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

test.describe('用户认证流程', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})

test('应该显示登录页面', async ({ page }) => {
await expect(page).toHaveTitle(/登录/)
await expect(page.locator('h1')).toContainText('用户登录')
await expect(page.locator('input[type="email"]')).toBeVisible()
await expect(page.locator('input[type="password"]')).toBeVisible()
await expect(page.locator('button[type="submit"]')).toBeVisible()
})

test('应该验证表单字段', async ({ page }) => {
// 点击提交按钮而不填写任何字段
await page.click('button[type="submit"]')

// 检查验证错误
await expect(page.locator('.error-message')).toContainText('邮箱为必填项')

// 填写无效邮箱
await page.fill('input[type="email"]', 'invalid-email')
await page.click('button[type="submit"]')

await expect(page.locator('.error-message')).toContainText('请输入有效的邮箱地址')
})

test('应该成功登录', async ({ page }) => {
// 填写登录表单
await page.fill('input[type="email"]', 'test@example.com')
await page.fill('input[type="password"]', 'password123')

// 提交表单
await page.click('button[type="submit"]')

// 等待导航到仪表板
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('仪表板')

// 检查用户信息
await expect(page.locator('.user-info')).toContainText('test@example.com')
})

test('应该处理登录失败', async ({ page }) => {
// 填写错误的登录信息
await page.fill('input[type="email"]', 'test@example.com')
await page.fill('input[type="password"]', 'wrongpassword')

// 提交表单
await page.click('button[type="submit"]')

// 检查错误消息
await expect(page.locator('.error-message')).toContainText('用户名或密码错误')

// 确保仍在登录页面
await expect(page).toHaveURL('/')
})

test('应该支持记住我功能', async ({ page }) => {
// 勾选记住我
await page.check('input[type="checkbox"]')

// 登录
await page.fill('input[type="email"]', 'test@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')

// 等待导航
await expect(page).toHaveURL('/dashboard')

// 刷新页面,应该仍然保持登录状态
await page.reload()
await expect(page).toHaveURL('/dashboard')
})

test('应该支持登出', async ({ page }) => {
// 先登录
await page.fill('input[type="email"]', 'test@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')

await expect(page).toHaveURL('/dashboard')

// 点击登出按钮
await page.click('.logout-button')

// 应该重定向到登录页面
await expect(page).toHaveURL('/')
await expect(page.locator('h1')).toContainText('用户登录')
})
})

可视化回归测试

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

test.describe('视觉回归测试', () => {
test('登录页面截图对比', async ({ page }) => {
await page.goto('/')

// 等待页面完全加载
await page.waitForLoadState('networkidle')

// 截图对比
await expect(page).toHaveScreenshot('login-page.png')
})

test('仪表板页面截图对比', async ({ page }) => {
// 先登录
await page.goto('/')
await page.fill('input[type="email"]', 'test@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')

await expect(page).toHaveURL('/dashboard')
await page.waitForLoadState('networkidle')

// 截图对比
await expect(page).toHaveScreenshot('dashboard-page.png')
})

test('响应式设计测试', async ({ page }) => {
await page.goto('/')

// 桌面视图
await page.setViewportSize({ width: 1920, height: 1080 })
await expect(page).toHaveScreenshot('login-desktop.png')

// 平板视图
await page.setViewportSize({ width: 768, height: 1024 })
await expect(page).toHaveScreenshot('login-tablet.png')

// 手机视图
await page.setViewportSize({ width: 375, height: 667 })
await expect(page).toHaveScreenshot('login-mobile.png')
})
})

测试工具与辅助函数

1. 测试工具函数

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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
// tests/utils/test-helpers.ts
import { mount, VueWrapper } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import type { ComponentMountingOptions } from '@vue/test-utils'

/**
* 创建组件测试包装器
*/
export function createWrapper<T>(
component: T,
options: ComponentMountingOptions<T> = {}
): VueWrapper<any> {
return mount(component, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
],
...options.global
},
...options
})
}

/**
* 等待异步操作完成
*/
export async function flushPromises(): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, 0)
})
}

/**
* 模拟用户输入
*/
export async function userInput(
wrapper: VueWrapper<any>,
selector: string,
value: string
): Promise<void> {
const input = wrapper.find(selector)
await input.setValue(value)
await input.trigger('input')
await input.trigger('change')
}

/**
* 模拟用户点击
*/
export async function userClick(
wrapper: VueWrapper<any>,
selector: string
): Promise<void> {
const element = wrapper.find(selector)
await element.trigger('click')
}

/**
* 等待元素出现
*/
export async function waitForElement(
wrapper: VueWrapper<any>,
selector: string,
timeout = 1000
): Promise<void> {
const start = Date.now()

while (Date.now() - start < timeout) {
if (wrapper.find(selector).exists()) {
return
}
await new Promise(resolve => setTimeout(resolve, 10))
}

throw new Error(`Element ${selector} not found within ${timeout}ms`)
}

/**
* 模拟 API 响应
*/
export function mockApiResponse(data: any, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data))
}
}

/**
* 创建模拟路由器
*/
export function createMockRouter(currentRoute = '/') {
return {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
currentRoute: {
value: {
path: currentRoute,
query: {},
params: {},
meta: {}
}
}
}
}

/**
* 断言工具
*/
export const assertions = {
/**
* 断言元素可见
*/
toBeVisible(wrapper: VueWrapper<any>, selector: string) {
const element = wrapper.find(selector)
expect(element.exists()).toBe(true)
expect(element.isVisible()).toBe(true)
},

/**
* 断言元素包含文本
*/
toContainText(wrapper: VueWrapper<any>, selector: string, text: string) {
const element = wrapper.find(selector)
expect(element.exists()).toBe(true)
expect(element.text()).toContain(text)
},

/**
* 断言表单验证错误
*/
toHaveValidationError(wrapper: VueWrapper<any>, fieldName: string, errorMessage: string) {
const errorElement = wrapper.find(`[data-testid="${fieldName}-error"]`)
expect(errorElement.exists()).toBe(true)
expect(errorElement.text()).toContain(errorMessage)
}
}

2. 测试数据工厂

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
// tests/factories/user.factory.ts
import type { User } from '@/types/user'

/**
* 用户数据工厂
*/
export class UserFactory {
private static idCounter = 1

static create(overrides: Partial<User> = {}): User {
return {
id: this.idCounter++,
name: 'Test User',
email: `user${this.idCounter}@example.com`,
role: 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
}
}

static createMany(count: number, overrides: Partial<User> = {}): User[] {
return Array.from({ length: count }, () => this.create(overrides))
}

static createAdmin(overrides: Partial<User> = {}): User {
return this.create({
role: 'admin',
...overrides
})
}

static reset() {
this.idCounter = 1
}
}

// 使用示例
const user = UserFactory.create({ name: 'John Doe' })
const admin = UserFactory.createAdmin()
const users = UserFactory.createMany(5)

总结

Vue 3 的测试策略应该遵循以下原则:

  1. 分层测试:按照测试金字塔原理,重点关注单元测试,适量集成测试,少量端到端测试
  2. 测试驱动开发:先写测试,再写实现,确保代码质量
  3. 隔离测试:每个测试应该独立,不依赖其他测试的状态
  4. 真实场景:测试应该模拟真实的用户交互和使用场景
  5. 持续集成:将测试集成到 CI/CD 流程中,确保代码质量

通过完善的测试策略,可以显著提高代码质量,减少 bug,提升开发效率,为项目的长期维护奠定坚实基础。

本站由 提供部署服务