Skip to content

测试策略

测试是确保 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/test
javascript
// 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

下一步

vue study guide - 专业的 Vue.js 学习平台