Appearance
测试策略
测试是确保 Vue.js 应用质量和稳定性的重要手段。本章将介绍 Vue 应用的测试策略和最佳实践。
测试金字塔
一个良好的测试策略应该遵循测试金字塔原则:
/\
/ \ E2E 测试 (少量)
/____\
/ \ 集成测试 (适量)
/________\ 单元测试 (大量)- 单元测试:测试独立的函数、组件
- 集成测试:测试组件间的交互
- E2E 测试:测试完整的用户流程
单元测试
测试环境搭建
使用 Vitest 作为测试运行器:
bash
npm install -D vitest @vue/test-utils jsdom配置 vite.config.js:
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})组件测试基础
javascript
// tests/components/Button.test.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from '@/components/Button.vue'
describe('Button', () => {
it('renders properly', () => {
const wrapper = mount(Button, {
props: { text: 'Click me' }
})
expect(wrapper.text()).toContain('Click me')
})
it('emits click event when clicked', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
})
})测试 Props
javascript
describe('UserCard', () => {
it('displays user information correctly', () => {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatar: '/avatar.jpg'
}
const wrapper = mount(UserCard, {
props: { user }
})
expect(wrapper.find('.user-name').text()).toBe(user.name)
expect(wrapper.find('.user-email').text()).toBe(user.email)
expect(wrapper.find('.user-avatar').attributes('src')).toBe(user.avatar)
})
it('shows default avatar when avatar prop is not provided', () => {
const user = { name: 'John Doe', email: 'john@example.com' }
const wrapper = mount(UserCard, {
props: { user }
})
expect(wrapper.find('.user-avatar').attributes('src')).toBe('/default-avatar.png')
})
})测试事件
javascript
describe('SearchInput', () => {
it('emits search event with input value', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.setValue('vue testing')
await input.trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['vue testing'])
})
it('debounces search input', async () => {
vi.useFakeTimers()
const wrapper = mount(SearchInput, {
props: { debounce: 300 }
})
const input = wrapper.find('input')
await input.setValue('test')
// 在防抖时间内不应该触发事件
expect(wrapper.emitted('search')).toBeFalsy()
// 快进时间
vi.advanceTimersByTime(300)
expect(wrapper.emitted('search')).toBeTruthy()
vi.useRealTimers()
})
})测试插槽
javascript
describe('Modal', () => {
it('renders default slot content', () => {
const wrapper = mount(Modal, {
slots: {
default: '<p>Modal content</p>'
}
})
expect(wrapper.html()).toContain('<p>Modal content</p>')
})
it('renders named slots', () => {
const wrapper = mount(Modal, {
slots: {
header: '<h2>Modal Title</h2>',
footer: '<button>Close</button>'
}
})
expect(wrapper.find('.modal-header').html()).toContain('<h2>Modal Title</h2>')
expect(wrapper.find('.modal-footer').html()).toContain('<button>Close</button>')
})
})测试计算属性
javascript
describe('ShoppingCart', () => {
it('calculates total price correctly', () => {
const items = [
{ id: 1, price: 10, quantity: 2 },
{ id: 2, price: 15, quantity: 1 }
]
const wrapper = mount(ShoppingCart, {
props: { items }
})
expect(wrapper.vm.totalPrice).toBe(35)
})
it('shows empty cart message when no items', () => {
const wrapper = mount(ShoppingCart, {
props: { items: [] }
})
expect(wrapper.find('.empty-cart').exists()).toBe(true)
expect(wrapper.vm.totalPrice).toBe(0)
})
})测试组合式 API
测试 Composables
javascript
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return {
count,
doubleCount,
increment,
decrement,
reset
}
}javascript
// tests/composables/useCounter.test.js
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { count, doubleCount } = useCounter()
expect(count.value).toBe(0)
expect(doubleCount.value).toBe(0)
})
it('initializes with custom value', () => {
const { count, doubleCount } = useCounter(5)
expect(count.value).toBe(5)
expect(doubleCount.value).toBe(10)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('resets to initial value', () => {
const { count, increment, reset } = useCounter(3)
increment()
increment()
expect(count.value).toBe(5)
reset()
expect(count.value).toBe(3)
})
})测试异步 Composables
javascript
// composables/useApi.js
import { ref } from 'vue'
export function useApi() {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetchData = async (url) => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error('Failed to fetch')
}
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
fetchData
}
}javascript
// tests/composables/useApi.test.js
import { useApi } from '@/composables/useApi'
// Mock fetch
global.fetch = vi.fn()
describe('useApi', () => {
beforeEach(() => {
fetch.mockClear()
})
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' }
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
})
const { data, loading, error, fetchData } = useApi()
expect(loading.value).toBe(false)
const promise = fetchData('/api/test')
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
expect(data.value).toEqual(mockData)
expect(error.value).toBe(null)
})
it('handles fetch error', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'))
const { data, loading, error, fetchData } = useApi()
await fetchData('/api/test')
expect(loading.value).toBe(false)
expect(data.value).toBe(null)
expect(error.value).toBe('Network error')
})
})模拟和存根
模拟外部依赖
javascript
// tests/components/WeatherWidget.test.js
import { mount } from '@vue/test-utils'
import WeatherWidget from '@/components/WeatherWidget.vue'
// 模拟 API 服务
vi.mock('@/services/weatherApi', () => ({
getWeather: vi.fn(() => Promise.resolve({
temperature: 25,
condition: 'sunny'
}))
}))
describe('WeatherWidget', () => {
it('displays weather data', async () => {
const wrapper = mount(WeatherWidget, {
props: { city: 'Beijing' }
})
// 等待异步操作完成
await wrapper.vm.$nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
expect(wrapper.find('.temperature').text()).toBe('25°C')
expect(wrapper.find('.condition').text()).toBe('sunny')
})
})模拟全局属性
javascript
describe('UserProfile', () => {
it('uses global properties', () => {
const wrapper = mount(UserProfile, {
global: {
mocks: {
$t: (key) => key, // 模拟国际化函数
$router: {
push: vi.fn()
}
}
}
})
// 测试组件行为
})
})集成测试
测试组件交互
javascript
describe('TodoApp Integration', () => {
it('adds and removes todos', async () => {
const wrapper = mount(TodoApp)
// 添加待办事项
const input = wrapper.find('[data-test="todo-input"]')
const addButton = wrapper.find('[data-test="add-button"]')
await input.setValue('Learn Vue testing')
await addButton.trigger('click')
// 验证待办事项已添加
expect(wrapper.find('[data-test="todo-item"]').text()).toContain('Learn Vue testing')
// 删除待办事项
const deleteButton = wrapper.find('[data-test="delete-button"]')
await deleteButton.trigger('click')
// 验证待办事项已删除
expect(wrapper.find('[data-test="todo-item"]').exists()).toBe(false)
})
})测试路由
javascript
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import App from '@/App.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
})
describe('Router Integration', () => {
it('navigates to about page', async () => {
const wrapper = mount(App, {
global: {
plugins: [router]
}
})
await router.push('/about')
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="about-page"]').exists()).toBe(true)
})
})E2E 测试
使用 Playwright
bash
npm install -D @playwright/testjavascript
// tests/e2e/user-flow.spec.js
import { test, expect } from '@playwright/test'
test('user can complete signup flow', async ({ page }) => {
await page.goto('/')
// 点击注册按钮
await page.click('[data-test="signup-button"]')
// 填写表单
await page.fill('[data-test="email-input"]', 'test@example.com')
await page.fill('[data-test="password-input"]', 'password123')
await page.fill('[data-test="confirm-password-input"]', 'password123')
// 提交表单
await page.click('[data-test="submit-button"]')
// 验证成功页面
await expect(page.locator('[data-test="success-message"]')).toBeVisible()
})
test('user can search for products', async ({ page }) => {
await page.goto('/products')
// 搜索产品
await page.fill('[data-test="search-input"]', 'laptop')
await page.press('[data-test="search-input"]', 'Enter')
// 验证搜索结果
await expect(page.locator('[data-test="product-item"]')).toHaveCount(5)
await expect(page.locator('[data-test="product-title"]').first()).toContainText('laptop')
})使用 Cypress
javascript
// cypress/e2e/shopping-cart.cy.js
describe('Shopping Cart', () => {
beforeEach(() => {
cy.visit('/products')
})
it('adds items to cart', () => {
// 添加商品到购物车
cy.get('[data-cy="product-item"]').first().within(() => {
cy.get('[data-cy="add-to-cart"]').click()
})
// 验证购物车数量
cy.get('[data-cy="cart-count"]').should('contain', '1')
// 查看购物车
cy.get('[data-cy="cart-icon"]').click()
cy.get('[data-cy="cart-item"]').should('have.length', 1)
})
it('removes items from cart', () => {
// 先添加商品
cy.get('[data-cy="product-item"]').first().within(() => {
cy.get('[data-cy="add-to-cart"]').click()
})
// 打开购物车并删除商品
cy.get('[data-cy="cart-icon"]').click()
cy.get('[data-cy="remove-item"]').click()
// 验证购物车为空
cy.get('[data-cy="cart-empty"]').should('be.visible')
})
})测试最佳实践
1. 使用数据测试属性
vue
<template>
<div>
<button data-test="submit-button" @click="submit">
Submit
</button>
<input data-test="email-input" v-model="email" />
</div>
</template>2. 测试用户行为而非实现细节
javascript
// 不好 - 测试实现细节
it('calls handleClick method', () => {
const handleClick = vi.spyOn(wrapper.vm, 'handleClick')
wrapper.find('button').trigger('click')
expect(handleClick).toHaveBeenCalled()
})
// 好 - 测试用户行为
it('shows success message when form is submitted', async () => {
await wrapper.find('button').trigger('click')
expect(wrapper.find('.success-message').exists()).toBe(true)
})3. 保持测试独立
javascript
describe('UserList', () => {
let wrapper
beforeEach(() => {
// 每个测试都有独立的组件实例
wrapper = mount(UserList, {
props: {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
}
})
})
afterEach(() => {
wrapper.unmount()
})
})4. 使用工厂函数
javascript
function createWrapper(props = {}) {
return mount(UserCard, {
props: {
user: { id: 1, name: 'John Doe' },
...props
}
})
}
describe('UserCard', () => {
it('displays user name', () => {
const wrapper = createWrapper()
expect(wrapper.find('.user-name').text()).toBe('John Doe')
})
it('shows admin badge for admin users', () => {
const wrapper = createWrapper({
user: { id: 1, name: 'Admin', role: 'admin' }
})
expect(wrapper.find('.admin-badge').exists()).toBe(true)
})
})测试覆盖率
配置测试覆盖率报告:
javascript
// vite.config.js
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts'
]
}
}
})运行覆盖率测试:
bash
npm run test -- --coverage持续集成
GitHub Actions 配置
yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3