Vue Test Utils

什么是 Vue Test Utils?

Vue Test Utils 是 Vue.js 官方的测试工具库,用于测试 Vue 组件。它提供了一系列 API 来挂载组件、模拟用户交互、断言组件行为等,让开发者可以轻松编写组件测试。

1. 安装 Vue Test Utils

使用 npm 或 yarn 安装 Vue Test Utils 和测试框架(如 Jest)。

示例:安装 Vue Test Utils

# 使用 npm
npm install --save-dev @vue/test-utils@next jest @vue/vue3-jest babel-jest

# 使用 yarn
yarn add --dev @vue/test-utils@next jest @vue/vue3-jest babel-jest

2. 基本配置

创建 Jest 配置文件,配置 Vue 组件的测试环境。

示例:Jest 配置

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.vue$': '@vue/vue3-jest',
    '^.+\\.js$': 'babel-jest'
  },
  moduleFileExtensions: ['vue', 'js', 'json', 'jsx', 'ts', 'tsx', 'node']
}

3. 基本测试结构

编写一个基本的组件测试。

示例:基本测试结构

// 组件:HelloWorld.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script setup>
const props = defineProps({
  msg: {
    type: String,
    default: 'Hello World'
  }
})
</script>

// 测试文件:HelloWorld.spec.js
import { mount } from '@vue/test-utils'
import HelloWorld from './HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'New Message'
    const wrapper = mount(HelloWorld, {
      props: {
        msg
      }
    })
    
    // 断言组件渲染了正确的内容
    expect(wrapper.text()).toMatch(msg)
  })
  
  it('renders default message when no props passed', () => {
    const wrapper = mount(HelloWorld)
    
    // 断言组件渲染了默认内容
    expect(wrapper.text()).toMatch('Hello World')
  })
})

4. 挂载组件

使用 mount() 函数挂载组件,创建一个包含组件实例的 wrapper 对象。

示例:挂载组件

import { mount } from '@vue/test-utils'
import MyComponent from './MyComponent.vue'

// 基本挂载
const wrapper = mount(MyComponent)

// 传递 props
const wrapper = mount(MyComponent, {
  props: {
    title: 'Test Title',
    count: 10
  }
})

// 传递 slots
const wrapper = mount(MyComponent, {
  slots: {
    default: 'Default Slot Content',
    header: '<h2>Header Slot</h2>',
    footer: '<p>Footer Slot</p>'
  }
})

// 传递 provide
const wrapper = mount(MyComponent, {
  global: {
    provide: {
      'my-inject-key': 'injected value'
    }
  }
})

// 模拟全局属性
const wrapper = mount(MyComponent, {
  global: {
    mocks: {
      $t: (key) => key, // 模拟 i18n
      $router: { push: jest.fn() } // 模拟 vue-router
    }
  }
})

5. 查找元素

使用 wrapper 对象的方法查找组件内的元素。

示例:查找元素

// 按选择器查找
const button = wrapper.find('button')
const input = wrapper.find('.my-input')
const listItem = wrapper.find('li:nth-child(2)')

// 按组件名查找
const childComponent = wrapper.findComponent(ChildComponent)

// 查找所有匹配的元素
const buttons = wrapper.findAll('button')
const listItems = wrapper.findAll('li')

// 按文本内容查找
const link = wrapper.find('a', { text: 'Learn More' })

// 使用数据属性查找
const submitButton = wrapper.find('[data-testid="submit-button"]')

6. 模拟用户交互

使用 wrapper 对象的方法模拟用户交互,如点击、输入、提交等。

示例:模拟用户交互

// 点击元素
const button = wrapper.find('button')
await button.trigger('click')

// 输入文本
const input = wrapper.find('input')
await input.setValue('Test Input')

// 触发键盘事件
await input.trigger('keydown.enter')

// 触发表单提交
const form = wrapper.find('form')
await form.trigger('submit.prevent')

// 触发自定义事件
const customElement = wrapper.find('.custom-element')
await customElement.trigger('custom-event', { detail: { value: 123 } })

7. 断言组件行为

使用断言库(如 Jest)断言组件的行为是否符合预期。

示例:断言组件行为

// 断言文本内容
expect(wrapper.text()).toContain('Hello World')

// 断言元素存在
expect(wrapper.find('button').exists()).toBe(true)

// 断言元素不存在
expect(wrapper.find('.non-existent').exists()).toBe(false)

// 断言元素可见
expect(wrapper.find('.visible-element').isVisible()).toBe(true)

// 断言元素属性
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('text')
expect(input.attributes('placeholder')).toBe('Enter text')

// 断言元素类名
const button = wrapper.find('button')
expect(button.classes()).toContain('primary')

// 断言元素样式
const element = wrapper.find('.styled-element')
expect(element.style('color')).toBe('red')

// 断言表单值
const input = wrapper.find('input')
await input.setValue('Test')
expect(input.element.value).toBe('Test')

// 断言组件 props
const childComponent = wrapper.findComponent(ChildComponent)
expect(childComponent.props('title')).toBe('Test Title')

// 断言事件触发
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)

expect(wrapper.emitted('update:count')).toEqual([[1], [2], [3]])

8. 测试组合式 API 组件

测试使用组合式 API 编写的组件。

示例:测试组合式 API 组件

// 组件:Counter.vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

// 暴露给测试的属性
defineExpose({
  count,
  increment
})
</script>

// 测试文件:Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('increments count when button is clicked', async () => {
    const wrapper = mount(Counter)
    const button = wrapper.find('button')
    
    // 初始值应为 0
    expect(wrapper.text()).toMatch('Count: 0')
    
    // 点击按钮
    await button.trigger('click')
    
    // 值应变为 1
    expect(wrapper.text()).toMatch('Count: 1')
    
    // 再次点击按钮
    await button.trigger('click')
    
    // 值应变为 2
    expect(wrapper.text()).toMatch('Count: 2')
  })
  
  it('can access exposed properties', () => {
    const wrapper = mount(Counter)
    
    // 访问暴露的属性
    expect(wrapper.vm.count).toBe(0)
    
    // 调用暴露的方法
    wrapper.vm.increment()
    expect(wrapper.vm.count).toBe(1)
  })
})

9. 测试异步组件

测试包含异步操作的组件,如 API 请求、定时器等。

示例:测试异步组件

// 组件:AsyncComponent.vue
<template>
  <div>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else>Data: {{ data }}</div>
    <button @click="fetchData">Fetch Data</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const loading = ref(false)
const error = ref(null)
const data = ref(null)

async function fetchData() {
  loading.value = true
  error.value = null
  
  try {
    // 模拟 API 请求
    const response = await fetch('https://api.example.com/data')
    const result = await response.json()
    data.value = result
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}
</script>

// 测试文件:AsyncComponent.spec.js
import { mount } from '@vue/test-utils'
import AsyncComponent from './AsyncComponent.vue'

describe('AsyncComponent.vue', () => {
  it('fetches data when button is clicked', async () => {
    // 模拟 fetch API
    global.fetch = jest.fn(() =>
      Promise.resolve({
        json: () => Promise.resolve('Test Data')
      })
    )
    
    const wrapper = mount(AsyncComponent)
    const button = wrapper.find('button')
    
    // 初始状态
    expect(wrapper.text()).toContain('Fetch Data')
    expect(wrapper.find('.loading').exists()).toBe(false)
    
    // 点击按钮
    await button.trigger('click')
    
    // 加载状态
    expect(wrapper.text()).toContain('Loading...')
    
    // 等待异步操作完成
    await wrapper.vm.$nextTick()
    
    // 数据加载完成
    expect(wrapper.text()).toContain('Data: Test Data')
    expect(global.fetch).toHaveBeenCalledTimes(1)
  })
  
  it('handles error when fetch fails', async () => {
    // 模拟 fetch API 失败
    global.fetch = jest.fn(() =>
      Promise.reject(new Error('Network Error'))
    )
    
    const wrapper = mount(AsyncComponent)
    const button = wrapper.find('button')
    
    // 点击按钮
    await button.trigger('click')
    
    // 等待异步操作完成
    await wrapper.vm.$nextTick()
    
    // 错误状态
    expect(wrapper.text()).toContain('Error: Network Error')
  })
})

10. 测试路由组件

测试使用 Vue Router 的组件。

示例:测试路由组件

import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import { createApp } from 'vue'
import RouteComponent from './RouteComponent.vue'

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: RouteComponent },
    { path: '/about', component: () => import('./AboutComponent.vue') }
  ]
})

// 创建应用实例
const app = createApp({})
app.use(router)

// 测试路由组件
describe('RouteComponent.vue', () => {
  it('renders route links', () => {
    const wrapper = mount(RouteComponent, {
      global: {
        plugins: [router]
      }
    })
    
    // 断言路由链接存在
    expect(wrapper.find('router-link[to="/"]').exists()).toBe(true)
    expect(wrapper.find('router-link[to="/about"]').exists()).toBe(true)
  })
  
  it('renders router-view', async () => {
    const wrapper = mount({
      template: '<router-view />',
    }, {
      global: {
        plugins: [router]
      }
    })
    
    // 导航到首页
    await router.push('/')
    await router.isReady()
    
    // 断言首页内容渲染
    expect(wrapper.text()).toContain('Home Page')
    
    // 导航到关于页
    await router.push('/about')
    await router.isReady()
    
    // 断言关于页内容渲染
    expect(wrapper.text()).toContain('About Page')
  })
})

11. Vue Test Utils 最佳实践

  • 使用 data-testid 属性标识测试元素,避免依赖 CSS 类名或结构
  • 测试组件的公共 API,而不是内部实现细节
  • 使用 defineExpose 暴露需要测试的内部属性和方法
  • 模拟外部依赖(如 API 请求、路由、状态管理)
  • 编写清晰、简洁的测试用例,每个测试只关注一个功能点
  • 使用异步测试处理组件中的异步操作
  • 测试组件的各种状态(初始状态、加载状态、错误状态、成功状态)
  • 使用快照测试验证组件的渲染结果