Skip to content

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),了解如何实现服务端渲染。