活动公告

系统通知
05-18 21:22
系统通知
通知:本站资源由网友上传分享,如有违规等问题请到版务模块进行投诉,资源失效请在帖子内回复要求补档,会尽快处理!
10-23 09:31

Vue3单元测试实践案例详解提升代码质量与开发效率通过真实项目案例学习测试驱动开发掌握Vue3测试核心技能与最佳实践

SunJu_FaceMall

3万

主题

2860

科技点

3万

积分

白金月票

碾压王

积分
32872

塔罗立华奏

<font color=白金月票" /> 发表于 2025-9-11 18:40:01 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

x
引言

在现代前端开发中,单元测试已经成为保证代码质量、提高开发效率的重要手段。随着Vue3的发布,其组合式API(Composition API)为组件逻辑复用和代码组织带来了全新的可能性,同时也对单元测试提出了新的要求。本文将通过真实项目案例,详细介绍Vue3单元测试的实践方法,探讨测试驱动开发(TDD)在Vue3项目中的应用,帮助开发者掌握Vue3测试的核心技能与最佳实践,从而提升代码质量与开发效率。

Vue3单元测试基础

什么是单元测试

单元测试是对软件中最小可测试单元进行检查和验证的过程。在Vue3应用中,单元测试主要针对组件、工具函数、模块等进行独立测试,确保它们在各种输入条件下都能产生预期的输出。

Vue3单元测试的重要性

• 提高代码质量:通过测试发现潜在问题,减少生产环境中的bug
• 促进重构:有测试保障的情况下,开发者可以更自信地重构代码
• 文档作用:测试用例可以作为代码使用的示例和文档
• 设计改进:编写测试的过程会促使开发者思考代码设计,提高代码可测试性

Vue3单元测试工具栈

在Vue3生态中,常用的单元测试工具包括:

1. Vitest:专为Vite设计的测试框架,与Vue3完美兼容
2. Vue Test Utils:官方提供的Vue组件测试工具库
3. Jest:流行的JavaScript测试框架,也可用于Vue3测试
4. Cypress:端到端测试工具,也可用于组件测试
5. Testing Library:提供更贴近用户行为的测试方法

环境搭建

创建Vue3测试项目

首先,我们需要创建一个带有测试配置的Vue3项目。可以使用Vite来快速搭建:
  1. # 创建Vue3项目
  2. npm create vite@latest vue3-testing-demo -- --template vue
  3. cd vue3-testing-demo
  4. # 安装依赖
  5. npm install
  6. # 安装测试相关依赖
  7. npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8
复制代码

配置Vitest

在项目根目录创建vitest.config.ts文件:
  1. import { defineConfig } from 'vitest/config'
  2. import vue from '@vitejs/plugin-vue'
  3. export default defineConfig({
  4.   plugins: [vue()],
  5.   test: {
  6.     // 启用类似全局的Vue Test Utils API
  7.     globals: true,
  8.     // 模拟DOM环境
  9.     environment: 'jsdom',
  10.     // 支持Vue组件测试
  11.     include: ['src/**/*.test.{js,ts,jsx,tsx}'],
  12.     // 覆盖率配置
  13.     coverage: {
  14.       provider: 'v8',
  15.       reporter: ['text', 'json', 'html'],
  16.       exclude: [
  17.         'node_modules/',
  18.         'src/main.js',
  19.         '**/*.d.ts',
  20.       ],
  21.     },
  22.   },
  23. })
复制代码

配置package.json

在package.json中添加测试脚本:
  1. {
  2.   "scripts": {
  3.     "dev": "vite",
  4.     "build": "vite build",
  5.     "preview": "vite preview",
  6.     "test": "vitest",
  7.     "test:run": "vitest run",
  8.     "test:coverage": "vitest run --coverage"
  9.   }
  10. }
复制代码

Vue3组件测试基础

编写第一个组件测试

让我们创建一个简单的计数器组件并为其编写测试。

Counter.vue 组件:
  1. <template>
  2.   <div class="counter">
  3.     <h2>Counter: {{ count }}</h2>
  4.     <button @click="increment">Increment</button>
  5.     <button @click="decrement">Decrement</button>
  6.     <button @click="reset">Reset</button>
  7.   </div>
  8. </template>
  9. <script setup>
  10. import { ref } from 'vue'
  11. const count = ref(0)
  12. const increment = () => {
  13.   count.value++
  14. }
  15. const decrement = () => {
  16.   count.value--
  17. }
  18. const reset = () => {
  19.   count.value = 0
  20. }
  21. </script>
复制代码

Counter.test.js 测试文件:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import Counter from '../components/Counter.vue'
  4. describe('Counter.vue', () => {
  5.   it('renders initial count value', () => {
  6.     const wrapper = mount(Counter)
  7.     expect(wrapper.text()).toContain('Counter: 0')
  8.   })
  9.   it('increments count when increment button is clicked', async () => {
  10.     const wrapper = mount(Counter)
  11.     const incrementButton = wrapper.find('button:nth-child(2)')
  12.    
  13.     await incrementButton.trigger('click')
  14.    
  15.     expect(wrapper.text()).toContain('Counter: 1')
  16.   })
  17.   it('decrements count when decrement button is clicked', async () => {
  18.     const wrapper = mount(Counter)
  19.     const decrementButton = wrapper.find('button:nth-child(3)')
  20.    
  21.     await decrementButton.trigger('click')
  22.    
  23.     expect(wrapper.text()).toContain('Counter: -1')
  24.   })
  25.   it('resets count when reset button is clicked', async () => {
  26.     const wrapper = mount(Counter)
  27.     const incrementButton = wrapper.find('button:nth-child(2)')
  28.     const resetButton = wrapper.find('button:nth-child(4)')
  29.    
  30.     // 先增加计数
  31.     await incrementButton.trigger('click')
  32.     expect(wrapper.text()).toContain('Counter: 1')
  33.    
  34.     // 然后重置
  35.     await resetButton.trigger('click')
  36.     expect(wrapper.text()).toContain('Counter: 0')
  37.   })
  38. })
复制代码

测试组件Props

让我们创建一个接收props的组件并测试它。

Greeting.vue 组件:
  1. <template>
  2.   <div>
  3.     <h1>Hello, {{ name }}!</h1>
  4.     <p v-if="showMessage">{{ message }}</p>
  5.   </div>
  6. </template>
  7. <script setup>
  8. defineProps({
  9.   name: {
  10.     type: String,
  11.     required: true
  12.   },
  13.   message: {
  14.     type: String,
  15.     default: 'Welcome to our app'
  16.   },
  17.   showMessage: {
  18.     type: Boolean,
  19.     default: true
  20.   }
  21. })
  22. </script>
复制代码

Greeting.test.js 测试文件:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import Greeting from '../components/Greeting.vue'
  4. describe('Greeting.vue', () => {
  5.   it('renders name prop correctly', () => {
  6.     const name = 'John Doe'
  7.     const wrapper = mount(Greeting, {
  8.       props: { name }
  9.     })
  10.    
  11.     expect(wrapper.text()).toContain(`Hello, ${name}!`)
  12.   })
  13.   it('renders default message when showMessage is true', () => {
  14.     const wrapper = mount(Greeting, {
  15.       props: { name: 'John' }
  16.     })
  17.    
  18.     expect(wrapper.text()).toContain('Welcome to our app')
  19.   })
  20.   it('does not render message when showMessage is false', () => {
  21.     const wrapper = mount(Greeting, {
  22.       props: {
  23.         name: 'John',
  24.         showMessage: false
  25.       }
  26.     })
  27.    
  28.     expect(wrapper.find('p').exists()).toBe(false)
  29.   })
  30.   it('renders custom message when provided', () => {
  31.     const customMessage = 'This is a custom message'
  32.     const wrapper = mount(Greeting, {
  33.       props: {
  34.         name: 'John',
  35.         message: customMessage
  36.       }
  37.     })
  38.    
  39.     expect(wrapper.text()).toContain(customMessage)
  40.   })
  41. })
复制代码

测试组件事件

创建一个发出事件的组件并测试它。

LoginForm.vue 组件:
  1. <template>
  2.   <form @submit.prevent="handleSubmit">
  3.     <div>
  4.       <label for="username">Username:</label>
  5.       <input
  6.         id="username"
  7.         v-model="username"
  8.         type="text"
  9.         required
  10.       />
  11.     </div>
  12.     <div>
  13.       <label for="password">Password:</label>
  14.       <input
  15.         id="password"
  16.         v-model="password"
  17.         type="password"
  18.         required
  19.       />
  20.     </div>
  21.     <button type="submit">Login</button>
  22.   </form>
  23. </template>
  24. <script setup>
  25. import { ref } from 'vue'
  26. const username = ref('')
  27. const password = ref('')
  28. const emit = defineEmits(['login'])
  29. const handleSubmit = () => {
  30.   emit('login', {
  31.     username: username.value,
  32.     password: password.value
  33.   })
  34. }
  35. </script>
复制代码

LoginForm.test.js 测试文件:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import LoginForm from '../components/LoginForm.vue'
  4. describe('LoginForm.vue', () => {
  5.   it('emits login event with username and password when form is submitted', async () => {
  6.     const wrapper = mount(LoginForm)
  7.    
  8.     // 设置表单数据
  9.     const usernameInput = wrapper.find('#username')
  10.     const passwordInput = wrapper.find('#password')
  11.    
  12.     await usernameInput.setValue('testuser')
  13.     await passwordInput.setValue('password123')
  14.    
  15.     // 提交表单
  16.     await wrapper.find('form').trigger('submit')
  17.    
  18.     // 验证事件是否被触发
  19.     expect(wrapper.emitted()).toHaveProperty('login')
  20.    
  21.     // 验证事件参数
  22.     const loginEvent = wrapper.emitted('login')
  23.     expect(loginEvent).toHaveLength(1)
  24.     expect(loginEvent[0]).toEqual([
  25.       {
  26.         username: 'testuser',
  27.         password: 'password123'
  28.       }
  29.     ])
  30.   })
  31.   it('does not emit login event when form is not valid', async () => {
  32.     const wrapper = mount(LoginForm)
  33.    
  34.     // 不设置必填字段,直接提交表单
  35.     await wrapper.find('form').trigger('submit')
  36.    
  37.     // 验证事件未被触发
  38.     expect(wrapper.emitted()).not.toHaveProperty('login')
  39.   })
  40. })
复制代码

测试组合式函数(Composables)

在Vue3中,组合式函数(Composables)是复用逻辑的重要方式。测试组合式函数对于保证逻辑正确性至关重要。

创建一个可测试的组合式函数

useCounter.js:
  1. import { ref, computed } from 'vue'
  2. export function useCounter(initialValue = 0) {
  3.   const count = ref(initialValue)
  4.   
  5.   const double = computed(() => count.value * 2)
  6.   
  7.   const increment = () => {
  8.     count.value++
  9.   }
  10.   
  11.   const decrement = () => {
  12.     count.value--
  13.   }
  14.   
  15.   const reset = () => {
  16.     count.value = initialValue
  17.   }
  18.   
  19.   return {
  20.     count,
  21.     double,
  22.     increment,
  23.     decrement,
  24.     reset
  25.   }
  26. }
复制代码

测试组合式函数

useCounter.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { useCounter } from '../composables/useCounter'
  3. describe('useCounter', () => {
  4.   it('starts with initial value', () => {
  5.     const { count } = useCounter(5)
  6.     expect(count.value).toBe(5)
  7.   })
  8.   it('defaults to 0 if no initial value is provided', () => {
  9.     const { count } = useCounter()
  10.     expect(count.value).toBe(0)
  11.   })
  12.   it('increments the count', () => {
  13.     const { count, increment } = useCounter()
  14.     increment()
  15.     expect(count.value).toBe(1)
  16.   })
  17.   it('decrements the count', () => {
  18.     const { count, decrement } = useCounter()
  19.     decrement()
  20.     expect(count.value).toBe(-1)
  21.   })
  22.   it('resets to initial value', () => {
  23.     const { count, increment, reset } = useCounter(10)
  24.     increment()
  25.     increment()
  26.     expect(count.value).toBe(12)
  27.     reset()
  28.     expect(count.value).toBe(10)
  29.   })
  30.   it('computes double value correctly', () => {
  31.     const { count, double } = useCounter(3)
  32.     expect(double.value).toBe(6)
  33.    
  34.     count.value = 5
  35.     expect(double.value).toBe(10)
  36.   })
  37. })
复制代码

测试异步组合式函数

useFetch.js:
  1. import { ref, onMounted } from 'vue'
  2. export function useFetch(url) {
  3.   const data = ref(null)
  4.   const error = ref(null)
  5.   const loading = ref(false)
  6.   const fetchData = async () => {
  7.     loading.value = true
  8.     error.value = null
  9.    
  10.     try {
  11.       const response = await fetch(url)
  12.       if (!response.ok) {
  13.         throw new Error(`HTTP error! status: ${response.status}`)
  14.       }
  15.       data.value = await response.json()
  16.     } catch (err) {
  17.       error.value = err.message
  18.     } finally {
  19.       loading.value = false
  20.     }
  21.   }
  22.   onMounted(() => {
  23.     fetchData()
  24.   })
  25.   return {
  26.     data,
  27.     error,
  28.     loading,
  29.     refetch: fetchData
  30.   }
  31. }
复制代码

useFetch.test.js:
  1. import { describe, it, expect, vi, beforeEach } from 'vitest'
  2. import { useFetch } from '../composables/useFetch'
  3. // 模拟全局fetch函数
  4. global.fetch = vi.fn()
  5. describe('useFetch', () => {
  6.   beforeEach(() => {
  7.     // 在每个测试前重置模拟
  8.     fetch.mockClear()
  9.   })
  10.   it('starts with default values', () => {
  11.     const { data, error, loading } = useFetch('https://api.example.com/data')
  12.    
  13.     expect(data.value).toBeNull()
  14.     expect(error.value).toBeNull()
  15.     expect(loading.value).toBe(false)
  16.   })
  17.   it('fetches data successfully', async () => {
  18.     const mockData = { id: 1, name: 'Test Data' }
  19.     fetch.mockResolvedValueOnce({
  20.       ok: true,
  21.       json: async () => mockData
  22.     })
  23.     const { data, error, loading } = useFetch('https://api.example.com/data')
  24.    
  25.     // 由于onMounted是异步的,我们需要等待下一个tick
  26.     await new Promise(resolve => setTimeout(resolve, 0))
  27.    
  28.     expect(loading.value).toBe(false)
  29.     expect(error.value).toBeNull()
  30.     expect(data.value).toEqual(mockData)
  31.   })
  32.   it('handles fetch error', async () => {
  33.     const errorMessage = 'Network error'
  34.     fetch.mockRejectedValueOnce(new Error(errorMessage))
  35.     const { data, error, loading } = useFetch('https://api.example.com/data')
  36.    
  37.     await new Promise(resolve => setTimeout(resolve, 0))
  38.    
  39.     expect(loading.value).toBe(false)
  40.     expect(error.value).toBe(errorMessage)
  41.     expect(data.value).toBeNull()
  42.   })
  43.   it('handles HTTP error status', async () => {
  44.     fetch.mockResolvedValueOnce({
  45.       ok: false,
  46.       status: 404,
  47.       statusText: 'Not Found'
  48.     })
  49.     const { data, error, loading } = useFetch('https://api.example.com/data')
  50.    
  51.     await new Promise(resolve => setTimeout(resolve, 0))
  52.    
  53.     expect(loading.value).toBe(false)
  54.     expect(error.value).toBe('HTTP error! status: 404')
  55.     expect(data.value).toBeNull()
  56.   })
  57.   it('can refetch data', async () => {
  58.     const mockData1 = { id: 1, name: 'First Data' }
  59.     const mockData2 = { id: 2, name: 'Second Data' }
  60.    
  61.     fetch.mockResolvedValueOnce({
  62.       ok: true,
  63.       json: async () => mockData1
  64.     })
  65.     const { data, refetch } = useFetch('https://api.example.com/data')
  66.    
  67.     await new Promise(resolve => setTimeout(resolve, 0))
  68.     expect(data.value).toEqual(mockData1)
  69.    
  70.     // 准备第二次调用的模拟响应
  71.     fetch.mockResolvedValueOnce({
  72.       ok: true,
  73.       json: async () => mockData2
  74.     })
  75.    
  76.     // 手动调用refetch
  77.     await refetch()
  78.    
  79.     expect(data.value).toEqual(mockData2)
  80.   })
  81. })
复制代码

测试驱动开发(TDD)在Vue3中的应用

什么是测试驱动开发

测试驱动开发(Test-Driven Development,TDD)是一种软件开发方法,其核心思想是在编写功能代码之前先编写测试代码。TDD遵循”红-绿-重构”的循环:

1. 红:编写一个失败的测试
2. 绿:编写最简单的代码使测试通过
3. 重构:优化代码,同时保持测试通过

TDD实践案例:待办事项应用

让我们通过一个简单的待办事项应用来实践TDD。

首先,我们创建一个Todo列表组件的测试文件,定义我们期望的行为。

TodoList.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import TodoList from '../components/TodoList.vue'
  4. describe('TodoList.vue', () => {
  5.   it('renders an empty list when no todos are provided', () => {
  6.     const wrapper = mount(TodoList, {
  7.       props: {
  8.         todos: []
  9.       }
  10.     })
  11.    
  12.     expect(wrapper.findAll('.todo-item')).toHaveLength(0)
  13.   })
  14.   it('renders a list of todos', () => {
  15.     const todos = [
  16.       { id: 1, text: 'Learn Vue 3', completed: false },
  17.       { id: 2, text: 'Write tests', completed: true }
  18.     ]
  19.    
  20.     const wrapper = mount(TodoList, {
  21.       props: { todos }
  22.     })
  23.    
  24.     const todoItems = wrapper.findAll('.todo-item')
  25.     expect(todoItems).toHaveLength(2)
  26.     expect(todoItems[0].text()).toContain('Learn Vue 3')
  27.     expect(todoItems[1].text()).toContain('Write tests')
  28.   })
  29.   it('marks completed todos with a specific class', () => {
  30.     const todos = [
  31.       { id: 1, text: 'Learn Vue 3', completed: false },
  32.       { id: 2, text: 'Write tests', completed: true }
  33.     ]
  34.    
  35.     const wrapper = mount(TodoList, {
  36.       props: { todos }
  37.     })
  38.    
  39.     const todoItems = wrapper.findAll('.todo-item')
  40.     expect(todoItems[0].classes()).not.toContain('completed')
  41.     expect(todoItems[1].classes()).toContain('completed')
  42.   })
  43.   it('emits toggle event when a todo is clicked', async () => {
  44.     const todos = [
  45.       { id: 1, text: 'Learn Vue 3', completed: false }
  46.     ]
  47.    
  48.     const wrapper = mount(TodoList, {
  49.       props: { todos }
  50.     })
  51.    
  52.     await wrapper.find('.todo-item').trigger('click')
  53.    
  54.     expect(wrapper.emitted()).toHaveProperty('toggle')
  55.     expect(wrapper.emitted('toggle')[0]).toEqual([1])
  56.   })
  57. })
复制代码

运行测试,所有测试都会失败(红色),因为我们还没有创建TodoList组件。

现在,我们创建TodoList组件,实现最基本的功能使测试通过。

TodoList.vue:
  1. <template>
  2.   <div class="todo-list">
  3.     <div
  4.       v-for="todo in todos"
  5.       :key="todo.id"
  6.       class="todo-item"
  7.       :class="{ completed: todo.completed }"
  8.       @click="$emit('toggle', todo.id)"
  9.     >
  10.       {{ todo.text }}
  11.     </div>
  12.   </div>
  13. </template>
  14. <script setup>
  15. defineProps({
  16.   todos: {
  17.     type: Array,
  18.     required: true
  19.   }
  20. })
  21. defineEmits(['toggle'])
  22. </script>
  23. <style scoped>
  24. .todo-item {
  25.   padding: 8px;
  26.   margin: 4px 0;
  27.   background-color: #f9f9f9;
  28.   cursor: pointer;
  29. }
  30. .completed {
  31.   text-decoration: line-through;
  32.   color: #888;
  33. }
  34. </style>
复制代码

再次运行测试,所有测试都应该通过(绿色)。

现在我们的测试通过了,但我们可以重构代码以提高可读性和性能。在这个简单的例子中,重构的空间不大,但在更复杂的应用中,这一步非常重要。

TDD实践案例:添加新功能

让我们继续使用TDD方法为TodoList添加新功能:删除待办事项。

在TodoList.test.js中添加新的测试:
  1. it('emits delete event when delete button is clicked', async () => {
  2.   const todos = [
  3.     { id: 1, text: 'Learn Vue 3', completed: false }
  4.   ]
  5.   
  6.   const wrapper = mount(TodoList, {
  7.     props: { todos }
  8.   })
  9.   
  10.   await wrapper.find('.delete-button').trigger('click')
  11.   
  12.   expect(wrapper.emitted()).toHaveProperty('delete')
  13.   expect(wrapper.emitted('delete')[0]).toEqual([1])
  14. })
复制代码

运行测试,新测试会失败。

更新TodoList.vue组件:
  1. <template>
  2.   <div class="todo-list">
  3.     <div
  4.       v-for="todo in todos"
  5.       :key="todo.id"
  6.       class="todo-item"
  7.       :class="{ completed: todo.completed }"
  8.     >
  9.       <span @click="$emit('toggle', todo.id)">{{ todo.text }}</span>
  10.       <button class="delete-button" @click="$emit('delete', todo.id)">×</button>
  11.     </div>
  12.   </div>
  13. </template>
  14. <script setup>
  15. defineProps({
  16.   todos: {
  17.     type: Array,
  18.     required: true
  19.   }
  20. })
  21. defineEmits(['toggle', 'delete'])
  22. </script>
  23. <style scoped>
  24. .todo-item {
  25.   padding: 8px;
  26.   margin: 4px 0;
  27.   background-color: #f9f9f9;
  28.   cursor: pointer;
  29.   display: flex;
  30.   justify-content: space-between;
  31.   align-items: center;
  32. }
  33. .completed {
  34.   text-decoration: line-through;
  35.   color: #888;
  36. }
  37. .delete-button {
  38.   background: none;
  39.   border: none;
  40.   color: #ff0000;
  41.   cursor: pointer;
  42.   font-size: 16px;
  43.   padding: 0 4px;
  44. }
  45. </style>
复制代码

运行测试,所有测试应该通过。

我们可以优化一下模板结构,使其更清晰:
  1. <template>
  2.   <div class="todo-list">
  3.     <div
  4.       v-for="todo in todos"
  5.       :key="todo.id"
  6.       class="todo-item"
  7.       :class="{ completed: todo.completed }"
  8.     >
  9.       <span class="todo-text" @click="$emit('toggle', todo.id)">{{ todo.text }}</span>
  10.       <button class="delete-button" @click.stop="$emit('delete', todo.id)">×</button>
  11.     </div>
  12.   </div>
  13. </template>
复制代码

注意我们添加了.stop修饰符来阻止事件冒泡,这样点击删除按钮不会触发toggle事件。

Vue3高级测试技巧

测试Pinia状态管理

Pinia是Vue3的官方状态管理库。测试Pinia存储对于确保应用状态管理的正确性非常重要。

stores/counter.js:
  1. import { defineStore } from 'pinia'
  2. export const useCounterStore = defineStore('counter', {
  3.   state: () => ({
  4.     count: 0
  5.   }),
  6.   getters: {
  7.     double: (state) => state.count * 2
  8.   },
  9.   actions: {
  10.     increment() {
  11.       this.count++
  12.     },
  13.     decrement() {
  14.       this.count--
  15.     },
  16.     reset() {
  17.       this.count = 0
  18.     },
  19.     incrementBy(amount) {
  20.       this.count += amount
  21.     }
  22.   }
  23. })
复制代码

stores/counter.test.js:
  1. import { describe, it, expect, beforeEach } from 'vitest'
  2. import { setActivePinia, createPinia } from 'pinia'
  3. import { useCounterStore } from './counter'
  4. describe('Counter Store', () => {
  5.   beforeEach(() => {
  6.     // 创建一个新的pinia实例,使每个测试独立
  7.     setActivePinia(createPinia())
  8.   })
  9.   it('initializes with zero count', () => {
  10.     const counter = useCounterStore()
  11.     expect(counter.count).toBe(0)
  12.   })
  13.   it('increments count', () => {
  14.     const counter = useCounterStore()
  15.     counter.increment()
  16.     expect(counter.count).toBe(1)
  17.   })
  18.   it('decrements count', () => {
  19.     const counter = useCounterStore()
  20.     counter.decrement()
  21.     expect(counter.count).toBe(-1)
  22.   })
  23.   it('resets count', () => {
  24.     const counter = useCounterStore()
  25.     counter.increment()
  26.     counter.increment()
  27.     expect(counter.count).toBe(2)
  28.     counter.reset()
  29.     expect(counter.count).toBe(0)
  30.   })
  31.   it('increments by amount', () => {
  32.     const counter = useCounterStore()
  33.     counter.incrementBy(5)
  34.     expect(counter.count).toBe(5)
  35.   })
  36.   it('computes double correctly', () => {
  37.     const counter = useCounterStore()
  38.     counter.increment()
  39.     expect(counter.double).toBe(2)
  40.     counter.incrementBy(4)
  41.     expect(counter.double).toBe(10)
  42.   })
  43. })
复制代码

CounterDisplay.vue:
  1. <template>
  2.   <div class="counter-display">
  3.     <h2>Counter: {{ counter.count }}</h2>
  4.     <p>Double: {{ counter.double }}</p>
  5.     <button @click="counter.increment">Increment</button>
  6.     <button @click="counter.decrement">Decrement</button>
  7.     <button @click="counter.reset">Reset</button>
  8.   </div>
  9. </template>
  10. <script setup>
  11. import { useCounterStore } from '../stores/counter'
  12. const counter = useCounterStore()
  13. </script>
复制代码

CounterDisplay.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import { createPinia, setActivePinia } from 'pinia'
  4. import CounterDisplay from '../components/CounterDisplay.vue'
  5. describe('CounterDisplay.vue', () => {
  6.   beforeEach(() => {
  7.     // 创建一个新的pinia实例
  8.     setActivePinia(createPinia())
  9.   })
  10.   it('renders counter values from store', () => {
  11.     const wrapper = mount(CounterDisplay)
  12.     expect(wrapper.text()).toContain('Counter: 0')
  13.     expect(wrapper.text()).toContain('Double: 0')
  14.   })
  15.   it('calls store actions when buttons are clicked', async () => {
  16.     const wrapper = mount(CounterDisplay)
  17.    
  18.     const incrementButton = wrapper.find('button:nth-child(3)')
  19.     const decrementButton = wrapper.find('button:nth-child(4)')
  20.     const resetButton = wrapper.find('button:nth-child(5)')
  21.    
  22.     await incrementButton.trigger('click')
  23.     expect(wrapper.text()).toContain('Counter: 1')
  24.     expect(wrapper.text()).toContain('Double: 2')
  25.    
  26.     await incrementButton.trigger('click')
  27.     expect(wrapper.text()).toContain('Counter: 2')
  28.     expect(wrapper.text()).toContain('Double: 4')
  29.    
  30.     await decrementButton.trigger('click')
  31.     expect(wrapper.text()).toContain('Counter: 1')
  32.     expect(wrapper.text()).toContain('Double: 2')
  33.    
  34.     await resetButton.trigger('click')
  35.     expect(wrapper.text()).toContain('Counter: 0')
  36.     expect(wrapper.text()).toContain('Double: 0')
  37.   })
  38. })
复制代码

测试Vue Router

在Vue应用中,路由是核心功能之一。测试与路由相关的组件和功能非常重要。

UserProfile.vue:
  1. <template>
  2.   <div class="user-profile">
  3.     <div v-if="loading">Loading user data...</div>
  4.     <div v-else-if="error" class="error">{{ error }}</div>
  5.     <div v-else>
  6.       <h2>{{ user.name }}</h2>
  7.       <p>Email: {{ user.email }}</p>
  8.       <button @click="goBack">Back to Users</button>
  9.     </div>
  10.   </div>
  11. </template>
  12. <script setup>
  13. import { ref, onMounted } from 'vue'
  14. import { useRoute, useRouter } from 'vue-router'
  15. const route = useRoute()
  16. const router = useRouter()
  17. const user = ref({})
  18. const loading = ref(true)
  19. const error = ref(null)
  20. const fetchUser = async () => {
  21.   try {
  22.     // 模拟API调用
  23.     const userId = route.params.id
  24.     const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
  25.    
  26.     if (!response.ok) {
  27.       throw new Error('Failed to fetch user')
  28.     }
  29.    
  30.     user.value = await response.json()
  31.   } catch (err) {
  32.     error.value = err.message
  33.   } finally {
  34.     loading.value = false
  35.   }
  36. }
  37. const goBack = () => {
  38.   router.push('/users')
  39. }
  40. onMounted(() => {
  41.   fetchUser()
  42. })
  43. </script>
复制代码

UserProfile.test.js:
  1. import { describe, it, expect, vi, beforeEach } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import { createRouter, createWebHistory, Router } from 'vue-router'
  4. import UserProfile from '../components/UserProfile.vue'
  5. // 模拟fetch函数
  6. global.fetch = vi.fn()
  7. describe('UserProfile.vue', () => {
  8.   let router
  9.   beforeEach(() => {
  10.     // 创建路由实例
  11.     router = createRouter({
  12.       history: createWebHistory(),
  13.       routes: [
  14.         { path: '/users/:id', component: UserProfile },
  15.         { path: '/users', component: { template: '<div>Users List</div>' } }
  16.       ]
  17.     })
  18.    
  19.     // 重置fetch模拟
  20.     fetch.mockClear()
  21.   })
  22.   it('renders loading state initially', () => {
  23.     const wrapper = mount(UserProfile, {
  24.       global: {
  25.         plugins: [router]
  26.       },
  27.       props: {
  28.         // 模拟路由参数
  29.         id: '1'
  30.       }
  31.     })
  32.    
  33.     expect(wrapper.text()).toContain('Loading user data...')
  34.   })
  35.   it('renders user data after successful fetch', async () => {
  36.     const mockUser = {
  37.       id: 1,
  38.       name: 'John Doe',
  39.       email: 'john@example.com'
  40.     }
  41.    
  42.     fetch.mockResolvedValueOnce({
  43.       ok: true,
  44.       json: async () => mockUser
  45.     })
  46.    
  47.     const wrapper = mount(UserProfile, {
  48.       global: {
  49.         plugins: [router]
  50.       },
  51.       props: {
  52.         id: '1'
  53.       }
  54.     })
  55.    
  56.     // 等待异步操作完成
  57.     await new Promise(resolve => setTimeout(resolve, 0))
  58.     await wrapper.vm.$nextTick()
  59.    
  60.     expect(wrapper.text()).toContain(mockUser.name)
  61.     expect(wrapper.text()).toContain(mockUser.email)
  62.   })
  63.   it('renders error message when fetch fails', async () => {
  64.     const errorMessage = 'Failed to fetch user'
  65.     fetch.mockRejectedValueOnce(new Error(errorMessage))
  66.    
  67.     const wrapper = mount(UserProfile, {
  68.       global: {
  69.         plugins: [router]
  70.       },
  71.       props: {
  72.         id: '1'
  73.       }
  74.     })
  75.    
  76.     // 等待异步操作完成
  77.     await new Promise(resolve => setTimeout(resolve, 0))
  78.     await wrapper.vm.$nextTick()
  79.    
  80.     expect(wrapper.text()).toContain(errorMessage)
  81.   })
  82.   it('navigates to users page when back button is clicked', async () => {
  83.     const mockUser = {
  84.       id: 1,
  85.       name: 'John Doe',
  86.       email: 'john@example.com'
  87.     }
  88.    
  89.     fetch.mockResolvedValueOnce({
  90.       ok: true,
  91.       json: async () => mockUser
  92.     })
  93.    
  94.     const wrapper = mount(UserProfile, {
  95.       global: {
  96.         plugins: [router]
  97.       },
  98.       props: {
  99.         id: '1'
  100.       }
  101.     })
  102.    
  103.     // 等待数据加载完成
  104.     await new Promise(resolve => setTimeout(resolve, 0))
  105.     await wrapper.vm.$nextTick()
  106.    
  107.     // 模拟路由push方法
  108.     const push = vi.spyOn(router, 'push')
  109.    
  110.     // 点击返回按钮
  111.     await wrapper.find('button').trigger('click')
  112.    
  113.     // 验证路由是否被调用
  114.     expect(push).toHaveBeenCalledWith('/users')
  115.   })
  116. })
复制代码

测试异步组件

在Vue3中,异步组件是提高应用性能的重要手段。测试异步组件需要特殊处理。

AsyncComponent.vue:
  1. <template>
  2.   <div class="async-component">
  3.     <h2>Async Component</h2>
  4.     <p>{{ message }}</p>
  5.     <button @click="updateMessage">Update Message</button>
  6.   </div>
  7. </template>
  8. <script setup>
  9. import { ref } from 'vue'
  10. const message = ref('Initial message')
  11. const updateMessage = () => {
  12.   message.value = 'Updated message'
  13. }
  14. </script>
复制代码

App.vue(使用异步组件):
  1. <template>
  2.   <div class="app">
  3.     <h1>Async Component Demo</h1>
  4.     <Suspense>
  5.       <template #default>
  6.         <AsyncComponent />
  7.       </template>
  8.       <template #fallback>
  9.         <div>Loading component...</div>
  10.       </template>
  11.     </Suspense>
  12.   </div>
  13. </template>
  14. <script setup>
  15. import { defineAsyncComponent } from 'vue'
  16. const AsyncComponent = defineAsyncComponent(() =>
  17.   import('./components/AsyncComponent.vue')
  18. )
  19. </script>
复制代码

AsyncComponent.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import { defineAsyncComponent } from 'vue'
  4. // 直接导入组件进行测试
  5. import AsyncComponent from '../components/AsyncComponent.vue'
  6. describe('AsyncComponent.vue', () => {
  7.   it('renders initial message', () => {
  8.     const wrapper = mount(AsyncComponent)
  9.     expect(wrapper.text()).toContain('Initial message')
  10.   })
  11.   it('updates message when button is clicked', async () => {
  12.     const wrapper = mount(AsyncComponent)
  13.    
  14.     await wrapper.find('button').trigger('click')
  15.    
  16.     expect(wrapper.text()).toContain('Updated message')
  17.   })
  18. })
  19. // 测试使用Suspense的异步组件
  20. describe('App with AsyncComponent', () => {
  21.   it('shows fallback while loading', async () => {
  22.     const AsyncComponent = defineAsyncComponent(() =>
  23.       import('../components/AsyncComponent.vue')
  24.     )
  25.    
  26.     const App = {
  27.       template: `
  28.         <div>
  29.           <Suspense>
  30.             <template #default>
  31.               <AsyncComponent />
  32.             </template>
  33.             <template #fallback>
  34.               <div>Loading component...</div>
  35.             </template>
  36.           </Suspense>
  37.         </div>
  38.       `,
  39.       components: { AsyncComponent }
  40.     }
  41.    
  42.     const wrapper = mount(App)
  43.    
  44.     // 初始状态下应该显示fallback内容
  45.     expect(wrapper.text()).toContain('Loading component...')
  46.    
  47.     // 等待异步组件加载
  48.     await new Promise(resolve => setTimeout(resolve, 10))
  49.     await wrapper.vm.$nextTick()
  50.    
  51.     // 异步组件加载完成后应该显示组件内容
  52.     expect(wrapper.text()).toContain('Async Component')
  53.     expect(wrapper.text()).toContain('Initial message')
  54.   })
  55. })
复制代码

Vue3测试最佳实践

1. 测试组织与命名

良好的测试组织与命名可以提高测试的可读性和可维护性。
  1. src/
  2. ├── components/
  3. │   ├── Button.vue
  4. │   ├── Button.test.js
  5. │   ├── UserProfile.vue
  6. │   └── UserProfile.test.js
  7. ├── composables/
  8. │   ├── useCounter.js
  9. │   ├── useCounter.test.js
  10. │   ├── useFetch.js
  11. │   └── useFetch.test.js
  12. ├── stores/
  13. │   ├── counter.js
  14. │   └── counter.test.js
  15. └── utils/
  16.     ├── formatDate.js
  17.     └── formatDate.test.js
复制代码

• 测试文件:与被测试文件同名,但添加.test.js或.spec.js后缀
• 测试套件:使用describe描述被测试的模块或组件
• 测试用例:使用it或test描述具体的行为,以”应该…“(should…)开头
  1. // 好的测试命名
  2. describe('Button.vue', () => {
  3.   it('should render with correct text', () => {
  4.     // ...
  5.   })
  6.   
  7.   it('should emit click event when clicked', () => {
  8.     // ...
  9.   })
  10.   
  11.   it('should be disabled when disabled prop is true', () => {
  12.     // ...
  13.   })
  14. })
复制代码

2. 测试覆盖率

测试覆盖率是衡量测试完整性的重要指标。Vitest提供了内置的覆盖率支持。

在vitest.config.ts中配置覆盖率:
  1. import { defineConfig } from 'vitest/config'
  2. import vue from '@vitejs/plugin-vue'
  3. export default defineConfig({
  4.   plugins: [vue()],
  5.   test: {
  6.     coverage: {
  7.       provider: 'v8',
  8.       reporter: ['text', 'json', 'html'],
  9.       exclude: [
  10.         'node_modules/',
  11.         'src/main.js',
  12.         '**/*.d.ts',
  13.       ],
  14.     },
  15.   },
  16. })
复制代码
  1. npm run test:coverage
复制代码

• 行覆盖率(Line Coverage):至少80%
• 分支覆盖率(Branch Coverage):至少80%
• 函数覆盖率(Function Coverage):至少80%
• 语句覆盖率(Statement Coverage):至少80%

3. 测试隔离

每个测试应该是独立的,不依赖于其他测试的状态或执行顺序。
  1. import { beforeEach, afterEach } from 'vitest'
  2. describe('Counter Store', () => {
  3.   let counter
  4.   
  5.   beforeEach(() => {
  6.     // 在每个测试前创建新的store实例
  7.     setActivePinia(createPinia())
  8.     counter = useCounterStore()
  9.   })
  10.   
  11.   afterEach(() => {
  12.     // 在每个测试后清理
  13.     // 例如:清除定时器、重置全局状态等
  14.   })
  15.   
  16.   it('should increment count', () => {
  17.     counter.increment()
  18.     expect(counter.count).toBe(1)
  19.   })
  20.   
  21.   it('should decrement count', () => {
  22.     counter.decrement()
  23.     expect(counter.count).toBe(-1)
  24.   })
  25. })
复制代码

4. 模拟外部依赖

在测试中,我们应该模拟外部依赖,如API调用、浏览器API等,以确保测试的稳定性和速度。
  1. import { vi } from 'vitest'
  2. // 模拟fetch函数
  3. global.fetch = vi.fn()
  4. describe('useFetch', () => {
  5.   beforeEach(() => {
  6.     fetch.mockClear()
  7.   })
  8.   
  9.   it('should handle successful response', async () => {
  10.     const mockData = { id: 1, name: 'Test' }
  11.     fetch.mockResolvedValueOnce({
  12.       ok: true,
  13.       json: async () => mockData
  14.     })
  15.    
  16.     const { data } = useFetch('https://api.example.com/data')
  17.    
  18.     await new Promise(resolve => setTimeout(resolve, 0))
  19.    
  20.     expect(data.value).toEqual(mockData)
  21.   })
  22. })
复制代码
  1. import { vi } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import MyComponent from '../components/MyComponent.vue'
  4. // 模拟整个模块
  5. vi.mock('../utils/api', () => ({
  6.   fetchData: vi.fn(() => Promise.resolve({ data: 'mock data' }))
  7. }))
  8. describe('MyComponent', () => {
  9.   it('should use mocked API', async () => {
  10.     const wrapper = mount(MyComponent)
  11.    
  12.     // 等待异步操作完成
  13.     await new Promise(resolve => setTimeout(resolve, 0))
  14.     await wrapper.vm.$nextTick()
  15.    
  16.     expect(wrapper.text()).toContain('mock data')
  17.   })
  18. })
复制代码

5. 测试用户交互

测试应该模拟真实用户的交互行为,而不是测试实现细节。
  1. import { render, fireEvent, screen } from '@testing-library/vue'
  2. import Button from '../components/Button.vue'
  3. test('should call onClick when button is clicked', async () => {
  4.   const onClick = vi.fn()
  5.   
  6.   render(Button, {
  7.     props: {
  8.       onClick
  9.     }
  10.   })
  11.   
  12.   // 使用screen查询元素,更接近用户行为
  13.   const button = screen.getByRole('button', { name: /click me/i })
  14.   
  15.   // 使用fireEvent模拟用户事件
  16.   await fireEvent.click(button)
  17.   
  18.   expect(onClick).toHaveBeenCalled()
  19. })
复制代码

6. 测试可访问性

可访问性是现代Web应用的重要组成部分,应该在测试中考虑。
  1. import { mount } from '@vue/test-utils'
  2. import Button from '../components/Button.vue'
  3. describe('Button Accessibility', () => {
  4.   it('should have correct aria attributes when disabled', () => {
  5.     const wrapper = mount(Button, {
  6.       props: {
  7.         disabled: true
  8.       }
  9.     })
  10.    
  11.     const button = wrapper.find('button')
  12.     expect(button.attributes('aria-disabled')).toBe('true')
  13.     expect(button.attributes('disabled')).toBeDefined()
  14.   })
  15.   
  16.   it('should have correct aria-label when provided', () => {
  17.     const wrapper = mount(Button, {
  18.       props: {
  19.         ariaLabel: 'Close dialog'
  20.       }
  21.     })
  22.    
  23.     const button = wrapper.find('button')
  24.     expect(button.attributes('aria-label')).toBe('Close dialog')
  25.   })
  26. })
复制代码

7. 持续集成中的测试

将测试集成到CI/CD流程中,确保代码变更不会破坏现有功能。
  1. name: Tests
  2. on: [push, pull_request]
  3. jobs:
  4.   test:
  5.     runs-on: ubuntu-latest
  6.    
  7.     steps:
  8.     - uses: actions/checkout@v3
  9.    
  10.     - name: Set up Node.js
  11.       uses: actions/setup-node@v3
  12.       with:
  13.         node-version: '18'
  14.         cache: 'npm'
  15.    
  16.     - name: Install dependencies
  17.       run: npm ci
  18.    
  19.     - name: Run unit tests
  20.       run: npm run test:run
  21.    
  22.     - name: Run coverage
  23.       run: npm run test:coverage
  24.    
  25.     - name: Upload coverage to Codecov
  26.       uses: codecov/codecov-action@v3
  27.       with:
  28.         token: ${{ secrets.CODECOV_TOKEN }}
  29.         file: ./coverage/lcov.info
复制代码

真实项目案例:任务管理应用

让我们通过一个更复杂的真实项目案例,综合运用前面所学的Vue3测试技术。

项目概述

我们将构建一个任务管理应用,包含以下功能:

• 任务的增删改查
• 任务状态管理(待办、进行中、已完成)
• 任务分类和筛选
• 数据持久化(使用localStorage)

项目结构
  1. src/
  2. ├── components/
  3. │   ├── TaskItem.vue
  4. │   ├── TaskList.vue
  5. │   ├── TaskForm.vue
  6. │   └── TaskFilters.vue
  7. ├── composables/
  8. │   ├── useTasks.js
  9. │   └── useLocalStorage.js
  10. ├── stores/
  11. │   └── taskStore.js
  12. └── App.vue
复制代码

实现与测试

composables/useLocalStorage.js:
  1. import { ref, watchEffect } from 'vue'
  2. export function useLocalStorage(key, defaultValue) {
  3.   // 从localStorage获取值,如果没有则使用默认值
  4.   const storedValue = localStorage.getItem(key)
  5.   const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue
  6.   
  7.   // 创建响应式引用
  8.   const value = ref(initialValue)
  9.   
  10.   // 当值变化时,保存到localStorage
  11.   watchEffect(() => {
  12.     localStorage.setItem(key, JSON.stringify(value.value))
  13.   })
  14.   
  15.   return value
  16. }
复制代码

composables/useLocalStorage.test.js:
  1. import { describe, it, expect, beforeEach } from 'vitest'
  2. import { useLocalStorage } from '../composables/useLocalStorage'
  3. describe('useLocalStorage', () => {
  4.   beforeEach(() => {
  5.     // 在每个测试前清除localStorage
  6.     localStorage.clear()
  7.   })
  8.   it('uses default value when no stored value exists', () => {
  9.     const value = useLocalStorage('test-key', 'default-value')
  10.     expect(value.value).toBe('default-value')
  11.   })
  12.   it('uses stored value when it exists', () => {
  13.     localStorage.setItem('test-key', JSON.stringify('stored-value'))
  14.     const value = useLocalStorage('test-key', 'default-value')
  15.     expect(value.value).toBe('stored-value')
  16.   })
  17.   it('saves to localStorage when value changes', () => {
  18.     const value = useLocalStorage('test-key', 'initial-value')
  19.    
  20.     // 修改值
  21.     value.value = 'new-value'
  22.    
  23.     // 检查localStorage是否更新
  24.     expect(localStorage.getItem('test-key')).toBe(JSON.stringify('new-value'))
  25.   })
  26.   it('works with objects', () => {
  27.     const defaultValue = { count: 0, name: 'test' }
  28.     const value = useLocalStorage('test-object', defaultValue)
  29.    
  30.     expect(value.value).toEqual(defaultValue)
  31.    
  32.     // 修改对象
  33.     value.value.count = 5
  34.    
  35.     // 检查localStorage是否更新
  36.     expect(JSON.parse(localStorage.getItem('test-object'))).toEqual({ count: 5, name: 'test' })
  37.   })
  38.   it('works with arrays', () => {
  39.     const defaultValue = ['item1', 'item2']
  40.     const value = useLocalStorage('test-array', defaultValue)
  41.    
  42.     expect(value.value).toEqual(defaultValue)
  43.    
  44.     // 添加新项
  45.     value.value.push('item3')
  46.    
  47.     // 检查localStorage是否更新
  48.     expect(JSON.parse(localStorage.getItem('test-array'))).toEqual(['item1', 'item2', 'item3'])
  49.   })
  50. })
复制代码

composables/useTasks.js:
  1. import { ref, computed } from 'vue'
  2. import { useLocalStorage } from './useLocalStorage'
  3. export function useTasks() {
  4.   // 使用localStorage存储任务
  5.   const tasks = useLocalStorage('tasks', [])
  6.   
  7.   // 获取所有任务
  8.   const getAllTasks = computed(() => tasks.value)
  9.   
  10.   // 根据状态获取任务
  11.   const getTasksByStatus = (status) => {
  12.     return computed(() => tasks.value.filter(task => task.status === status))
  13.   }
  14.   
  15.   // 添加新任务
  16.   const addTask = (task) => {
  17.     const newTask = {
  18.       id: Date.now(),
  19.       title: task.title,
  20.       description: task.description || '',
  21.       status: 'todo', // 默认状态为待办
  22.       createdAt: new Date().toISOString(),
  23.       updatedAt: new Date().toISOString()
  24.     }
  25.    
  26.     tasks.value.push(newTask)
  27.     return newTask
  28.   }
  29.   
  30.   // 更新任务
  31.   const updateTask = (id, updates) => {
  32.     const index = tasks.value.findIndex(task => task.id === id)
  33.     if (index !== -1) {
  34.       tasks.value[index] = {
  35.         ...tasks.value[index],
  36.         ...updates,
  37.         updatedAt: new Date().toISOString()
  38.       }
  39.       return tasks.value[index]
  40.     }
  41.     return null
  42.   }
  43.   
  44.   // 删除任务
  45.   const deleteTask = (id) => {
  46.     const index = tasks.value.findIndex(task => task.id === id)
  47.     if (index !== -1) {
  48.       const deletedTask = tasks.value[index]
  49.       tasks.value.splice(index, 1)
  50.       return deletedTask
  51.     }
  52.     return null
  53.   }
  54.   
  55.   // 获取任务统计
  56.   const getTaskStats = computed(() => {
  57.     const stats = {
  58.       total: tasks.value.length,
  59.       todo: 0,
  60.       inProgress: 0,
  61.       completed: 0
  62.     }
  63.    
  64.     tasks.value.forEach(task => {
  65.       if (task.status === 'todo') stats.todo++
  66.       else if (task.status === 'inProgress') stats.inProgress++
  67.       else if (task.status === 'completed') stats.completed++
  68.     })
  69.    
  70.     return stats
  71.   })
  72.   
  73.   return {
  74.     getAllTasks,
  75.     getTasksByStatus,
  76.     addTask,
  77.     updateTask,
  78.     deleteTask,
  79.     getTaskStats
  80.   }
  81. }
复制代码

composables/useTasks.test.js:
  1. import { describe, it, expect, beforeEach } from 'vitest'
  2. import { useTasks } from '../composables/useTasks'
  3. describe('useTasks', () => {
  4.   let tasks
  5.   
  6.   beforeEach(() => {
  7.     // 在每个测试前创建新的tasks实例
  8.     tasks = useTasks()
  9.   })
  10.   
  11.   it('starts with empty tasks array', () => {
  12.     expect(tasks.getAllTasks.value).toEqual([])
  13.   })
  14.   
  15.   it('adds a new task', () => {
  16.     const newTask = tasks.addTask({
  17.       title: 'Test Task',
  18.       description: 'Test Description'
  19.     })
  20.    
  21.     expect(newTask.title).toBe('Test Task')
  22.     expect(newTask.description).toBe('Test Description')
  23.     expect(newTask.status).toBe('todo')
  24.     expect(newTask.id).toBeDefined()
  25.     expect(tasks.getAllTasks.value).toHaveLength(1)
  26.   })
  27.   
  28.   it('updates a task', () => {
  29.     const task = tasks.addTask({
  30.       title: 'Original Title'
  31.     })
  32.    
  33.     const updatedTask = tasks.updateTask(task.id, {
  34.       title: 'Updated Title',
  35.       status: 'inProgress'
  36.     })
  37.    
  38.     expect(updatedTask.title).toBe('Updated Title')
  39.     expect(updatedTask.status).toBe('inProgress')
  40.     expect(updatedTask.updatedAt).not.toBe(task.updatedAt)
  41.   })
  42.   
  43.   it('returns null when updating non-existent task', () => {
  44.     const result = tasks.updateTask(999, { title: 'Updated' })
  45.     expect(result).toBeNull()
  46.   })
  47.   
  48.   it('deletes a task', () => {
  49.     const task = tasks.addTask({
  50.       title: 'Task to Delete'
  51.     })
  52.    
  53.     const deletedTask = tasks.deleteTask(task.id)
  54.    
  55.     expect(deletedTask.id).toBe(task.id)
  56.     expect(tasks.getAllTasks.value).toHaveLength(0)
  57.   })
  58.   
  59.   it('returns null when deleting non-existent task', () => {
  60.     const result = tasks.deleteTask(999)
  61.     expect(result).toBeNull()
  62.   })
  63.   
  64.   it('filters tasks by status', () => {
  65.     tasks.addTask({ title: 'Todo Task 1' })
  66.     tasks.addTask({ title: 'Todo Task 2' })
  67.    
  68.     const inProgressTask = tasks.addTask({ title: 'In Progress Task' })
  69.     tasks.updateTask(inProgressTask.id, { status: 'inProgress' })
  70.    
  71.     const completedTask = tasks.addTask({ title: 'Completed Task' })
  72.     tasks.updateTask(completedTask.id, { status: 'completed' })
  73.    
  74.     const todoTasks = tasks.getTasksByStatus('todo')
  75.     const inProgressTasks = tasks.getTasksByStatus('inProgress')
  76.     const completedTasks = tasks.getTasksByStatus('completed')
  77.    
  78.     expect(todoTasks.value).toHaveLength(2)
  79.     expect(inProgressTasks.value).toHaveLength(1)
  80.     expect(completedTasks.value).toHaveLength(1)
  81.   })
  82.   
  83.   it('calculates task statistics correctly', () => {
  84.     tasks.addTask({ title: 'Todo Task 1' })
  85.     tasks.addTask({ title: 'Todo Task 2' })
  86.    
  87.     const inProgressTask = tasks.addTask({ title: 'In Progress Task' })
  88.     tasks.updateTask(inProgressTask.id, { status: 'inProgress' })
  89.    
  90.     const completedTask = tasks.addTask({ title: 'Completed Task' })
  91.     tasks.updateTask(completedTask.id, { status: 'completed' })
  92.    
  93.     const stats = tasks.getTaskStats
  94.    
  95.     expect(stats.value.total).toBe(4)
  96.     expect(stats.value.todo).toBe(2)
  97.     expect(stats.value.inProgress).toBe(1)
  98.     expect(stats.value.completed).toBe(1)
  99.   })
  100. })
复制代码

components/TaskItem.vue:
  1. <template>
  2.   <div class="task-item" :class="task.status">
  3.     <div class="task-content" @click="$emit('select', task.id)">
  4.       <h3>{{ task.title }}</h3>
  5.       <p v-if="task.description">{{ task.description }}</p>
  6.       <div class="task-meta">
  7.         <span class="task-date">Created: {{ formatDate(task.createdAt) }}</span>
  8.         <span class="task-status">{{ statusLabel }}</span>
  9.       </div>
  10.     </div>
  11.     <div class="task-actions">
  12.       <button
  13.         v-if="task.status !== 'completed'"
  14.         class="btn-complete"
  15.         @click="$emit('complete', task.id)"
  16.         title="Mark as completed"
  17.       >
  18.         ✓
  19.       </button>
  20.       <button
  21.         class="btn-delete"
  22.         @click="$emit('delete', task.id)"
  23.         title="Delete task"
  24.       >
  25.         ×
  26.       </button>
  27.     </div>
  28.   </div>
  29. </template>
  30. <script setup>
  31. import { computed } from 'vue'
  32. const props = defineProps({
  33.   task: {
  34.     type: Object,
  35.     required: true
  36.   }
  37. })
  38. defineEmits(['select', 'complete', 'delete'])
  39. const statusLabel = computed(() => {
  40.   switch (props.task.status) {
  41.     case 'todo': return 'To Do'
  42.     case 'inProgress': return 'In Progress'
  43.     case 'completed': return 'Completed'
  44.     default: return props.task.status
  45.   }
  46. })
  47. const formatDate = (dateString) => {
  48.   const date = new Date(dateString)
  49.   return date.toLocaleDateString()
  50. }
  51. </script>
  52. <style scoped>
  53. .task-item {
  54.   display: flex;
  55.   justify-content: space-between;
  56.   padding: 12px;
  57.   margin-bottom: 8px;
  58.   border-radius: 4px;
  59.   background-color: #fff;
  60.   box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  61.   transition: transform 0.2s;
  62. }
  63. .task-item:hover {
  64.   transform: translateY(-2px);
  65.   box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  66. }
  67. .task-item.todo {
  68.   border-left: 4px solid #3498db;
  69. }
  70. .task-item.inProgress {
  71.   border-left: 4px solid #f39c12;
  72. }
  73. .task-item.completed {
  74.   border-left: 4px solid #2ecc71;
  75.   opacity: 0.7;
  76. }
  77. .task-content {
  78.   flex: 1;
  79.   cursor: pointer;
  80. }
  81. .task-content h3 {
  82.   margin: 0 0 8px 0;
  83.   font-size: 16px;
  84. }
  85. .task-content p {
  86.   margin: 0 0 8px 0;
  87.   color: #666;
  88.   font-size: 14px;
  89. }
  90. .task-meta {
  91.   display: flex;
  92.   justify-content: space-between;
  93.   font-size: 12px;
  94.   color: #999;
  95. }
  96. .task-status {
  97.   font-weight: bold;
  98.   text-transform: uppercase;
  99. }
  100. .task-actions {
  101.   display: flex;
  102.   flex-direction: column;
  103.   justify-content: center;
  104.   margin-left: 10px;
  105. }
  106. .btn-complete, .btn-delete {
  107.   background: none;
  108.   border: none;
  109.   cursor: pointer;
  110.   font-size: 16px;
  111.   padding: 4px 8px;
  112.   border-radius: 4px;
  113.   margin: 2px 0;
  114. }
  115. .btn-complete {
  116.   color: #2ecc71;
  117. }
  118. .btn-complete:hover {
  119.   background-color: rgba(46, 204, 113, 0.1);
  120. }
  121. .btn-delete {
  122.   color: #e74c3c;
  123. }
  124. .btn-delete:hover {
  125.   background-color: rgba(231, 76, 60, 0.1);
  126. }
  127. </style>
复制代码

components/TaskItem.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import TaskItem from '../components/TaskItem.vue'
  4. describe('TaskItem.vue', () => {
  5.   const mockTask = {
  6.     id: 1,
  7.     title: 'Test Task',
  8.     description: 'Test Description',
  9.     status: 'todo',
  10.     createdAt: '2023-01-01T00:00:00.000Z',
  11.     updatedAt: '2023-01-01T00:00:00.000Z'
  12.   }
  13.   it('renders task information', () => {
  14.     const wrapper = mount(TaskItem, {
  15.       props: {
  16.         task: mockTask
  17.       }
  18.     })
  19.    
  20.     expect(wrapper.text()).toContain(mockTask.title)
  21.     expect(wrapper.text()).toContain(mockTask.description)
  22.     expect(wrapper.text()).toContain('To Do')
  23.   })
  24.   it('applies correct status class', () => {
  25.     const wrapper = mount(TaskItem, {
  26.       props: {
  27.         task: mockTask
  28.       }
  29.     })
  30.    
  31.     expect(wrapper.classes()).toContain('todo')
  32.   })
  33.   it('emits select event when task content is clicked', async () => {
  34.     const wrapper = mount(TaskItem, {
  35.       props: {
  36.         task: mockTask
  37.       }
  38.     })
  39.    
  40.     await wrapper.find('.task-content').trigger('click')
  41.    
  42.     expect(wrapper.emitted()).toHaveProperty('select')
  43.     expect(wrapper.emitted('select')[0]).toEqual([mockTask.id])
  44.   })
  45.   it('emits complete event when complete button is clicked', async () => {
  46.     const wrapper = mount(TaskItem, {
  47.       props: {
  48.         task: mockTask
  49.       }
  50.     })
  51.    
  52.     await wrapper.find('.btn-complete').trigger('click')
  53.    
  54.     expect(wrapper.emitted()).toHaveProperty('complete')
  55.     expect(wrapper.emitted('complete')[0]).toEqual([mockTask.id])
  56.   })
  57.   it('emits delete event when delete button is clicked', async () => {
  58.     const wrapper = mount(TaskItem, {
  59.       props: {
  60.         task: mockTask
  61.       }
  62.     })
  63.    
  64.     await wrapper.find('.btn-delete').trigger('click')
  65.    
  66.     expect(wrapper.emitted()).toHaveProperty('delete')
  67.     expect(wrapper.emitted('delete')[0]).toEqual([mockTask.id])
  68.   })
  69.   it('does not show complete button for completed tasks', () => {
  70.     const completedTask = { ...mockTask, status: 'completed' }
  71.     const wrapper = mount(TaskItem, {
  72.       props: {
  73.         task: completedTask
  74.       }
  75.     })
  76.    
  77.     expect(wrapper.find('.btn-complete').exists()).toBe(false)
  78.   })
  79.   it('formats date correctly', () => {
  80.     const wrapper = mount(TaskItem, {
  81.       props: {
  82.         task: mockTask
  83.       }
  84.     })
  85.    
  86.     // 检查日期是否被格式化(不包含ISO格式的时间部分)
  87.     expect(wrapper.text()).not.toContain('T00:00:00.000Z')
  88.   })
  89.   it('applies different styles for different statuses', () => {
  90.     const todoWrapper = mount(TaskItem, {
  91.       props: {
  92.         task: { ...mockTask, status: 'todo' }
  93.       }
  94.     })
  95.    
  96.     const inProgressWrapper = mount(TaskItem, {
  97.       props: {
  98.         task: { ...mockTask, status: 'inProgress' }
  99.       }
  100.     })
  101.    
  102.     const completedWrapper = mount(TaskItem, {
  103.       props: {
  104.         task: { ...mockTask, status: 'completed' }
  105.       }
  106.     })
  107.    
  108.     expect(todoWrapper.classes()).toContain('todo')
  109.     expect(inProgressWrapper.classes()).toContain('inProgress')
  110.     expect(completedWrapper.classes()).toContain('completed')
  111.    
  112.     // 检查状态文本
  113.     expect(todoWrapper.text()).toContain('To Do')
  114.     expect(inProgressWrapper.text()).toContain('In Progress')
  115.     expect(completedWrapper.text()).toContain('Completed')
  116.   })
  117. })
复制代码

components/TaskList.vue:
  1. <template>
  2.   <div class="task-list">
  3.     <div v-if="tasks.length === 0" class="empty-state">
  4.       <p>No tasks found. Create a new task to get started!</p>
  5.     </div>
  6.     <div v-else>
  7.       <TaskItem
  8.         v-for="task in tasks"
  9.         :key="task.id"
  10.         :task="task"
  11.         @select="onSelectTask"
  12.         @complete="onCompleteTask"
  13.         @delete="onDeleteTask"
  14.       />
  15.     </div>
  16.   </div>
  17. </template>
  18. <script setup>
  19. import { defineProps, defineEmits } from 'vue'
  20. import TaskItem from './TaskItem.vue'
  21. const props = defineProps({
  22.   tasks: {
  23.     type: Array,
  24.     required: true
  25.   }
  26. })
  27. const emit = defineEmits(['select-task', 'complete-task', 'delete-task'])
  28. const onSelectTask = (taskId) => {
  29.   emit('select-task', taskId)
  30. }
  31. const onCompleteTask = (taskId) => {
  32.   emit('complete-task', taskId)
  33. }
  34. const onDeleteTask = (taskId) => {
  35.   emit('delete-task', taskId)
  36. }
  37. </script>
  38. <style scoped>
  39. .task-list {
  40.   margin-top: 20px;
  41. }
  42. .empty-state {
  43.   text-align: center;
  44.   padding: 40px 20px;
  45.   background-color: #f9f9f9;
  46.   border-radius: 8px;
  47.   color: #666;
  48. }
  49. </style>
复制代码

components/TaskList.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import TaskList from '../components/TaskList.vue'
  4. import TaskItem from '../components/TaskItem.vue'
  5. // 模拟TaskItem组件
  6. vi.mock('../components/TaskItem.vue', () => ({
  7.   default: {
  8.     name: 'TaskItem',
  9.     props: ['task'],
  10.     emits: ['select', 'complete', 'delete'],
  11.     template: '<div class="mock-task-item">{{ task.title }}</div>'
  12.   }
  13. }))
  14. describe('TaskList.vue', () => {
  15.   const mockTasks = [
  16.     { id: 1, title: 'Task 1', status: 'todo' },
  17.     { id: 2, title: 'Task 2', status: 'inProgress' },
  18.     { id: 3, title: 'Task 3', status: 'completed' }
  19.   ]
  20.   it('renders empty state when no tasks are provided', () => {
  21.     const wrapper = mount(TaskList, {
  22.       props: {
  23.         tasks: []
  24.       }
  25.     })
  26.    
  27.     expect(wrapper.find('.empty-state').exists()).toBe(true)
  28.     expect(wrapper.text()).toContain('No tasks found')
  29.   })
  30.   it('renders task items when tasks are provided', () => {
  31.     const wrapper = mount(TaskList, {
  32.       props: {
  33.         tasks: mockTasks
  34.       }
  35.     })
  36.    
  37.     expect(wrapper.find('.empty-state').exists()).toBe(false)
  38.     expect(wrapper.findAll('.mock-task-item')).toHaveLength(mockTasks.length)
  39.   })
  40.   it('emits select-task event when TaskItem emits select', async () => {
  41.     const wrapper = mount(TaskList, {
  42.       props: {
  43.         tasks: mockTasks
  44.       }
  45.     })
  46.    
  47.     // 找到第一个TaskItem并触发select事件
  48.     await wrapper.findAllComponents(TaskItem)[0].$emit('select', 1)
  49.    
  50.     expect(wrapper.emitted()).toHaveProperty('select-task')
  51.     expect(wrapper.emitted('select-task')[0]).toEqual([1])
  52.   })
  53.   it('emits complete-task event when TaskItem emits complete', async () => {
  54.     const wrapper = mount(TaskList, {
  55.       props: {
  56.         tasks: mockTasks
  57.       }
  58.     })
  59.    
  60.     // 找到第一个TaskItem并触发complete事件
  61.     await wrapper.findAllComponents(TaskItem)[0].$emit('complete', 1)
  62.    
  63.     expect(wrapper.emitted()).toHaveProperty('complete-task')
  64.     expect(wrapper.emitted('complete-task')[0]).toEqual([1])
  65.   })
  66.   it('emits delete-task event when TaskItem emits delete', async () => {
  67.     const wrapper = mount(TaskList, {
  68.       props: {
  69.         tasks: mockTasks
  70.       }
  71.     })
  72.    
  73.     // 找到第一个TaskItem并触发delete事件
  74.     await wrapper.findAllComponents(TaskItem)[0].$emit('delete', 1)
  75.    
  76.     expect(wrapper.emitted()).toHaveProperty('delete-task')
  77.     expect(wrapper.emitted('delete-task')[0]).toEqual([1])
  78.   })
  79. })
复制代码

components/TaskForm.vue:
  1. <template>
  2.   <div class="task-form">
  3.     <h2>{{ isEditing ? 'Edit Task' : 'Create New Task' }}</h2>
  4.     <form @submit.prevent="handleSubmit">
  5.       <div class="form-group">
  6.         <label for="title">Title *</label>
  7.         <input
  8.           id="title"
  9.           v-model="formData.title"
  10.           type="text"
  11.           required
  12.           placeholder="Enter task title"
  13.         />
  14.       </div>
  15.       
  16.       <div class="form-group">
  17.         <label for="description">Description</label>
  18.         <textarea
  19.           id="description"
  20.           v-model="formData.description"
  21.           placeholder="Enter task description"
  22.           rows="3"
  23.         ></textarea>
  24.       </div>
  25.       
  26.       <div class="form-group">
  27.         <label for="status">Status</label>
  28.         <select id="status" v-model="formData.status">
  29.           <option value="todo">To Do</option>
  30.           <option value="inProgress">In Progress</option>
  31.           <option value="completed">Completed</option>
  32.         </select>
  33.       </div>
  34.       
  35.       <div class="form-actions">
  36.         <button type="submit" class="btn-primary">
  37.           {{ isEditing ? 'Update Task' : 'Create Task' }}
  38.         </button>
  39.         <button type="button" class="btn-secondary" @click="handleCancel">
  40.           Cancel
  41.         </button>
  42.       </div>
  43.     </form>
  44.   </div>
  45. </template>
  46. <script setup>
  47. import { ref, computed, watch } from 'vue'
  48. const props = defineProps({
  49.   task: {
  50.     type: Object,
  51.     default: null
  52.   }
  53. })
  54. const emit = defineEmits(['submit', 'cancel'])
  55. // 表单数据
  56. const formData = ref({
  57.   title: '',
  58.   description: '',
  59.   status: 'todo'
  60. })
  61. // 计算是否处于编辑模式
  62. const isEditing = computed(() => props.task !== null)
  63. // 监听props.task变化,更新表单数据
  64. watch(() => props.task, (newTask) => {
  65.   if (newTask) {
  66.     formData.value = {
  67.       title: newTask.title,
  68.       description: newTask.description,
  69.       status: newTask.status
  70.     }
  71.   } else {
  72.     resetForm()
  73.   }
  74. }, { immediate: true })
  75. // 重置表单
  76. const resetForm = () => {
  77.   formData.value = {
  78.     title: '',
  79.     description: '',
  80.     status: 'todo'
  81.   }
  82. }
  83. // 处理表单提交
  84. const handleSubmit = () => {
  85.   emit('submit', {
  86.     ...formData.value,
  87.     id: props.task ? props.task.id : undefined
  88.   })
  89. }
  90. // 处理取消
  91. const handleCancel = () => {
  92.   resetForm()
  93.   emit('cancel')
  94. }
  95. </script>
  96. <style scoped>
  97. .task-form {
  98.   background-color: #fff;
  99.   padding: 20px;
  100.   border-radius: 8px;
  101.   box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  102.   margin-bottom: 20px;
  103. }
  104. h2 {
  105.   margin-top: 0;
  106.   margin-bottom: 20px;
  107.   color: #333;
  108. }
  109. .form-group {
  110.   margin-bottom: 15px;
  111. }
  112. label {
  113.   display: block;
  114.   margin-bottom: 5px;
  115.   font-weight: bold;
  116.   color: #555;
  117. }
  118. input[type="text"],
  119. textarea,
  120. select {
  121.   width: 100%;
  122.   padding: 10px;
  123.   border: 1px solid #ddd;
  124.   border-radius: 4px;
  125.   font-size: 14px;
  126. }
  127. textarea {
  128.   resize: vertical;
  129. }
  130. .form-actions {
  131.   display: flex;
  132.   gap: 10px;
  133.   margin-top: 20px;
  134. }
  135. .btn-primary,
  136. .btn-secondary {
  137.   padding: 10px 20px;
  138.   border: none;
  139.   border-radius: 4px;
  140.   font-size: 14px;
  141.   cursor: pointer;
  142.   transition: background-color 0.2s;
  143. }
  144. .btn-primary {
  145.   background-color: #3498db;
  146.   color: white;
  147. }
  148. .btn-primary:hover {
  149.   background-color: #2980b9;
  150. }
  151. .btn-secondary {
  152.   background-color: #ecf0f1;
  153.   color: #333;
  154. }
  155. .btn-secondary:hover {
  156.   background-color: #bdc3c7;
  157. }
  158. </style>
复制代码

components/TaskForm.test.js:
  1. import { describe, it, expect } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import TaskForm from '../components/TaskForm.vue'
  4. describe('TaskForm.vue', () => {
  5.   it('renders create form by default', () => {
  6.     const wrapper = mount(TaskForm)
  7.    
  8.     expect(wrapper.find('h2').text()).toBe('Create New Task')
  9.     expect(wrapper.find('button[type="submit"]').text()).toBe('Create Task')
  10.   })
  11.   it('renders edit form when task prop is provided', () => {
  12.     const task = {
  13.       id: 1,
  14.       title: 'Edit Task',
  15.       description: 'Edit Description',
  16.       status: 'inProgress'
  17.     }
  18.    
  19.     const wrapper = mount(TaskForm, {
  20.       props: {
  21.         task
  22.       }
  23.     })
  24.    
  25.     expect(wrapper.find('h2').text()).toBe('Edit Task')
  26.     expect(wrapper.find('button[type="submit"]').text()).toBe('Update Task')
  27.     expect(wrapper.find('#title').element.value).toBe(task.title)
  28.     expect(wrapper.find('#description').element.value).toBe(task.description)
  29.     expect(wrapper.find('#status').element.value).toBe(task.status)
  30.   })
  31.   it('emits submit event with form data when submitted', async () => {
  32.     const wrapper = mount(TaskForm)
  33.    
  34.     // 填写表单
  35.     await wrapper.find('#title').setValue('New Task')
  36.     await wrapper.find('#description').setValue('New Description')
  37.     await wrapper.find('#status').setValue('inProgress')
  38.    
  39.     // 提交表单
  40.     await wrapper.find('form').trigger('submit')
  41.    
  42.     expect(wrapper.emitted()).toHaveProperty('submit')
  43.     expect(wrapper.emitted('submit')[0][0]).toEqual({
  44.       title: 'New Task',
  45.       description: 'New Description',
  46.       status: 'inProgress',
  47.       id: undefined
  48.     })
  49.   })
  50.   it('includes task id when editing', async () => {
  51.     const task = {
  52.       id: 1,
  53.       title: 'Edit Task',
  54.       description: 'Edit Description',
  55.       status: 'inProgress'
  56.     }
  57.    
  58.     const wrapper = mount(TaskForm, {
  59.       props: {
  60.         task
  61.       }
  62.     })
  63.    
  64.     // 修改表单
  65.     await wrapper.find('#title').setValue('Updated Task')
  66.    
  67.     // 提交表单
  68.     await wrapper.find('form').trigger('submit')
  69.    
  70.     expect(wrapper.emitted()).toHaveProperty('submit')
  71.     expect(wrapper.emitted('submit')[0][0]).toEqual({
  72.       title: 'Updated Task',
  73.       description: 'Edit Description',
  74.       status: 'inProgress',
  75.       id: 1
  76.     })
  77.   })
  78.   it('emits cancel event when cancel button is clicked', async () => {
  79.     const wrapper = mount(TaskForm)
  80.    
  81.     await wrapper.find('.btn-secondary').trigger('click')
  82.    
  83.     expect(wrapper.emitted()).toHaveProperty('cancel')
  84.   })
  85.   it('resets form when cancel button is clicked', async () => {
  86.     const wrapper = mount(TaskForm)
  87.    
  88.     // 填写表单
  89.     await wrapper.find('#title').setValue('New Task')
  90.     await wrapper.find('#description').setValue('New Description')
  91.    
  92.     // 点击取消
  93.     await wrapper.find('.btn-secondary').trigger('click')
  94.    
  95.     // 检查表单是否重置
  96.     expect(wrapper.find('#title').element.value).toBe('')
  97.     expect(wrapper.find('#description').element.value).toBe('')
  98.     expect(wrapper.find('#status').element.value).toBe('todo')
  99.   })
  100.   it('updates form when task prop changes', async () => {
  101.     const wrapper = mount(TaskForm)
  102.    
  103.     // 初始状态
  104.     expect(wrapper.find('#title').element.value).toBe('')
  105.     expect(wrapper.find('#description').element.value).toBe('')
  106.    
  107.     // 更新task prop
  108.     const newTask = {
  109.       id: 2,
  110.       title: 'New Task',
  111.       description: 'New Description',
  112.       status: 'completed'
  113.     }
  114.    
  115.     await wrapper.setProps({ task: newTask })
  116.    
  117.     // 检查表单是否更新
  118.     expect(wrapper.find('#title').element.value).toBe(newTask.title)
  119.     expect(wrapper.find('#description').element.value).toBe(newTask.description)
  120.     expect(wrapper.find('#status').element.value).toBe(newTask.status)
  121.   })
  122.   it('requires title field', async () => {
  123.     const wrapper = mount(TaskForm)
  124.    
  125.     // 尝试提交空表单
  126.     await wrapper.find('form').trigger('submit.prevent')
  127.    
  128.     // 表单验证应该阻止提交
  129.     expect(wrapper.emitted()).not.toHaveProperty('submit')
  130.   })
  131. })
复制代码

App.vue:
  1. <template>
  2.   <div class="app">
  3.     <header class="app-header">
  4.       <h1>Task Manager</h1>
  5.       <div class="task-stats">
  6.         <div class="stat">
  7.           <span class="stat-value">{{ stats.total }}</span>
  8.           <span class="stat-label">Total</span>
  9.         </div>
  10.         <div class="stat">
  11.           <span class="stat-value">{{ stats.todo }}</span>
  12.           <span class="stat-label">To Do</span>
  13.         </div>
  14.         <div class="stat">
  15.           <span class="stat-value">{{ stats.inProgress }}</span>
  16.           <span class="stat-label">In Progress</span>
  17.         </div>
  18.         <div class="stat">
  19.           <span class="stat-value">{{ stats.completed }}</span>
  20.           <span class="stat-label">Completed</span>
  21.         </div>
  22.       </div>
  23.     </header>
  24.    
  25.     <main class="app-main">
  26.       <TaskForm
  27.         :task="editingTask"
  28.         @submit="handleTaskSubmit"
  29.         @cancel="handleFormCancel"
  30.       />
  31.       
  32.       <div class="filter-section">
  33.         <h3>Filter Tasks</h3>
  34.         <div class="filter-buttons">
  35.           <button
  36.             class="filter-btn"
  37.             :class="{ active: currentFilter === 'all' }"
  38.             @click="currentFilter = 'all'"
  39.           >
  40.             All
  41.           </button>
  42.           <button
  43.             class="filter-btn"
  44.             :class="{ active: currentFilter === 'todo' }"
  45.             @click="currentFilter = 'todo'"
  46.           >
  47.             To Do
  48.           </button>
  49.           <button
  50.             class="filter-btn"
  51.             :class="{ active: currentFilter === 'inProgress' }"
  52.             @click="currentFilter = 'inProgress'"
  53.           >
  54.             In Progress
  55.           </button>
  56.           <button
  57.             class="filter-btn"
  58.             :class="{ active: currentFilter === 'completed' }"
  59.             @click="currentFilter = 'completed'"
  60.           >
  61.             Completed
  62.           </button>
  63.         </div>
  64.       </div>
  65.       
  66.       <TaskList
  67.         :tasks="filteredTasks"
  68.         @select-task="handleSelectTask"
  69.         @complete-task="handleCompleteTask"
  70.         @delete-task="handleDeleteTask"
  71.       />
  72.     </main>
  73.   </div>
  74. </template>
  75. <script setup>
  76. import { ref, computed, onMounted } from 'vue'
  77. import { useTasks } from './composables/useTasks'
  78. import TaskForm from './components/TaskForm.vue'
  79. import TaskList from './components/TaskList.vue'
  80. const { getAllTasks, addTask, updateTask, deleteTask, getTaskStats } = useTasks()
  81. // 任务统计
  82. const stats = computed(() => getTaskStats.value)
  83. // 当前过滤器
  84. const currentFilter = ref('all')
  85. // 当前编辑的任务
  86. const editingTask = ref(null)
  87. // 根据过滤器获取任务
  88. const filteredTasks = computed(() => {
  89.   if (currentFilter.value === 'all') {
  90.     return getAllTasks.value
  91.   }
  92.   return getAllTasks.value.filter(task => task.status === currentFilter.value)
  93. })
  94. // 处理任务表单提交
  95. const handleTaskSubmit = (taskData) => {
  96.   if (taskData.id) {
  97.     // 更新现有任务
  98.     updateTask(taskData.id, taskData)
  99.   } else {
  100.     // 创建新任务
  101.     addTask(taskData)
  102.   }
  103.   
  104.   // 重置编辑状态
  105.   editingTask.value = null
  106. }
  107. // 处理表单取消
  108. const handleFormCancel = () => {
  109.   editingTask.value = null
  110. }
  111. // 处理任务选择
  112. const handleSelectTask = (taskId) => {
  113.   const task = getAllTasks.value.find(t => t.id === taskId)
  114.   if (task) {
  115.     editingTask.value = task
  116.   }
  117. }
  118. // 处理任务完成
  119. const handleCompleteTask = (taskId) => {
  120.   updateTask(taskId, { status: 'completed' })
  121. }
  122. // 处理任务删除
  123. const handleDeleteTask = (taskId) => {
  124.   deleteTask(taskId)
  125.   
  126.   // 如果删除的是当前编辑的任务,重置编辑状态
  127.   if (editingTask.value && editingTask.value.id === taskId) {
  128.     editingTask.value = null
  129.   }
  130. }
  131. </script>
  132. <style>
  133. * {
  134.   box-sizing: border-box;
  135.   margin: 0;
  136.   padding: 0;
  137. }
  138. body {
  139.   font-family: 'Arial', sans-serif;
  140.   line-height: 1.6;
  141.   color: #333;
  142.   background-color: #f5f7fa;
  143. }
  144. .app {
  145.   max-width: 800px;
  146.   margin: 0 auto;
  147.   padding: 20px;
  148. }
  149. .app-header {
  150.   margin-bottom: 30px;
  151.   text-align: center;
  152. }
  153. .app-header h1 {
  154.   color: #2c3e50;
  155.   margin-bottom: 20px;
  156. }
  157. .task-stats {
  158.   display: flex;
  159.   justify-content: space-around;
  160.   background-color: #fff;
  161.   padding: 15px;
  162.   border-radius: 8px;
  163.   box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  164. }
  165. .stat {
  166.   display: flex;
  167.   flex-direction: column;
  168.   align-items: center;
  169. }
  170. .stat-value {
  171.   font-size: 24px;
  172.   font-weight: bold;
  173.   color: #3498db;
  174. }
  175. .stat-label {
  176.   font-size: 14px;
  177.   color: #7f8c8d;
  178. }
  179. .app-main {
  180.   background-color: #fff;
  181.   padding: 20px;
  182.   border-radius: 8px;
  183.   box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  184. }
  185. .filter-section {
  186.   margin: 20px 0;
  187. }
  188. .filter-section h3 {
  189.   margin-bottom: 10px;
  190.   color: #2c3e50;
  191. }
  192. .filter-buttons {
  193.   display: flex;
  194.   gap: 10px;
  195. }
  196. .filter-btn {
  197.   padding: 8px 16px;
  198.   border: 1px solid #ddd;
  199.   background-color: #fff;
  200.   border-radius: 4px;
  201.   cursor: pointer;
  202.   transition: all 0.2s;
  203. }
  204. .filter-btn:hover {
  205.   background-color: #f1f2f6;
  206. }
  207. .filter-btn.active {
  208.   background-color: #3498db;
  209.   color: white;
  210.   border-color: #3498db;
  211. }
  212. </style>
复制代码

App.test.js:
  1. import { describe, it, expect, vi, beforeEach } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import { createPinia, setActivePinia } from 'pinia'
  4. import App from '../App.vue'
  5. // 模拟组件
  6. vi.mock('../components/TaskForm.vue', () => ({
  7.   default: {
  8.     name: 'TaskForm',
  9.     props: ['task'],
  10.     emits: ['submit', 'cancel'],
  11.     template: '<div class="mock-task-form"></div>'
  12.   }
  13. }))
  14. vi.mock('../components/TaskList.vue', () => ({
  15.   default: {
  16.     name: 'TaskList',
  17.     props: ['tasks'],
  18.     emits: ['select-task', 'complete-task', 'delete-task'],
  19.     template: '<div class="mock-task-list"></div>'
  20.   }
  21. }))
  22. // 模拟组合式函数
  23. const mockUseTasks = {
  24.   getAllTasks: { value: [] },
  25.   addTask: vi.fn(),
  26.   updateTask: vi.fn(),
  27.   deleteTask: vi.fn(),
  28.   getTaskStats: { value: { total: 0, todo: 0, inProgress: 0, completed: 0 } }
  29. }
  30. vi.mock('../composables/useTasks', () => ({
  31.   useTasks: () => mockUseTasks
  32. }))
  33. describe('App.vue', () => {
  34.   let wrapper
  35.   beforeEach(() => {
  36.     // 创建新的pinia实例
  37.     setActivePinia(createPinia())
  38.    
  39.     // 重置模拟函数
  40.     vi.clearAllMocks()
  41.    
  42.     // 重置任务数据
  43.     mockUseTasks.getAllTasks.value = []
  44.     mockUseTasks.getTaskStats.value = { total: 0, todo: 0, inProgress: 0, completed: 0 }
  45.    
  46.     // 挂载组件
  47.     wrapper = mount(App)
  48.   })
  49.   it('renders correctly', () => {
  50.     expect(wrapper.find('h1').text()).toBe('Task Manager')
  51.     expect(wrapper.findComponent({ name: 'TaskForm' }).exists()).toBe(true)
  52.     expect(wrapper.findComponent({ name: 'TaskList' }).exists()).toBe(true)
  53.   })
  54.   it('displays task statistics', () => {
  55.     mockUseTasks.getTaskStats.value = { total: 5, todo: 2, inProgress: 2, completed: 1 }
  56.    
  57.     wrapper = mount(App)
  58.    
  59.     const stats = wrapper.findAll('.stat')
  60.     expect(stats[0].find('.stat-value').text()).toBe('5')
  61.     expect(stats[1].find('.stat-value').text()).toBe('2')
  62.     expect(stats[2].find('.stat-value').text()).toBe('2')
  63.     expect(stats[3].find('.stat-value').text()).toBe('1')
  64.   })
  65.   it('filters tasks correctly', async () => {
  66.     mockUseTasks.getAllTasks.value = [
  67.       { id: 1, title: 'Task 1', status: 'todo' },
  68.       { id: 2, title: 'Task 2', status: 'inProgress' },
  69.       { id: 3, title: 'Task 3', status: 'completed' }
  70.     ]
  71.    
  72.     wrapper = mount(App)
  73.    
  74.     // 检查初始状态(显示所有任务)
  75.     expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')).toHaveLength(3)
  76.    
  77.     // 应用过滤器
  78.     await wrapper.findAll('.filter-btn')[1].trigger('click') // 点击"To Do"按钮
  79.    
  80.     // 检查过滤后的任务
  81.     expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')).toHaveLength(1)
  82.     expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')[0].status).toBe('todo')
  83.   })
  84.   it('handles task form submission', async () => {
  85.     const taskData = {
  86.       title: 'New Task',
  87.       description: 'New Description',
  88.       status: 'todo'
  89.     }
  90.    
  91.     // 触发表单提交
  92.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
  93.    
  94.     // 检查是否调用了addTask
  95.     expect(mockUseTasks.addTask).toHaveBeenCalledWith(taskData)
  96.   })
  97.   it('handles task form submission for editing', async () => {
  98.     const taskData = {
  99.       id: 1,
  100.       title: 'Updated Task',
  101.       description: 'Updated Description',
  102.       status: 'inProgress'
  103.     }
  104.    
  105.     // 触发表单提交
  106.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
  107.    
  108.     // 检查是否调用了updateTask
  109.     expect(mockUseTasks.updateTask).toHaveBeenCalledWith(1, taskData)
  110.   })
  111.   it('handles task selection', async () => {
  112.     const taskId = 1
  113.    
  114.     // 触发任务选择
  115.     await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('select-task', taskId)
  116.    
  117.     // 检查是否设置了editingTask
  118.     expect(wrapper.vm.editingTask).not.toBeNull()
  119.   })
  120.   it('handles task completion', async () => {
  121.     const taskId = 1
  122.    
  123.     // 触发任务完成
  124.     await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('complete-task', taskId)
  125.    
  126.     // 检查是否调用了updateTask
  127.     expect(mockUseTasks.updateTask).toHaveBeenCalledWith(taskId, { status: 'completed' })
  128.   })
  129.   it('handles task deletion', async () => {
  130.     const taskId = 1
  131.    
  132.     // 设置当前编辑的任务
  133.     wrapper.vm.editingTask = { id: taskId }
  134.    
  135.     // 触发任务删除
  136.     await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('delete-task', taskId)
  137.    
  138.     // 检查是否调用了deleteTask
  139.     expect(mockUseTasks.deleteTask).toHaveBeenCalledWith(taskId)
  140.    
  141.     // 检查是否重置了editingTask
  142.     expect(wrapper.vm.editingTask).toBeNull()
  143.   })
  144.   it('resets editing task when form is cancelled', async () => {
  145.     // 设置当前编辑的任务
  146.     wrapper.vm.editingTask = { id: 1, title: 'Test Task' }
  147.    
  148.     // 触发表单取消
  149.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('cancel')
  150.    
  151.     // 检查是否重置了editingTask
  152.     expect(wrapper.vm.editingTask).toBeNull()
  153.   })
  154. })
复制代码

集成测试

除了单元测试,我们还需要编写一些集成测试,确保各个组件能够协同工作。

App.integration.test.js:
  1. import { describe, it, expect, beforeEach, vi } from 'vitest'
  2. import { mount } from '@vue/test-utils'
  3. import { createPinia, setActivePinia } from 'pinia'
  4. import App from '../App.vue'
  5. import { useTasks } from '../composables/useTasks'
  6. // 模拟localStorage
  7. const localStorageMock = {
  8.   getItem: vi.fn(),
  9.   setItem: vi.fn(),
  10.   clear: vi.fn()
  11. }
  12. global.localStorage = localStorageMock
  13. describe('App Integration Tests', () => {
  14.   let wrapper
  15.   beforeEach(() => {
  16.     // 创建新的pinia实例
  17.     setActivePinia(createPinia())
  18.    
  19.     // 重置localStorage模拟
  20.     localStorageMock.getItem.mockReturnValue(null)
  21.     localStorageMock.clear()
  22.    
  23.     // 挂载组件
  24.     wrapper = mount(App)
  25.   })
  26.   it('creates a new task and displays it in the list', async () => {
  27.     // 模拟TaskForm组件的submit事件
  28.     const taskData = {
  29.       title: 'Integration Test Task',
  30.       description: 'This is a test task',
  31.       status: 'todo'
  32.     }
  33.    
  34.     // 触发表单提交
  35.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
  36.    
  37.     // 检查任务是否被添加到列表中
  38.     const taskList = wrapper.findComponent({ name: 'TaskList' })
  39.     expect(taskList.props('tasks')).toHaveLength(1)
  40.     expect(taskList.props('tasks')[0].title).toBe(taskData.title)
  41.   })
  42.   it('filters tasks based on status', async () => {
  43.     // 添加多个任务
  44.     const tasks = [
  45.       { title: 'Todo Task', status: 'todo' },
  46.       { title: 'In Progress Task', status: 'inProgress' },
  47.       { title: 'Completed Task', status: 'completed' }
  48.     ]
  49.    
  50.     for (const task of tasks) {
  51.       await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', task)
  52.     }
  53.    
  54.     // 检查所有任务是否显示
  55.     let taskList = wrapper.findComponent({ name: 'TaskList' })
  56.     expect(taskList.props('tasks')).toHaveLength(3)
  57.    
  58.     // 应用过滤器
  59.     const filterButtons = wrapper.findAll('.filter-btn')
  60.     await filterButtons[1].trigger('click') // "To Do" filter
  61.    
  62.     // 检查过滤结果
  63.     taskList = wrapper.findComponent({ name: 'TaskList' })
  64.     expect(taskList.props('tasks')).toHaveLength(1)
  65.     expect(taskList.props('tasks')[0].status).toBe('todo')
  66.    
  67.     // 应用另一个过滤器
  68.     await filterButtons[2].trigger('click') // "In Progress" filter
  69.    
  70.     // 检查过滤结果
  71.     taskList = wrapper.findComponent({ name: 'TaskList' })
  72.     expect(taskList.props('tasks')).toHaveLength(1)
  73.     expect(taskList.props('tasks')[0].status).toBe('inProgress')
  74.   })
  75.   it('updates task statistics when tasks are added', async () => {
  76.     // 初始状态
  77.     let stats = wrapper.findAll('.stat')
  78.     expect(stats[0].find('.stat-value').text()).toBe('0') // Total
  79.     expect(stats[1].find('.stat-value').text()).toBe('0') // To Do
  80.     expect(stats[2].find('.stat-value').text()).toBe('0') // In Progress
  81.     expect(stats[3].find('.stat-value').text()).toBe('0') // Completed
  82.    
  83.     // 添加任务
  84.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
  85.       title: 'Todo Task',
  86.       status: 'todo'
  87.     })
  88.    
  89.     // 检查统计更新
  90.     stats = wrapper.findAll('.stat')
  91.     expect(stats[0].find('.stat-value').text()).toBe('1') // Total
  92.     expect(stats[1].find('.stat-value').text()).toBe('1') // To Do
  93.    
  94.     // 添加另一个任务
  95.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
  96.       title: 'In Progress Task',
  97.       status: 'inProgress'
  98.     })
  99.    
  100.     // 检查统计更新
  101.     stats = wrapper.findAll('.stat')
  102.     expect(stats[0].find('.stat-value').text()).toBe('2') // Total
  103.     expect(stats[1].find('.stat-value').text()).toBe('1') // To Do
  104.     expect(stats[2].find('.stat-value').text()).toBe('1') // In Progress
  105.   })
  106.   it('persists tasks to localStorage', async () => {
  107.     // 添加任务
  108.     await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
  109.       title: 'Persistent Task',
  110.       status: 'todo'
  111.     })
  112.    
  113.     // 检查是否调用了localStorage.setItem
  114.     expect(localStorageMock.setItem).toHaveBeenCalled()
  115.    
  116.     // 检查保存的数据
  117.     const savedData = JSON.parse(localStorageMock.setItem.mock.calls[0][1])
  118.     expect(savedData).toHaveLength(1)
  119.     expect(savedData[0].title).toBe('Persistent Task')
  120.   })
  121.   it('loads tasks from localStorage on initialization', () => {
  122.     // 设置localStorage中的初始数据
  123.     const initialTasks = [
  124.       { id: 1, title: 'Loaded Task', status: 'todo' }
  125.     ]
  126.     localStorageMock.getItem.mockReturnValue(JSON.stringify(initialTasks))
  127.    
  128.     // 重新挂载组件
  129.     wrapper = mount(App)
  130.    
  131.     // 检查任务是否被加载
  132.     const taskList = wrapper.findComponent({ name: 'TaskList' })
  133.     expect(taskList.props('tasks')).toHaveLength(1)
  134.     expect(taskList.props('tasks')[0].title).toBe('Loaded Task')
  135.   })
  136. })
复制代码

总结

通过本文的学习,我们深入了解了Vue3单元测试的各个方面,包括:

1. 基础测试技术:如何测试Vue3组件、组合式函数、事件和Props
2. 测试工具:Vitest、Vue Test Utils等工具的使用方法
3. 测试驱动开发(TDD):通过红-绿-重构循环开发高质量代码
4. 高级测试技巧:测试Pinia状态管理、Vue Router、异步组件等
5. 最佳实践:测试组织、覆盖率、隔离、模拟外部依赖等
6. 真实项目案例:通过任务管理应用综合应用各种测试技术

单元测试不仅能够提高代码质量,还能促进更好的设计和架构。通过测试驱动开发,我们可以更加自信地重构和扩展代码,减少生产环境中的bug。

在Vue3项目中,合理的测试策略应该包括:

• 对组件进行单元测试,验证其渲染、事件和交互
• 对组合式函数进行单元测试,确保逻辑正确性
• 对状态管理进行测试,确保数据流正确
• 编写集成测试,验证组件间的交互
• 设置适当的测试覆盖率目标,确保关键代码被测试覆盖

通过持续集成和自动化测试,我们可以在开发过程中及时发现问题,提高开发效率和代码质量。

希望本文能够帮助你在Vue3项目中更好地实践单元测试,构建更加健壮、可维护的应用程序。
「七転び八起き(ななころびやおき)」
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则