Vue.js 测试
为什么需要测试?
测试可以确保代码质量,防止回归错误,提高代码可维护性。Vue 应用主要有三种测试类型:
- 单元测试:测试独立的函数和组件
- 组件测试:测试组件的行为和交互
- 端到端测试(E2E):测试完整的用户流程
测试工具
Vitest(推荐)
Vitest 是一个快速的单元测试框架,与 Vite 完美集成。
bash
npm install -D vitest @vue/test-utils配置 Vitest
javascript
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})单元测试基础
测试纯函数
javascript
// utils/math.js
export function add(a, b) {
return a + b
}
export function multiply(a, b) {
return a * b
}javascript
// utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add, multiply } from './math'
describe('Math Utils', () => {
it('should add two numbers', () => {
expect(add(1, 2)).toBe(3)
expect(add(-1, 1)).toBe(0)
})
it('should multiply two numbers', () => {
expect(multiply(2, 3)).toBe(6)
expect(multiply(0, 5)).toBe(0)
})
})测试组合式函数
javascript
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
increment,
decrement,
reset
}
}javascript
// composables/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('should initialize with custom value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('should increment count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('should decrement count', () => {
const { count, decrement } = useCounter(5)
decrement()
expect(count.value).toBe(4)
})
it('should reset count', () => {
const { count, increment, reset } = useCounter(0)
increment()
increment()
reset()
expect(count.value).toBe(0)
})
})组件测试
测试简单组件
vue
<!-- components/Counter.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>javascript
// components/Counter.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter Component', () => {
it('should render initial count', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('Count: 0')
})
it('should increment count when button is clicked', async () => {
const wrapper = mount(Counter)
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('Count: 1')
await button.trigger('click')
expect(wrapper.text()).toContain('Count: 2')
})
})测试 Props
vue
<!-- components/Greeting.vue -->
<script setup>
defineProps({
name: {
type: String,
required: true
},
age: Number
})
</script>
<template>
<div>
<h1>Hello, {{ name }}!</h1>
<p v-if="age">You are {{ age }} years old.</p>
</div>
</template>javascript
// components/Greeting.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'
describe('Greeting Component', () => {
it('should render name prop', () => {
const wrapper = mount(Greeting, {
props: {
name: 'Alice'
}
})
expect(wrapper.text()).toContain('Hello, Alice!')
})
it('should render age when provided', () => {
const wrapper = mount(Greeting, {
props: {
name: 'Bob',
age: 25
}
})
expect(wrapper.text()).toContain('You are 25 years old.')
})
it('should not render age when not provided', () => {
const wrapper = mount(Greeting, {
props: {
name: 'Charlie'
}
})
expect(wrapper.text()).not.toContain('years old')
})
})测试 Emits
vue
<!-- components/CustomButton.vue -->
<script setup>
const emit = defineEmits(['click', 'doubleClick'])
function handleClick() {
emit('click', 'Button clicked')
}
function handleDoubleClick() {
emit('doubleClick', 'Button double clicked')
}
</script>
<template>
<button
@click="handleClick"
@dblclick="handleDoubleClick"
>
<slot>Click me</slot>
</button>
</template>javascript
// components/CustomButton.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CustomButton from './CustomButton.vue'
describe('CustomButton Component', () => {
it('should emit click event', async () => {
const wrapper = mount(CustomButton)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')[0]).toEqual(['Button clicked'])
})
it('should emit doubleClick event', async () => {
const wrapper = mount(CustomButton)
await wrapper.trigger('dblclick')
expect(wrapper.emitted()).toHaveProperty('doubleClick')
expect(wrapper.emitted('doubleClick')[0]).toEqual(['Button double clicked'])
})
it('should render slot content', () => {
const wrapper = mount(CustomButton, {
slots: {
default: 'Custom Text'
}
})
expect(wrapper.text()).toBe('Custom Text')
})
})测试异步操作
vue
<!-- components/UserProfile.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const user = ref(null)
const loading = ref(true)
const error = ref(null)
async function fetchUser() {
try {
const response = await fetch('/api/user')
user.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchUser()
})
</script>
<template>
<div>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
</template>javascript
// components/UserProfile.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
describe('UserProfile Component', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
it('should show loading state initially', () => {
const wrapper = mount(UserProfile)
expect(wrapper.text()).toContain('Loading...')
})
it('should display user data after loading', async () => {
global.fetch.mockResolvedValue({
json: async () => ({
name: 'John Doe',
email: 'john@example.com'
})
})
const wrapper = mount(UserProfile)
await flushPromises()
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('john@example.com')
})
it('should display error message on failure', async () => {
global.fetch.mockRejectedValue(new Error('Network error'))
const wrapper = mount(UserProfile)
await flushPromises()
expect(wrapper.text()).toContain('Error: Network error')
})
})测试 Pinia Store
javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
count.value--
}
return { count, increment, decrement }
})javascript
// stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should initialize with count 0', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
})
it('should increment count', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
})
it('should decrement count', () => {
const store = useCounterStore()
store.count = 5
store.decrement()
expect(store.count).toBe(4)
})
})测试最佳实践
1. 测试用户行为,而非实现细节
javascript
// ❌ 测试实现细节
it('should call increment method', () => {
const wrapper = mount(Counter)
const spy = vi.spyOn(wrapper.vm, 'increment')
wrapper.find('button').trigger('click')
expect(spy).toHaveBeenCalled()
})
// ✅ 测试用户行为
it('should increase count when button is clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})2. 使用数据测试 ID
vue
<template>
<button data-testid="increment-btn" @click="increment">
Increment
</button>
</template>javascript
it('should increment when button is clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment-btn"]').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})3. 模拟依赖
javascript
// Mock API 调用
vi.mock('./api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ name: 'Test User' }))
}))
// Mock 路由
const mockRouter = {
push: vi.fn()
}
const wrapper = mount(Component, {
global: {
mocks: {
$router: mockRouter
}
}
})测试覆盖率
json
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}bash
npm run test:coverage端到端测试(E2E)
使用 Playwright 或 Cypress 进行 E2E 测试:
javascript
// e2e/login.spec.js (Playwright)
import { test, expect } from '@playwright/test'
test('user can login', async ({ page }) => {
await page.goto('http://localhost:3000')
await page.fill('[data-testid="username"]', 'testuser')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="login-btn"]')
await expect(page.locator('text=Welcome')).toBeVisible()
})总结
- 使用 Vitest 进行单元测试和组件测试
- 测试用户行为,而非实现细节
- 使用
@vue/test-utils测试 Vue 组件 - Mock 外部依赖和 API 调用
- 追求合理的测试覆盖率
- 使用 E2E 测试验证完整流程
下一步
接下来学习 服务端渲染(SSR),了解如何实现服务端渲染。