|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
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来快速搭建:
- # 创建Vue3项目
- npm create vite@latest vue3-testing-demo -- --template vue
- cd vue3-testing-demo
- # 安装依赖
- npm install
- # 安装测试相关依赖
- npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8
复制代码
配置Vitest
在项目根目录创建vitest.config.ts文件:
- import { defineConfig } from 'vitest/config'
- import vue from '@vitejs/plugin-vue'
- export default defineConfig({
- plugins: [vue()],
- test: {
- // 启用类似全局的Vue Test Utils API
- globals: true,
- // 模拟DOM环境
- environment: 'jsdom',
- // 支持Vue组件测试
- include: ['src/**/*.test.{js,ts,jsx,tsx}'],
- // 覆盖率配置
- coverage: {
- provider: 'v8',
- reporter: ['text', 'json', 'html'],
- exclude: [
- 'node_modules/',
- 'src/main.js',
- '**/*.d.ts',
- ],
- },
- },
- })
复制代码
配置package.json
在package.json中添加测试脚本:
- {
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "test": "vitest",
- "test:run": "vitest run",
- "test:coverage": "vitest run --coverage"
- }
- }
复制代码
Vue3组件测试基础
编写第一个组件测试
让我们创建一个简单的计数器组件并为其编写测试。
Counter.vue 组件:
- <template>
- <div class="counter">
- <h2>Counter: {{ count }}</h2>
- <button @click="increment">Increment</button>
- <button @click="decrement">Decrement</button>
- <button @click="reset">Reset</button>
- </div>
- </template>
- <script setup>
- import { ref } from 'vue'
- const count = ref(0)
- const increment = () => {
- count.value++
- }
- const decrement = () => {
- count.value--
- }
- const reset = () => {
- count.value = 0
- }
- </script>
复制代码
Counter.test.js 测试文件:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import Counter from '../components/Counter.vue'
- describe('Counter.vue', () => {
- it('renders initial count value', () => {
- const wrapper = mount(Counter)
- expect(wrapper.text()).toContain('Counter: 0')
- })
- it('increments count when increment button is clicked', async () => {
- const wrapper = mount(Counter)
- const incrementButton = wrapper.find('button:nth-child(2)')
-
- await incrementButton.trigger('click')
-
- expect(wrapper.text()).toContain('Counter: 1')
- })
- it('decrements count when decrement button is clicked', async () => {
- const wrapper = mount(Counter)
- const decrementButton = wrapper.find('button:nth-child(3)')
-
- await decrementButton.trigger('click')
-
- expect(wrapper.text()).toContain('Counter: -1')
- })
- it('resets count when reset button is clicked', async () => {
- const wrapper = mount(Counter)
- const incrementButton = wrapper.find('button:nth-child(2)')
- const resetButton = wrapper.find('button:nth-child(4)')
-
- // 先增加计数
- await incrementButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 1')
-
- // 然后重置
- await resetButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 0')
- })
- })
复制代码
测试组件Props
让我们创建一个接收props的组件并测试它。
Greeting.vue 组件:
- <template>
- <div>
- <h1>Hello, {{ name }}!</h1>
- <p v-if="showMessage">{{ message }}</p>
- </div>
- </template>
- <script setup>
- defineProps({
- name: {
- type: String,
- required: true
- },
- message: {
- type: String,
- default: 'Welcome to our app'
- },
- showMessage: {
- type: Boolean,
- default: true
- }
- })
- </script>
复制代码
Greeting.test.js 测试文件:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import Greeting from '../components/Greeting.vue'
- describe('Greeting.vue', () => {
- it('renders name prop correctly', () => {
- const name = 'John Doe'
- const wrapper = mount(Greeting, {
- props: { name }
- })
-
- expect(wrapper.text()).toContain(`Hello, ${name}!`)
- })
- it('renders default message when showMessage is true', () => {
- const wrapper = mount(Greeting, {
- props: { name: 'John' }
- })
-
- expect(wrapper.text()).toContain('Welcome to our app')
- })
- it('does not render message when showMessage is false', () => {
- const wrapper = mount(Greeting, {
- props: {
- name: 'John',
- showMessage: false
- }
- })
-
- expect(wrapper.find('p').exists()).toBe(false)
- })
- it('renders custom message when provided', () => {
- const customMessage = 'This is a custom message'
- const wrapper = mount(Greeting, {
- props: {
- name: 'John',
- message: customMessage
- }
- })
-
- expect(wrapper.text()).toContain(customMessage)
- })
- })
复制代码
测试组件事件
创建一个发出事件的组件并测试它。
LoginForm.vue 组件:
- <template>
- <form @submit.prevent="handleSubmit">
- <div>
- <label for="username">Username:</label>
- <input
- id="username"
- v-model="username"
- type="text"
- required
- />
- </div>
- <div>
- <label for="password">Password:</label>
- <input
- id="password"
- v-model="password"
- type="password"
- required
- />
- </div>
- <button type="submit">Login</button>
- </form>
- </template>
- <script setup>
- import { ref } from 'vue'
- const username = ref('')
- const password = ref('')
- const emit = defineEmits(['login'])
- const handleSubmit = () => {
- emit('login', {
- username: username.value,
- password: password.value
- })
- }
- </script>
复制代码
LoginForm.test.js 测试文件:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import LoginForm from '../components/LoginForm.vue'
- describe('LoginForm.vue', () => {
- it('emits login event with username and password when form is submitted', async () => {
- const wrapper = mount(LoginForm)
-
- // 设置表单数据
- const usernameInput = wrapper.find('#username')
- const passwordInput = wrapper.find('#password')
-
- await usernameInput.setValue('testuser')
- await passwordInput.setValue('password123')
-
- // 提交表单
- await wrapper.find('form').trigger('submit')
-
- // 验证事件是否被触发
- expect(wrapper.emitted()).toHaveProperty('login')
-
- // 验证事件参数
- const loginEvent = wrapper.emitted('login')
- expect(loginEvent).toHaveLength(1)
- expect(loginEvent[0]).toEqual([
- {
- username: 'testuser',
- password: 'password123'
- }
- ])
- })
- it('does not emit login event when form is not valid', async () => {
- const wrapper = mount(LoginForm)
-
- // 不设置必填字段,直接提交表单
- await wrapper.find('form').trigger('submit')
-
- // 验证事件未被触发
- expect(wrapper.emitted()).not.toHaveProperty('login')
- })
- })
复制代码
测试组合式函数(Composables)
在Vue3中,组合式函数(Composables)是复用逻辑的重要方式。测试组合式函数对于保证逻辑正确性至关重要。
创建一个可测试的组合式函数
useCounter.js:
- import { ref, computed } from 'vue'
- export function useCounter(initialValue = 0) {
- const count = ref(initialValue)
-
- const double = computed(() => count.value * 2)
-
- const increment = () => {
- count.value++
- }
-
- const decrement = () => {
- count.value--
- }
-
- const reset = () => {
- count.value = initialValue
- }
-
- return {
- count,
- double,
- increment,
- decrement,
- reset
- }
- }
复制代码
测试组合式函数
useCounter.test.js:
- import { describe, it, expect } from 'vitest'
- import { useCounter } from '../composables/useCounter'
- describe('useCounter', () => {
- it('starts with initial value', () => {
- const { count } = useCounter(5)
- expect(count.value).toBe(5)
- })
- it('defaults to 0 if no initial value is provided', () => {
- const { count } = useCounter()
- expect(count.value).toBe(0)
- })
- it('increments the count', () => {
- const { count, increment } = useCounter()
- increment()
- expect(count.value).toBe(1)
- })
- it('decrements the count', () => {
- const { count, decrement } = useCounter()
- decrement()
- expect(count.value).toBe(-1)
- })
- it('resets to initial value', () => {
- const { count, increment, reset } = useCounter(10)
- increment()
- increment()
- expect(count.value).toBe(12)
- reset()
- expect(count.value).toBe(10)
- })
- it('computes double value correctly', () => {
- const { count, double } = useCounter(3)
- expect(double.value).toBe(6)
-
- count.value = 5
- expect(double.value).toBe(10)
- })
- })
复制代码
测试异步组合式函数
useFetch.js:
- import { ref, onMounted } from 'vue'
- export function useFetch(url) {
- const data = ref(null)
- const error = ref(null)
- const loading = ref(false)
- const fetchData = async () => {
- loading.value = true
- error.value = null
-
- try {
- const response = await fetch(url)
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`)
- }
- data.value = await response.json()
- } catch (err) {
- error.value = err.message
- } finally {
- loading.value = false
- }
- }
- onMounted(() => {
- fetchData()
- })
- return {
- data,
- error,
- loading,
- refetch: fetchData
- }
- }
复制代码
useFetch.test.js:
- import { describe, it, expect, vi, beforeEach } from 'vitest'
- import { useFetch } from '../composables/useFetch'
- // 模拟全局fetch函数
- global.fetch = vi.fn()
- describe('useFetch', () => {
- beforeEach(() => {
- // 在每个测试前重置模拟
- fetch.mockClear()
- })
- it('starts with default values', () => {
- const { data, error, loading } = useFetch('https://api.example.com/data')
-
- expect(data.value).toBeNull()
- expect(error.value).toBeNull()
- expect(loading.value).toBe(false)
- })
- it('fetches data successfully', async () => {
- const mockData = { id: 1, name: 'Test Data' }
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockData
- })
- const { data, error, loading } = useFetch('https://api.example.com/data')
-
- // 由于onMounted是异步的,我们需要等待下一个tick
- await new Promise(resolve => setTimeout(resolve, 0))
-
- expect(loading.value).toBe(false)
- expect(error.value).toBeNull()
- expect(data.value).toEqual(mockData)
- })
- it('handles fetch error', async () => {
- const errorMessage = 'Network error'
- fetch.mockRejectedValueOnce(new Error(errorMessage))
- const { data, error, loading } = useFetch('https://api.example.com/data')
-
- await new Promise(resolve => setTimeout(resolve, 0))
-
- expect(loading.value).toBe(false)
- expect(error.value).toBe(errorMessage)
- expect(data.value).toBeNull()
- })
- it('handles HTTP error status', async () => {
- fetch.mockResolvedValueOnce({
- ok: false,
- status: 404,
- statusText: 'Not Found'
- })
- const { data, error, loading } = useFetch('https://api.example.com/data')
-
- await new Promise(resolve => setTimeout(resolve, 0))
-
- expect(loading.value).toBe(false)
- expect(error.value).toBe('HTTP error! status: 404')
- expect(data.value).toBeNull()
- })
- it('can refetch data', async () => {
- const mockData1 = { id: 1, name: 'First Data' }
- const mockData2 = { id: 2, name: 'Second Data' }
-
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockData1
- })
- const { data, refetch } = useFetch('https://api.example.com/data')
-
- await new Promise(resolve => setTimeout(resolve, 0))
- expect(data.value).toEqual(mockData1)
-
- // 准备第二次调用的模拟响应
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockData2
- })
-
- // 手动调用refetch
- await refetch()
-
- expect(data.value).toEqual(mockData2)
- })
- })
复制代码
测试驱动开发(TDD)在Vue3中的应用
什么是测试驱动开发
测试驱动开发(Test-Driven Development,TDD)是一种软件开发方法,其核心思想是在编写功能代码之前先编写测试代码。TDD遵循”红-绿-重构”的循环:
1. 红:编写一个失败的测试
2. 绿:编写最简单的代码使测试通过
3. 重构:优化代码,同时保持测试通过
TDD实践案例:待办事项应用
让我们通过一个简单的待办事项应用来实践TDD。
首先,我们创建一个Todo列表组件的测试文件,定义我们期望的行为。
TodoList.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import TodoList from '../components/TodoList.vue'
- describe('TodoList.vue', () => {
- it('renders an empty list when no todos are provided', () => {
- const wrapper = mount(TodoList, {
- props: {
- todos: []
- }
- })
-
- expect(wrapper.findAll('.todo-item')).toHaveLength(0)
- })
- it('renders a list of todos', () => {
- const todos = [
- { id: 1, text: 'Learn Vue 3', completed: false },
- { id: 2, text: 'Write tests', completed: true }
- ]
-
- const wrapper = mount(TodoList, {
- props: { todos }
- })
-
- const todoItems = wrapper.findAll('.todo-item')
- expect(todoItems).toHaveLength(2)
- expect(todoItems[0].text()).toContain('Learn Vue 3')
- expect(todoItems[1].text()).toContain('Write tests')
- })
- it('marks completed todos with a specific class', () => {
- const todos = [
- { id: 1, text: 'Learn Vue 3', completed: false },
- { id: 2, text: 'Write tests', completed: true }
- ]
-
- const wrapper = mount(TodoList, {
- props: { todos }
- })
-
- const todoItems = wrapper.findAll('.todo-item')
- expect(todoItems[0].classes()).not.toContain('completed')
- expect(todoItems[1].classes()).toContain('completed')
- })
- it('emits toggle event when a todo is clicked', async () => {
- const todos = [
- { id: 1, text: 'Learn Vue 3', completed: false }
- ]
-
- const wrapper = mount(TodoList, {
- props: { todos }
- })
-
- await wrapper.find('.todo-item').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('toggle')
- expect(wrapper.emitted('toggle')[0]).toEqual([1])
- })
- })
复制代码
运行测试,所有测试都会失败(红色),因为我们还没有创建TodoList组件。
现在,我们创建TodoList组件,实现最基本的功能使测试通过。
TodoList.vue:
- <template>
- <div class="todo-list">
- <div
- v-for="todo in todos"
- :key="todo.id"
- class="todo-item"
- :class="{ completed: todo.completed }"
- @click="$emit('toggle', todo.id)"
- >
- {{ todo.text }}
- </div>
- </div>
- </template>
- <script setup>
- defineProps({
- todos: {
- type: Array,
- required: true
- }
- })
- defineEmits(['toggle'])
- </script>
- <style scoped>
- .todo-item {
- padding: 8px;
- margin: 4px 0;
- background-color: #f9f9f9;
- cursor: pointer;
- }
- .completed {
- text-decoration: line-through;
- color: #888;
- }
- </style>
复制代码
再次运行测试,所有测试都应该通过(绿色)。
现在我们的测试通过了,但我们可以重构代码以提高可读性和性能。在这个简单的例子中,重构的空间不大,但在更复杂的应用中,这一步非常重要。
TDD实践案例:添加新功能
让我们继续使用TDD方法为TodoList添加新功能:删除待办事项。
在TodoList.test.js中添加新的测试:
- it('emits delete event when delete button is clicked', async () => {
- const todos = [
- { id: 1, text: 'Learn Vue 3', completed: false }
- ]
-
- const wrapper = mount(TodoList, {
- props: { todos }
- })
-
- await wrapper.find('.delete-button').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('delete')
- expect(wrapper.emitted('delete')[0]).toEqual([1])
- })
复制代码
运行测试,新测试会失败。
更新TodoList.vue组件:
- <template>
- <div class="todo-list">
- <div
- v-for="todo in todos"
- :key="todo.id"
- class="todo-item"
- :class="{ completed: todo.completed }"
- >
- <span @click="$emit('toggle', todo.id)">{{ todo.text }}</span>
- <button class="delete-button" @click="$emit('delete', todo.id)">×</button>
- </div>
- </div>
- </template>
- <script setup>
- defineProps({
- todos: {
- type: Array,
- required: true
- }
- })
- defineEmits(['toggle', 'delete'])
- </script>
- <style scoped>
- .todo-item {
- padding: 8px;
- margin: 4px 0;
- background-color: #f9f9f9;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .completed {
- text-decoration: line-through;
- color: #888;
- }
- .delete-button {
- background: none;
- border: none;
- color: #ff0000;
- cursor: pointer;
- font-size: 16px;
- padding: 0 4px;
- }
- </style>
复制代码
运行测试,所有测试应该通过。
我们可以优化一下模板结构,使其更清晰:
- <template>
- <div class="todo-list">
- <div
- v-for="todo in todos"
- :key="todo.id"
- class="todo-item"
- :class="{ completed: todo.completed }"
- >
- <span class="todo-text" @click="$emit('toggle', todo.id)">{{ todo.text }}</span>
- <button class="delete-button" @click.stop="$emit('delete', todo.id)">×</button>
- </div>
- </div>
- </template>
复制代码
注意我们添加了.stop修饰符来阻止事件冒泡,这样点击删除按钮不会触发toggle事件。
Vue3高级测试技巧
测试Pinia状态管理
Pinia是Vue3的官方状态管理库。测试Pinia存储对于确保应用状态管理的正确性非常重要。
stores/counter.js:
- import { defineStore } from 'pinia'
- export const useCounterStore = defineStore('counter', {
- state: () => ({
- count: 0
- }),
- getters: {
- double: (state) => state.count * 2
- },
- actions: {
- increment() {
- this.count++
- },
- decrement() {
- this.count--
- },
- reset() {
- this.count = 0
- },
- incrementBy(amount) {
- this.count += amount
- }
- }
- })
复制代码
stores/counter.test.js:
- import { describe, it, expect, beforeEach } from 'vitest'
- import { setActivePinia, createPinia } from 'pinia'
- import { useCounterStore } from './counter'
- describe('Counter Store', () => {
- beforeEach(() => {
- // 创建一个新的pinia实例,使每个测试独立
- setActivePinia(createPinia())
- })
- it('initializes with zero count', () => {
- const counter = useCounterStore()
- expect(counter.count).toBe(0)
- })
- it('increments count', () => {
- const counter = useCounterStore()
- counter.increment()
- expect(counter.count).toBe(1)
- })
- it('decrements count', () => {
- const counter = useCounterStore()
- counter.decrement()
- expect(counter.count).toBe(-1)
- })
- it('resets count', () => {
- const counter = useCounterStore()
- counter.increment()
- counter.increment()
- expect(counter.count).toBe(2)
- counter.reset()
- expect(counter.count).toBe(0)
- })
- it('increments by amount', () => {
- const counter = useCounterStore()
- counter.incrementBy(5)
- expect(counter.count).toBe(5)
- })
- it('computes double correctly', () => {
- const counter = useCounterStore()
- counter.increment()
- expect(counter.double).toBe(2)
- counter.incrementBy(4)
- expect(counter.double).toBe(10)
- })
- })
复制代码
CounterDisplay.vue:
- <template>
- <div class="counter-display">
- <h2>Counter: {{ counter.count }}</h2>
- <p>Double: {{ counter.double }}</p>
- <button @click="counter.increment">Increment</button>
- <button @click="counter.decrement">Decrement</button>
- <button @click="counter.reset">Reset</button>
- </div>
- </template>
- <script setup>
- import { useCounterStore } from '../stores/counter'
- const counter = useCounterStore()
- </script>
复制代码
CounterDisplay.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import { createPinia, setActivePinia } from 'pinia'
- import CounterDisplay from '../components/CounterDisplay.vue'
- describe('CounterDisplay.vue', () => {
- beforeEach(() => {
- // 创建一个新的pinia实例
- setActivePinia(createPinia())
- })
- it('renders counter values from store', () => {
- const wrapper = mount(CounterDisplay)
- expect(wrapper.text()).toContain('Counter: 0')
- expect(wrapper.text()).toContain('Double: 0')
- })
- it('calls store actions when buttons are clicked', async () => {
- const wrapper = mount(CounterDisplay)
-
- const incrementButton = wrapper.find('button:nth-child(3)')
- const decrementButton = wrapper.find('button:nth-child(4)')
- const resetButton = wrapper.find('button:nth-child(5)')
-
- await incrementButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 1')
- expect(wrapper.text()).toContain('Double: 2')
-
- await incrementButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 2')
- expect(wrapper.text()).toContain('Double: 4')
-
- await decrementButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 1')
- expect(wrapper.text()).toContain('Double: 2')
-
- await resetButton.trigger('click')
- expect(wrapper.text()).toContain('Counter: 0')
- expect(wrapper.text()).toContain('Double: 0')
- })
- })
复制代码
测试Vue Router
在Vue应用中,路由是核心功能之一。测试与路由相关的组件和功能非常重要。
UserProfile.vue:
- <template>
- <div class="user-profile">
- <div v-if="loading">Loading user data...</div>
- <div v-else-if="error" class="error">{{ error }}</div>
- <div v-else>
- <h2>{{ user.name }}</h2>
- <p>Email: {{ user.email }}</p>
- <button @click="goBack">Back to Users</button>
- </div>
- </div>
- </template>
- <script setup>
- import { ref, onMounted } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- const route = useRoute()
- const router = useRouter()
- const user = ref({})
- const loading = ref(true)
- const error = ref(null)
- const fetchUser = async () => {
- try {
- // 模拟API调用
- const userId = route.params.id
- const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
-
- if (!response.ok) {
- throw new Error('Failed to fetch user')
- }
-
- user.value = await response.json()
- } catch (err) {
- error.value = err.message
- } finally {
- loading.value = false
- }
- }
- const goBack = () => {
- router.push('/users')
- }
- onMounted(() => {
- fetchUser()
- })
- </script>
复制代码
UserProfile.test.js:
- import { describe, it, expect, vi, beforeEach } from 'vitest'
- import { mount } from '@vue/test-utils'
- import { createRouter, createWebHistory, Router } from 'vue-router'
- import UserProfile from '../components/UserProfile.vue'
- // 模拟fetch函数
- global.fetch = vi.fn()
- describe('UserProfile.vue', () => {
- let router
- beforeEach(() => {
- // 创建路由实例
- router = createRouter({
- history: createWebHistory(),
- routes: [
- { path: '/users/:id', component: UserProfile },
- { path: '/users', component: { template: '<div>Users List</div>' } }
- ]
- })
-
- // 重置fetch模拟
- fetch.mockClear()
- })
- it('renders loading state initially', () => {
- const wrapper = mount(UserProfile, {
- global: {
- plugins: [router]
- },
- props: {
- // 模拟路由参数
- id: '1'
- }
- })
-
- expect(wrapper.text()).toContain('Loading user data...')
- })
- it('renders user data after successful fetch', async () => {
- const mockUser = {
- id: 1,
- name: 'John Doe',
- email: 'john@example.com'
- }
-
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockUser
- })
-
- const wrapper = mount(UserProfile, {
- global: {
- plugins: [router]
- },
- props: {
- id: '1'
- }
- })
-
- // 等待异步操作完成
- await new Promise(resolve => setTimeout(resolve, 0))
- await wrapper.vm.$nextTick()
-
- expect(wrapper.text()).toContain(mockUser.name)
- expect(wrapper.text()).toContain(mockUser.email)
- })
- it('renders error message when fetch fails', async () => {
- const errorMessage = 'Failed to fetch user'
- fetch.mockRejectedValueOnce(new Error(errorMessage))
-
- const wrapper = mount(UserProfile, {
- global: {
- plugins: [router]
- },
- props: {
- id: '1'
- }
- })
-
- // 等待异步操作完成
- await new Promise(resolve => setTimeout(resolve, 0))
- await wrapper.vm.$nextTick()
-
- expect(wrapper.text()).toContain(errorMessage)
- })
- it('navigates to users page when back button is clicked', async () => {
- const mockUser = {
- id: 1,
- name: 'John Doe',
- email: 'john@example.com'
- }
-
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockUser
- })
-
- const wrapper = mount(UserProfile, {
- global: {
- plugins: [router]
- },
- props: {
- id: '1'
- }
- })
-
- // 等待数据加载完成
- await new Promise(resolve => setTimeout(resolve, 0))
- await wrapper.vm.$nextTick()
-
- // 模拟路由push方法
- const push = vi.spyOn(router, 'push')
-
- // 点击返回按钮
- await wrapper.find('button').trigger('click')
-
- // 验证路由是否被调用
- expect(push).toHaveBeenCalledWith('/users')
- })
- })
复制代码
测试异步组件
在Vue3中,异步组件是提高应用性能的重要手段。测试异步组件需要特殊处理。
AsyncComponent.vue:
- <template>
- <div class="async-component">
- <h2>Async Component</h2>
- <p>{{ message }}</p>
- <button @click="updateMessage">Update Message</button>
- </div>
- </template>
- <script setup>
- import { ref } from 'vue'
- const message = ref('Initial message')
- const updateMessage = () => {
- message.value = 'Updated message'
- }
- </script>
复制代码
App.vue(使用异步组件):
- <template>
- <div class="app">
- <h1>Async Component Demo</h1>
- <Suspense>
- <template #default>
- <AsyncComponent />
- </template>
- <template #fallback>
- <div>Loading component...</div>
- </template>
- </Suspense>
- </div>
- </template>
- <script setup>
- import { defineAsyncComponent } from 'vue'
- const AsyncComponent = defineAsyncComponent(() =>
- import('./components/AsyncComponent.vue')
- )
- </script>
复制代码
AsyncComponent.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import { defineAsyncComponent } from 'vue'
- // 直接导入组件进行测试
- import AsyncComponent from '../components/AsyncComponent.vue'
- describe('AsyncComponent.vue', () => {
- it('renders initial message', () => {
- const wrapper = mount(AsyncComponent)
- expect(wrapper.text()).toContain('Initial message')
- })
- it('updates message when button is clicked', async () => {
- const wrapper = mount(AsyncComponent)
-
- await wrapper.find('button').trigger('click')
-
- expect(wrapper.text()).toContain('Updated message')
- })
- })
- // 测试使用Suspense的异步组件
- describe('App with AsyncComponent', () => {
- it('shows fallback while loading', async () => {
- const AsyncComponent = defineAsyncComponent(() =>
- import('../components/AsyncComponent.vue')
- )
-
- const App = {
- template: `
- <div>
- <Suspense>
- <template #default>
- <AsyncComponent />
- </template>
- <template #fallback>
- <div>Loading component...</div>
- </template>
- </Suspense>
- </div>
- `,
- components: { AsyncComponent }
- }
-
- const wrapper = mount(App)
-
- // 初始状态下应该显示fallback内容
- expect(wrapper.text()).toContain('Loading component...')
-
- // 等待异步组件加载
- await new Promise(resolve => setTimeout(resolve, 10))
- await wrapper.vm.$nextTick()
-
- // 异步组件加载完成后应该显示组件内容
- expect(wrapper.text()).toContain('Async Component')
- expect(wrapper.text()).toContain('Initial message')
- })
- })
复制代码
Vue3测试最佳实践
1. 测试组织与命名
良好的测试组织与命名可以提高测试的可读性和可维护性。
- src/
- ├── components/
- │ ├── Button.vue
- │ ├── Button.test.js
- │ ├── UserProfile.vue
- │ └── UserProfile.test.js
- ├── composables/
- │ ├── useCounter.js
- │ ├── useCounter.test.js
- │ ├── useFetch.js
- │ └── useFetch.test.js
- ├── stores/
- │ ├── counter.js
- │ └── counter.test.js
- └── utils/
- ├── formatDate.js
- └── formatDate.test.js
复制代码
• 测试文件:与被测试文件同名,但添加.test.js或.spec.js后缀
• 测试套件:使用describe描述被测试的模块或组件
• 测试用例:使用it或test描述具体的行为,以”应该…“(should…)开头
- // 好的测试命名
- describe('Button.vue', () => {
- it('should render with correct text', () => {
- // ...
- })
-
- it('should emit click event when clicked', () => {
- // ...
- })
-
- it('should be disabled when disabled prop is true', () => {
- // ...
- })
- })
复制代码
2. 测试覆盖率
测试覆盖率是衡量测试完整性的重要指标。Vitest提供了内置的覆盖率支持。
在vitest.config.ts中配置覆盖率:
- import { defineConfig } from 'vitest/config'
- import vue from '@vitejs/plugin-vue'
- export default defineConfig({
- plugins: [vue()],
- test: {
- coverage: {
- provider: 'v8',
- reporter: ['text', 'json', 'html'],
- exclude: [
- 'node_modules/',
- 'src/main.js',
- '**/*.d.ts',
- ],
- },
- },
- })
复制代码
• 行覆盖率(Line Coverage):至少80%
• 分支覆盖率(Branch Coverage):至少80%
• 函数覆盖率(Function Coverage):至少80%
• 语句覆盖率(Statement Coverage):至少80%
3. 测试隔离
每个测试应该是独立的,不依赖于其他测试的状态或执行顺序。
- import { beforeEach, afterEach } from 'vitest'
- describe('Counter Store', () => {
- let counter
-
- beforeEach(() => {
- // 在每个测试前创建新的store实例
- setActivePinia(createPinia())
- counter = useCounterStore()
- })
-
- afterEach(() => {
- // 在每个测试后清理
- // 例如:清除定时器、重置全局状态等
- })
-
- it('should increment count', () => {
- counter.increment()
- expect(counter.count).toBe(1)
- })
-
- it('should decrement count', () => {
- counter.decrement()
- expect(counter.count).toBe(-1)
- })
- })
复制代码
4. 模拟外部依赖
在测试中,我们应该模拟外部依赖,如API调用、浏览器API等,以确保测试的稳定性和速度。
- import { vi } from 'vitest'
- // 模拟fetch函数
- global.fetch = vi.fn()
- describe('useFetch', () => {
- beforeEach(() => {
- fetch.mockClear()
- })
-
- it('should handle successful response', async () => {
- const mockData = { id: 1, name: 'Test' }
- fetch.mockResolvedValueOnce({
- ok: true,
- json: async () => mockData
- })
-
- const { data } = useFetch('https://api.example.com/data')
-
- await new Promise(resolve => setTimeout(resolve, 0))
-
- expect(data.value).toEqual(mockData)
- })
- })
复制代码- import { vi } from 'vitest'
- import { mount } from '@vue/test-utils'
- import MyComponent from '../components/MyComponent.vue'
- // 模拟整个模块
- vi.mock('../utils/api', () => ({
- fetchData: vi.fn(() => Promise.resolve({ data: 'mock data' }))
- }))
- describe('MyComponent', () => {
- it('should use mocked API', async () => {
- const wrapper = mount(MyComponent)
-
- // 等待异步操作完成
- await new Promise(resolve => setTimeout(resolve, 0))
- await wrapper.vm.$nextTick()
-
- expect(wrapper.text()).toContain('mock data')
- })
- })
复制代码
5. 测试用户交互
测试应该模拟真实用户的交互行为,而不是测试实现细节。
- import { render, fireEvent, screen } from '@testing-library/vue'
- import Button from '../components/Button.vue'
- test('should call onClick when button is clicked', async () => {
- const onClick = vi.fn()
-
- render(Button, {
- props: {
- onClick
- }
- })
-
- // 使用screen查询元素,更接近用户行为
- const button = screen.getByRole('button', { name: /click me/i })
-
- // 使用fireEvent模拟用户事件
- await fireEvent.click(button)
-
- expect(onClick).toHaveBeenCalled()
- })
复制代码
6. 测试可访问性
可访问性是现代Web应用的重要组成部分,应该在测试中考虑。
- import { mount } from '@vue/test-utils'
- import Button from '../components/Button.vue'
- describe('Button Accessibility', () => {
- it('should have correct aria attributes when disabled', () => {
- const wrapper = mount(Button, {
- props: {
- disabled: true
- }
- })
-
- const button = wrapper.find('button')
- expect(button.attributes('aria-disabled')).toBe('true')
- expect(button.attributes('disabled')).toBeDefined()
- })
-
- it('should have correct aria-label when provided', () => {
- const wrapper = mount(Button, {
- props: {
- ariaLabel: 'Close dialog'
- }
- })
-
- const button = wrapper.find('button')
- expect(button.attributes('aria-label')).toBe('Close dialog')
- })
- })
复制代码
7. 持续集成中的测试
将测试集成到CI/CD流程中,确保代码变更不会破坏现有功能。
- name: Tests
- on: [push, pull_request]
- jobs:
- test:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Set up Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '18'
- cache: 'npm'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Run unit tests
- run: npm run test:run
-
- - name: Run coverage
- run: npm run test:coverage
-
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v3
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- file: ./coverage/lcov.info
复制代码
真实项目案例:任务管理应用
让我们通过一个更复杂的真实项目案例,综合运用前面所学的Vue3测试技术。
项目概述
我们将构建一个任务管理应用,包含以下功能:
• 任务的增删改查
• 任务状态管理(待办、进行中、已完成)
• 任务分类和筛选
• 数据持久化(使用localStorage)
项目结构
- src/
- ├── components/
- │ ├── TaskItem.vue
- │ ├── TaskList.vue
- │ ├── TaskForm.vue
- │ └── TaskFilters.vue
- ├── composables/
- │ ├── useTasks.js
- │ └── useLocalStorage.js
- ├── stores/
- │ └── taskStore.js
- └── App.vue
复制代码
实现与测试
composables/useLocalStorage.js:
- import { ref, watchEffect } from 'vue'
- export function useLocalStorage(key, defaultValue) {
- // 从localStorage获取值,如果没有则使用默认值
- const storedValue = localStorage.getItem(key)
- const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue
-
- // 创建响应式引用
- const value = ref(initialValue)
-
- // 当值变化时,保存到localStorage
- watchEffect(() => {
- localStorage.setItem(key, JSON.stringify(value.value))
- })
-
- return value
- }
复制代码
composables/useLocalStorage.test.js:
- import { describe, it, expect, beforeEach } from 'vitest'
- import { useLocalStorage } from '../composables/useLocalStorage'
- describe('useLocalStorage', () => {
- beforeEach(() => {
- // 在每个测试前清除localStorage
- localStorage.clear()
- })
- it('uses default value when no stored value exists', () => {
- const value = useLocalStorage('test-key', 'default-value')
- expect(value.value).toBe('default-value')
- })
- it('uses stored value when it exists', () => {
- localStorage.setItem('test-key', JSON.stringify('stored-value'))
- const value = useLocalStorage('test-key', 'default-value')
- expect(value.value).toBe('stored-value')
- })
- it('saves to localStorage when value changes', () => {
- const value = useLocalStorage('test-key', 'initial-value')
-
- // 修改值
- value.value = 'new-value'
-
- // 检查localStorage是否更新
- expect(localStorage.getItem('test-key')).toBe(JSON.stringify('new-value'))
- })
- it('works with objects', () => {
- const defaultValue = { count: 0, name: 'test' }
- const value = useLocalStorage('test-object', defaultValue)
-
- expect(value.value).toEqual(defaultValue)
-
- // 修改对象
- value.value.count = 5
-
- // 检查localStorage是否更新
- expect(JSON.parse(localStorage.getItem('test-object'))).toEqual({ count: 5, name: 'test' })
- })
- it('works with arrays', () => {
- const defaultValue = ['item1', 'item2']
- const value = useLocalStorage('test-array', defaultValue)
-
- expect(value.value).toEqual(defaultValue)
-
- // 添加新项
- value.value.push('item3')
-
- // 检查localStorage是否更新
- expect(JSON.parse(localStorage.getItem('test-array'))).toEqual(['item1', 'item2', 'item3'])
- })
- })
复制代码
composables/useTasks.js:
- import { ref, computed } from 'vue'
- import { useLocalStorage } from './useLocalStorage'
- export function useTasks() {
- // 使用localStorage存储任务
- const tasks = useLocalStorage('tasks', [])
-
- // 获取所有任务
- const getAllTasks = computed(() => tasks.value)
-
- // 根据状态获取任务
- const getTasksByStatus = (status) => {
- return computed(() => tasks.value.filter(task => task.status === status))
- }
-
- // 添加新任务
- const addTask = (task) => {
- const newTask = {
- id: Date.now(),
- title: task.title,
- description: task.description || '',
- status: 'todo', // 默认状态为待办
- createdAt: new Date().toISOString(),
- updatedAt: new Date().toISOString()
- }
-
- tasks.value.push(newTask)
- return newTask
- }
-
- // 更新任务
- const updateTask = (id, updates) => {
- const index = tasks.value.findIndex(task => task.id === id)
- if (index !== -1) {
- tasks.value[index] = {
- ...tasks.value[index],
- ...updates,
- updatedAt: new Date().toISOString()
- }
- return tasks.value[index]
- }
- return null
- }
-
- // 删除任务
- const deleteTask = (id) => {
- const index = tasks.value.findIndex(task => task.id === id)
- if (index !== -1) {
- const deletedTask = tasks.value[index]
- tasks.value.splice(index, 1)
- return deletedTask
- }
- return null
- }
-
- // 获取任务统计
- const getTaskStats = computed(() => {
- const stats = {
- total: tasks.value.length,
- todo: 0,
- inProgress: 0,
- completed: 0
- }
-
- tasks.value.forEach(task => {
- if (task.status === 'todo') stats.todo++
- else if (task.status === 'inProgress') stats.inProgress++
- else if (task.status === 'completed') stats.completed++
- })
-
- return stats
- })
-
- return {
- getAllTasks,
- getTasksByStatus,
- addTask,
- updateTask,
- deleteTask,
- getTaskStats
- }
- }
复制代码
composables/useTasks.test.js:
- import { describe, it, expect, beforeEach } from 'vitest'
- import { useTasks } from '../composables/useTasks'
- describe('useTasks', () => {
- let tasks
-
- beforeEach(() => {
- // 在每个测试前创建新的tasks实例
- tasks = useTasks()
- })
-
- it('starts with empty tasks array', () => {
- expect(tasks.getAllTasks.value).toEqual([])
- })
-
- it('adds a new task', () => {
- const newTask = tasks.addTask({
- title: 'Test Task',
- description: 'Test Description'
- })
-
- expect(newTask.title).toBe('Test Task')
- expect(newTask.description).toBe('Test Description')
- expect(newTask.status).toBe('todo')
- expect(newTask.id).toBeDefined()
- expect(tasks.getAllTasks.value).toHaveLength(1)
- })
-
- it('updates a task', () => {
- const task = tasks.addTask({
- title: 'Original Title'
- })
-
- const updatedTask = tasks.updateTask(task.id, {
- title: 'Updated Title',
- status: 'inProgress'
- })
-
- expect(updatedTask.title).toBe('Updated Title')
- expect(updatedTask.status).toBe('inProgress')
- expect(updatedTask.updatedAt).not.toBe(task.updatedAt)
- })
-
- it('returns null when updating non-existent task', () => {
- const result = tasks.updateTask(999, { title: 'Updated' })
- expect(result).toBeNull()
- })
-
- it('deletes a task', () => {
- const task = tasks.addTask({
- title: 'Task to Delete'
- })
-
- const deletedTask = tasks.deleteTask(task.id)
-
- expect(deletedTask.id).toBe(task.id)
- expect(tasks.getAllTasks.value).toHaveLength(0)
- })
-
- it('returns null when deleting non-existent task', () => {
- const result = tasks.deleteTask(999)
- expect(result).toBeNull()
- })
-
- it('filters tasks by status', () => {
- tasks.addTask({ title: 'Todo Task 1' })
- tasks.addTask({ title: 'Todo Task 2' })
-
- const inProgressTask = tasks.addTask({ title: 'In Progress Task' })
- tasks.updateTask(inProgressTask.id, { status: 'inProgress' })
-
- const completedTask = tasks.addTask({ title: 'Completed Task' })
- tasks.updateTask(completedTask.id, { status: 'completed' })
-
- const todoTasks = tasks.getTasksByStatus('todo')
- const inProgressTasks = tasks.getTasksByStatus('inProgress')
- const completedTasks = tasks.getTasksByStatus('completed')
-
- expect(todoTasks.value).toHaveLength(2)
- expect(inProgressTasks.value).toHaveLength(1)
- expect(completedTasks.value).toHaveLength(1)
- })
-
- it('calculates task statistics correctly', () => {
- tasks.addTask({ title: 'Todo Task 1' })
- tasks.addTask({ title: 'Todo Task 2' })
-
- const inProgressTask = tasks.addTask({ title: 'In Progress Task' })
- tasks.updateTask(inProgressTask.id, { status: 'inProgress' })
-
- const completedTask = tasks.addTask({ title: 'Completed Task' })
- tasks.updateTask(completedTask.id, { status: 'completed' })
-
- const stats = tasks.getTaskStats
-
- expect(stats.value.total).toBe(4)
- expect(stats.value.todo).toBe(2)
- expect(stats.value.inProgress).toBe(1)
- expect(stats.value.completed).toBe(1)
- })
- })
复制代码
components/TaskItem.vue:
- <template>
- <div class="task-item" :class="task.status">
- <div class="task-content" @click="$emit('select', task.id)">
- <h3>{{ task.title }}</h3>
- <p v-if="task.description">{{ task.description }}</p>
- <div class="task-meta">
- <span class="task-date">Created: {{ formatDate(task.createdAt) }}</span>
- <span class="task-status">{{ statusLabel }}</span>
- </div>
- </div>
- <div class="task-actions">
- <button
- v-if="task.status !== 'completed'"
- class="btn-complete"
- @click="$emit('complete', task.id)"
- title="Mark as completed"
- >
- ✓
- </button>
- <button
- class="btn-delete"
- @click="$emit('delete', task.id)"
- title="Delete task"
- >
- ×
- </button>
- </div>
- </div>
- </template>
- <script setup>
- import { computed } from 'vue'
- const props = defineProps({
- task: {
- type: Object,
- required: true
- }
- })
- defineEmits(['select', 'complete', 'delete'])
- const statusLabel = computed(() => {
- switch (props.task.status) {
- case 'todo': return 'To Do'
- case 'inProgress': return 'In Progress'
- case 'completed': return 'Completed'
- default: return props.task.status
- }
- })
- const formatDate = (dateString) => {
- const date = new Date(dateString)
- return date.toLocaleDateString()
- }
- </script>
- <style scoped>
- .task-item {
- display: flex;
- justify-content: space-between;
- padding: 12px;
- margin-bottom: 8px;
- border-radius: 4px;
- background-color: #fff;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- transition: transform 0.2s;
- }
- .task-item:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- }
- .task-item.todo {
- border-left: 4px solid #3498db;
- }
- .task-item.inProgress {
- border-left: 4px solid #f39c12;
- }
- .task-item.completed {
- border-left: 4px solid #2ecc71;
- opacity: 0.7;
- }
- .task-content {
- flex: 1;
- cursor: pointer;
- }
- .task-content h3 {
- margin: 0 0 8px 0;
- font-size: 16px;
- }
- .task-content p {
- margin: 0 0 8px 0;
- color: #666;
- font-size: 14px;
- }
- .task-meta {
- display: flex;
- justify-content: space-between;
- font-size: 12px;
- color: #999;
- }
- .task-status {
- font-weight: bold;
- text-transform: uppercase;
- }
- .task-actions {
- display: flex;
- flex-direction: column;
- justify-content: center;
- margin-left: 10px;
- }
- .btn-complete, .btn-delete {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 16px;
- padding: 4px 8px;
- border-radius: 4px;
- margin: 2px 0;
- }
- .btn-complete {
- color: #2ecc71;
- }
- .btn-complete:hover {
- background-color: rgba(46, 204, 113, 0.1);
- }
- .btn-delete {
- color: #e74c3c;
- }
- .btn-delete:hover {
- background-color: rgba(231, 76, 60, 0.1);
- }
- </style>
复制代码
components/TaskItem.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import TaskItem from '../components/TaskItem.vue'
- describe('TaskItem.vue', () => {
- const mockTask = {
- id: 1,
- title: 'Test Task',
- description: 'Test Description',
- status: 'todo',
- createdAt: '2023-01-01T00:00:00.000Z',
- updatedAt: '2023-01-01T00:00:00.000Z'
- }
- it('renders task information', () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- expect(wrapper.text()).toContain(mockTask.title)
- expect(wrapper.text()).toContain(mockTask.description)
- expect(wrapper.text()).toContain('To Do')
- })
- it('applies correct status class', () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- expect(wrapper.classes()).toContain('todo')
- })
- it('emits select event when task content is clicked', async () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- await wrapper.find('.task-content').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('select')
- expect(wrapper.emitted('select')[0]).toEqual([mockTask.id])
- })
- it('emits complete event when complete button is clicked', async () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- await wrapper.find('.btn-complete').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('complete')
- expect(wrapper.emitted('complete')[0]).toEqual([mockTask.id])
- })
- it('emits delete event when delete button is clicked', async () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- await wrapper.find('.btn-delete').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('delete')
- expect(wrapper.emitted('delete')[0]).toEqual([mockTask.id])
- })
- it('does not show complete button for completed tasks', () => {
- const completedTask = { ...mockTask, status: 'completed' }
- const wrapper = mount(TaskItem, {
- props: {
- task: completedTask
- }
- })
-
- expect(wrapper.find('.btn-complete').exists()).toBe(false)
- })
- it('formats date correctly', () => {
- const wrapper = mount(TaskItem, {
- props: {
- task: mockTask
- }
- })
-
- // 检查日期是否被格式化(不包含ISO格式的时间部分)
- expect(wrapper.text()).not.toContain('T00:00:00.000Z')
- })
- it('applies different styles for different statuses', () => {
- const todoWrapper = mount(TaskItem, {
- props: {
- task: { ...mockTask, status: 'todo' }
- }
- })
-
- const inProgressWrapper = mount(TaskItem, {
- props: {
- task: { ...mockTask, status: 'inProgress' }
- }
- })
-
- const completedWrapper = mount(TaskItem, {
- props: {
- task: { ...mockTask, status: 'completed' }
- }
- })
-
- expect(todoWrapper.classes()).toContain('todo')
- expect(inProgressWrapper.classes()).toContain('inProgress')
- expect(completedWrapper.classes()).toContain('completed')
-
- // 检查状态文本
- expect(todoWrapper.text()).toContain('To Do')
- expect(inProgressWrapper.text()).toContain('In Progress')
- expect(completedWrapper.text()).toContain('Completed')
- })
- })
复制代码
components/TaskList.vue:
- <template>
- <div class="task-list">
- <div v-if="tasks.length === 0" class="empty-state">
- <p>No tasks found. Create a new task to get started!</p>
- </div>
- <div v-else>
- <TaskItem
- v-for="task in tasks"
- :key="task.id"
- :task="task"
- @select="onSelectTask"
- @complete="onCompleteTask"
- @delete="onDeleteTask"
- />
- </div>
- </div>
- </template>
- <script setup>
- import { defineProps, defineEmits } from 'vue'
- import TaskItem from './TaskItem.vue'
- const props = defineProps({
- tasks: {
- type: Array,
- required: true
- }
- })
- const emit = defineEmits(['select-task', 'complete-task', 'delete-task'])
- const onSelectTask = (taskId) => {
- emit('select-task', taskId)
- }
- const onCompleteTask = (taskId) => {
- emit('complete-task', taskId)
- }
- const onDeleteTask = (taskId) => {
- emit('delete-task', taskId)
- }
- </script>
- <style scoped>
- .task-list {
- margin-top: 20px;
- }
- .empty-state {
- text-align: center;
- padding: 40px 20px;
- background-color: #f9f9f9;
- border-radius: 8px;
- color: #666;
- }
- </style>
复制代码
components/TaskList.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import TaskList from '../components/TaskList.vue'
- import TaskItem from '../components/TaskItem.vue'
- // 模拟TaskItem组件
- vi.mock('../components/TaskItem.vue', () => ({
- default: {
- name: 'TaskItem',
- props: ['task'],
- emits: ['select', 'complete', 'delete'],
- template: '<div class="mock-task-item">{{ task.title }}</div>'
- }
- }))
- describe('TaskList.vue', () => {
- const mockTasks = [
- { id: 1, title: 'Task 1', status: 'todo' },
- { id: 2, title: 'Task 2', status: 'inProgress' },
- { id: 3, title: 'Task 3', status: 'completed' }
- ]
- it('renders empty state when no tasks are provided', () => {
- const wrapper = mount(TaskList, {
- props: {
- tasks: []
- }
- })
-
- expect(wrapper.find('.empty-state').exists()).toBe(true)
- expect(wrapper.text()).toContain('No tasks found')
- })
- it('renders task items when tasks are provided', () => {
- const wrapper = mount(TaskList, {
- props: {
- tasks: mockTasks
- }
- })
-
- expect(wrapper.find('.empty-state').exists()).toBe(false)
- expect(wrapper.findAll('.mock-task-item')).toHaveLength(mockTasks.length)
- })
- it('emits select-task event when TaskItem emits select', async () => {
- const wrapper = mount(TaskList, {
- props: {
- tasks: mockTasks
- }
- })
-
- // 找到第一个TaskItem并触发select事件
- await wrapper.findAllComponents(TaskItem)[0].$emit('select', 1)
-
- expect(wrapper.emitted()).toHaveProperty('select-task')
- expect(wrapper.emitted('select-task')[0]).toEqual([1])
- })
- it('emits complete-task event when TaskItem emits complete', async () => {
- const wrapper = mount(TaskList, {
- props: {
- tasks: mockTasks
- }
- })
-
- // 找到第一个TaskItem并触发complete事件
- await wrapper.findAllComponents(TaskItem)[0].$emit('complete', 1)
-
- expect(wrapper.emitted()).toHaveProperty('complete-task')
- expect(wrapper.emitted('complete-task')[0]).toEqual([1])
- })
- it('emits delete-task event when TaskItem emits delete', async () => {
- const wrapper = mount(TaskList, {
- props: {
- tasks: mockTasks
- }
- })
-
- // 找到第一个TaskItem并触发delete事件
- await wrapper.findAllComponents(TaskItem)[0].$emit('delete', 1)
-
- expect(wrapper.emitted()).toHaveProperty('delete-task')
- expect(wrapper.emitted('delete-task')[0]).toEqual([1])
- })
- })
复制代码
components/TaskForm.vue:
- <template>
- <div class="task-form">
- <h2>{{ isEditing ? 'Edit Task' : 'Create New Task' }}</h2>
- <form @submit.prevent="handleSubmit">
- <div class="form-group">
- <label for="title">Title *</label>
- <input
- id="title"
- v-model="formData.title"
- type="text"
- required
- placeholder="Enter task title"
- />
- </div>
-
- <div class="form-group">
- <label for="description">Description</label>
- <textarea
- id="description"
- v-model="formData.description"
- placeholder="Enter task description"
- rows="3"
- ></textarea>
- </div>
-
- <div class="form-group">
- <label for="status">Status</label>
- <select id="status" v-model="formData.status">
- <option value="todo">To Do</option>
- <option value="inProgress">In Progress</option>
- <option value="completed">Completed</option>
- </select>
- </div>
-
- <div class="form-actions">
- <button type="submit" class="btn-primary">
- {{ isEditing ? 'Update Task' : 'Create Task' }}
- </button>
- <button type="button" class="btn-secondary" @click="handleCancel">
- Cancel
- </button>
- </div>
- </form>
- </div>
- </template>
- <script setup>
- import { ref, computed, watch } from 'vue'
- const props = defineProps({
- task: {
- type: Object,
- default: null
- }
- })
- const emit = defineEmits(['submit', 'cancel'])
- // 表单数据
- const formData = ref({
- title: '',
- description: '',
- status: 'todo'
- })
- // 计算是否处于编辑模式
- const isEditing = computed(() => props.task !== null)
- // 监听props.task变化,更新表单数据
- watch(() => props.task, (newTask) => {
- if (newTask) {
- formData.value = {
- title: newTask.title,
- description: newTask.description,
- status: newTask.status
- }
- } else {
- resetForm()
- }
- }, { immediate: true })
- // 重置表单
- const resetForm = () => {
- formData.value = {
- title: '',
- description: '',
- status: 'todo'
- }
- }
- // 处理表单提交
- const handleSubmit = () => {
- emit('submit', {
- ...formData.value,
- id: props.task ? props.task.id : undefined
- })
- }
- // 处理取消
- const handleCancel = () => {
- resetForm()
- emit('cancel')
- }
- </script>
- <style scoped>
- .task-form {
- background-color: #fff;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- margin-bottom: 20px;
- }
- h2 {
- margin-top: 0;
- margin-bottom: 20px;
- color: #333;
- }
- .form-group {
- margin-bottom: 15px;
- }
- label {
- display: block;
- margin-bottom: 5px;
- font-weight: bold;
- color: #555;
- }
- input[type="text"],
- textarea,
- select {
- width: 100%;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 14px;
- }
- textarea {
- resize: vertical;
- }
- .form-actions {
- display: flex;
- gap: 10px;
- margin-top: 20px;
- }
- .btn-primary,
- .btn-secondary {
- padding: 10px 20px;
- border: none;
- border-radius: 4px;
- font-size: 14px;
- cursor: pointer;
- transition: background-color 0.2s;
- }
- .btn-primary {
- background-color: #3498db;
- color: white;
- }
- .btn-primary:hover {
- background-color: #2980b9;
- }
- .btn-secondary {
- background-color: #ecf0f1;
- color: #333;
- }
- .btn-secondary:hover {
- background-color: #bdc3c7;
- }
- </style>
复制代码
components/TaskForm.test.js:
- import { describe, it, expect } from 'vitest'
- import { mount } from '@vue/test-utils'
- import TaskForm from '../components/TaskForm.vue'
- describe('TaskForm.vue', () => {
- it('renders create form by default', () => {
- const wrapper = mount(TaskForm)
-
- expect(wrapper.find('h2').text()).toBe('Create New Task')
- expect(wrapper.find('button[type="submit"]').text()).toBe('Create Task')
- })
- it('renders edit form when task prop is provided', () => {
- const task = {
- id: 1,
- title: 'Edit Task',
- description: 'Edit Description',
- status: 'inProgress'
- }
-
- const wrapper = mount(TaskForm, {
- props: {
- task
- }
- })
-
- expect(wrapper.find('h2').text()).toBe('Edit Task')
- expect(wrapper.find('button[type="submit"]').text()).toBe('Update Task')
- expect(wrapper.find('#title').element.value).toBe(task.title)
- expect(wrapper.find('#description').element.value).toBe(task.description)
- expect(wrapper.find('#status').element.value).toBe(task.status)
- })
- it('emits submit event with form data when submitted', async () => {
- const wrapper = mount(TaskForm)
-
- // 填写表单
- await wrapper.find('#title').setValue('New Task')
- await wrapper.find('#description').setValue('New Description')
- await wrapper.find('#status').setValue('inProgress')
-
- // 提交表单
- await wrapper.find('form').trigger('submit')
-
- expect(wrapper.emitted()).toHaveProperty('submit')
- expect(wrapper.emitted('submit')[0][0]).toEqual({
- title: 'New Task',
- description: 'New Description',
- status: 'inProgress',
- id: undefined
- })
- })
- it('includes task id when editing', async () => {
- const task = {
- id: 1,
- title: 'Edit Task',
- description: 'Edit Description',
- status: 'inProgress'
- }
-
- const wrapper = mount(TaskForm, {
- props: {
- task
- }
- })
-
- // 修改表单
- await wrapper.find('#title').setValue('Updated Task')
-
- // 提交表单
- await wrapper.find('form').trigger('submit')
-
- expect(wrapper.emitted()).toHaveProperty('submit')
- expect(wrapper.emitted('submit')[0][0]).toEqual({
- title: 'Updated Task',
- description: 'Edit Description',
- status: 'inProgress',
- id: 1
- })
- })
- it('emits cancel event when cancel button is clicked', async () => {
- const wrapper = mount(TaskForm)
-
- await wrapper.find('.btn-secondary').trigger('click')
-
- expect(wrapper.emitted()).toHaveProperty('cancel')
- })
- it('resets form when cancel button is clicked', async () => {
- const wrapper = mount(TaskForm)
-
- // 填写表单
- await wrapper.find('#title').setValue('New Task')
- await wrapper.find('#description').setValue('New Description')
-
- // 点击取消
- await wrapper.find('.btn-secondary').trigger('click')
-
- // 检查表单是否重置
- expect(wrapper.find('#title').element.value).toBe('')
- expect(wrapper.find('#description').element.value).toBe('')
- expect(wrapper.find('#status').element.value).toBe('todo')
- })
- it('updates form when task prop changes', async () => {
- const wrapper = mount(TaskForm)
-
- // 初始状态
- expect(wrapper.find('#title').element.value).toBe('')
- expect(wrapper.find('#description').element.value).toBe('')
-
- // 更新task prop
- const newTask = {
- id: 2,
- title: 'New Task',
- description: 'New Description',
- status: 'completed'
- }
-
- await wrapper.setProps({ task: newTask })
-
- // 检查表单是否更新
- expect(wrapper.find('#title').element.value).toBe(newTask.title)
- expect(wrapper.find('#description').element.value).toBe(newTask.description)
- expect(wrapper.find('#status').element.value).toBe(newTask.status)
- })
- it('requires title field', async () => {
- const wrapper = mount(TaskForm)
-
- // 尝试提交空表单
- await wrapper.find('form').trigger('submit.prevent')
-
- // 表单验证应该阻止提交
- expect(wrapper.emitted()).not.toHaveProperty('submit')
- })
- })
复制代码
App.vue:
- <template>
- <div class="app">
- <header class="app-header">
- <h1>Task Manager</h1>
- <div class="task-stats">
- <div class="stat">
- <span class="stat-value">{{ stats.total }}</span>
- <span class="stat-label">Total</span>
- </div>
- <div class="stat">
- <span class="stat-value">{{ stats.todo }}</span>
- <span class="stat-label">To Do</span>
- </div>
- <div class="stat">
- <span class="stat-value">{{ stats.inProgress }}</span>
- <span class="stat-label">In Progress</span>
- </div>
- <div class="stat">
- <span class="stat-value">{{ stats.completed }}</span>
- <span class="stat-label">Completed</span>
- </div>
- </div>
- </header>
-
- <main class="app-main">
- <TaskForm
- :task="editingTask"
- @submit="handleTaskSubmit"
- @cancel="handleFormCancel"
- />
-
- <div class="filter-section">
- <h3>Filter Tasks</h3>
- <div class="filter-buttons">
- <button
- class="filter-btn"
- :class="{ active: currentFilter === 'all' }"
- @click="currentFilter = 'all'"
- >
- All
- </button>
- <button
- class="filter-btn"
- :class="{ active: currentFilter === 'todo' }"
- @click="currentFilter = 'todo'"
- >
- To Do
- </button>
- <button
- class="filter-btn"
- :class="{ active: currentFilter === 'inProgress' }"
- @click="currentFilter = 'inProgress'"
- >
- In Progress
- </button>
- <button
- class="filter-btn"
- :class="{ active: currentFilter === 'completed' }"
- @click="currentFilter = 'completed'"
- >
- Completed
- </button>
- </div>
- </div>
-
- <TaskList
- :tasks="filteredTasks"
- @select-task="handleSelectTask"
- @complete-task="handleCompleteTask"
- @delete-task="handleDeleteTask"
- />
- </main>
- </div>
- </template>
- <script setup>
- import { ref, computed, onMounted } from 'vue'
- import { useTasks } from './composables/useTasks'
- import TaskForm from './components/TaskForm.vue'
- import TaskList from './components/TaskList.vue'
- const { getAllTasks, addTask, updateTask, deleteTask, getTaskStats } = useTasks()
- // 任务统计
- const stats = computed(() => getTaskStats.value)
- // 当前过滤器
- const currentFilter = ref('all')
- // 当前编辑的任务
- const editingTask = ref(null)
- // 根据过滤器获取任务
- const filteredTasks = computed(() => {
- if (currentFilter.value === 'all') {
- return getAllTasks.value
- }
- return getAllTasks.value.filter(task => task.status === currentFilter.value)
- })
- // 处理任务表单提交
- const handleTaskSubmit = (taskData) => {
- if (taskData.id) {
- // 更新现有任务
- updateTask(taskData.id, taskData)
- } else {
- // 创建新任务
- addTask(taskData)
- }
-
- // 重置编辑状态
- editingTask.value = null
- }
- // 处理表单取消
- const handleFormCancel = () => {
- editingTask.value = null
- }
- // 处理任务选择
- const handleSelectTask = (taskId) => {
- const task = getAllTasks.value.find(t => t.id === taskId)
- if (task) {
- editingTask.value = task
- }
- }
- // 处理任务完成
- const handleCompleteTask = (taskId) => {
- updateTask(taskId, { status: 'completed' })
- }
- // 处理任务删除
- const handleDeleteTask = (taskId) => {
- deleteTask(taskId)
-
- // 如果删除的是当前编辑的任务,重置编辑状态
- if (editingTask.value && editingTask.value.id === taskId) {
- editingTask.value = null
- }
- }
- </script>
- <style>
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- }
- body {
- font-family: 'Arial', sans-serif;
- line-height: 1.6;
- color: #333;
- background-color: #f5f7fa;
- }
- .app {
- max-width: 800px;
- margin: 0 auto;
- padding: 20px;
- }
- .app-header {
- margin-bottom: 30px;
- text-align: center;
- }
- .app-header h1 {
- color: #2c3e50;
- margin-bottom: 20px;
- }
- .task-stats {
- display: flex;
- justify-content: space-around;
- background-color: #fff;
- padding: 15px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- }
- .stat {
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .stat-value {
- font-size: 24px;
- font-weight: bold;
- color: #3498db;
- }
- .stat-label {
- font-size: 14px;
- color: #7f8c8d;
- }
- .app-main {
- background-color: #fff;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
- }
- .filter-section {
- margin: 20px 0;
- }
- .filter-section h3 {
- margin-bottom: 10px;
- color: #2c3e50;
- }
- .filter-buttons {
- display: flex;
- gap: 10px;
- }
- .filter-btn {
- padding: 8px 16px;
- border: 1px solid #ddd;
- background-color: #fff;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.2s;
- }
- .filter-btn:hover {
- background-color: #f1f2f6;
- }
- .filter-btn.active {
- background-color: #3498db;
- color: white;
- border-color: #3498db;
- }
- </style>
复制代码
App.test.js:
- import { describe, it, expect, vi, beforeEach } from 'vitest'
- import { mount } from '@vue/test-utils'
- import { createPinia, setActivePinia } from 'pinia'
- import App from '../App.vue'
- // 模拟组件
- vi.mock('../components/TaskForm.vue', () => ({
- default: {
- name: 'TaskForm',
- props: ['task'],
- emits: ['submit', 'cancel'],
- template: '<div class="mock-task-form"></div>'
- }
- }))
- vi.mock('../components/TaskList.vue', () => ({
- default: {
- name: 'TaskList',
- props: ['tasks'],
- emits: ['select-task', 'complete-task', 'delete-task'],
- template: '<div class="mock-task-list"></div>'
- }
- }))
- // 模拟组合式函数
- const mockUseTasks = {
- getAllTasks: { value: [] },
- addTask: vi.fn(),
- updateTask: vi.fn(),
- deleteTask: vi.fn(),
- getTaskStats: { value: { total: 0, todo: 0, inProgress: 0, completed: 0 } }
- }
- vi.mock('../composables/useTasks', () => ({
- useTasks: () => mockUseTasks
- }))
- describe('App.vue', () => {
- let wrapper
- beforeEach(() => {
- // 创建新的pinia实例
- setActivePinia(createPinia())
-
- // 重置模拟函数
- vi.clearAllMocks()
-
- // 重置任务数据
- mockUseTasks.getAllTasks.value = []
- mockUseTasks.getTaskStats.value = { total: 0, todo: 0, inProgress: 0, completed: 0 }
-
- // 挂载组件
- wrapper = mount(App)
- })
- it('renders correctly', () => {
- expect(wrapper.find('h1').text()).toBe('Task Manager')
- expect(wrapper.findComponent({ name: 'TaskForm' }).exists()).toBe(true)
- expect(wrapper.findComponent({ name: 'TaskList' }).exists()).toBe(true)
- })
- it('displays task statistics', () => {
- mockUseTasks.getTaskStats.value = { total: 5, todo: 2, inProgress: 2, completed: 1 }
-
- wrapper = mount(App)
-
- const stats = wrapper.findAll('.stat')
- expect(stats[0].find('.stat-value').text()).toBe('5')
- expect(stats[1].find('.stat-value').text()).toBe('2')
- expect(stats[2].find('.stat-value').text()).toBe('2')
- expect(stats[3].find('.stat-value').text()).toBe('1')
- })
- it('filters tasks correctly', async () => {
- mockUseTasks.getAllTasks.value = [
- { id: 1, title: 'Task 1', status: 'todo' },
- { id: 2, title: 'Task 2', status: 'inProgress' },
- { id: 3, title: 'Task 3', status: 'completed' }
- ]
-
- wrapper = mount(App)
-
- // 检查初始状态(显示所有任务)
- expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')).toHaveLength(3)
-
- // 应用过滤器
- await wrapper.findAll('.filter-btn')[1].trigger('click') // 点击"To Do"按钮
-
- // 检查过滤后的任务
- expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')).toHaveLength(1)
- expect(wrapper.findComponent({ name: 'TaskList' }).props('tasks')[0].status).toBe('todo')
- })
- it('handles task form submission', async () => {
- const taskData = {
- title: 'New Task',
- description: 'New Description',
- status: 'todo'
- }
-
- // 触发表单提交
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
-
- // 检查是否调用了addTask
- expect(mockUseTasks.addTask).toHaveBeenCalledWith(taskData)
- })
- it('handles task form submission for editing', async () => {
- const taskData = {
- id: 1,
- title: 'Updated Task',
- description: 'Updated Description',
- status: 'inProgress'
- }
-
- // 触发表单提交
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
-
- // 检查是否调用了updateTask
- expect(mockUseTasks.updateTask).toHaveBeenCalledWith(1, taskData)
- })
- it('handles task selection', async () => {
- const taskId = 1
-
- // 触发任务选择
- await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('select-task', taskId)
-
- // 检查是否设置了editingTask
- expect(wrapper.vm.editingTask).not.toBeNull()
- })
- it('handles task completion', async () => {
- const taskId = 1
-
- // 触发任务完成
- await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('complete-task', taskId)
-
- // 检查是否调用了updateTask
- expect(mockUseTasks.updateTask).toHaveBeenCalledWith(taskId, { status: 'completed' })
- })
- it('handles task deletion', async () => {
- const taskId = 1
-
- // 设置当前编辑的任务
- wrapper.vm.editingTask = { id: taskId }
-
- // 触发任务删除
- await wrapper.findComponent({ name: 'TaskList' }).vm.$emit('delete-task', taskId)
-
- // 检查是否调用了deleteTask
- expect(mockUseTasks.deleteTask).toHaveBeenCalledWith(taskId)
-
- // 检查是否重置了editingTask
- expect(wrapper.vm.editingTask).toBeNull()
- })
- it('resets editing task when form is cancelled', async () => {
- // 设置当前编辑的任务
- wrapper.vm.editingTask = { id: 1, title: 'Test Task' }
-
- // 触发表单取消
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('cancel')
-
- // 检查是否重置了editingTask
- expect(wrapper.vm.editingTask).toBeNull()
- })
- })
复制代码
集成测试
除了单元测试,我们还需要编写一些集成测试,确保各个组件能够协同工作。
App.integration.test.js:
- import { describe, it, expect, beforeEach, vi } from 'vitest'
- import { mount } from '@vue/test-utils'
- import { createPinia, setActivePinia } from 'pinia'
- import App from '../App.vue'
- import { useTasks } from '../composables/useTasks'
- // 模拟localStorage
- const localStorageMock = {
- getItem: vi.fn(),
- setItem: vi.fn(),
- clear: vi.fn()
- }
- global.localStorage = localStorageMock
- describe('App Integration Tests', () => {
- let wrapper
- beforeEach(() => {
- // 创建新的pinia实例
- setActivePinia(createPinia())
-
- // 重置localStorage模拟
- localStorageMock.getItem.mockReturnValue(null)
- localStorageMock.clear()
-
- // 挂载组件
- wrapper = mount(App)
- })
- it('creates a new task and displays it in the list', async () => {
- // 模拟TaskForm组件的submit事件
- const taskData = {
- title: 'Integration Test Task',
- description: 'This is a test task',
- status: 'todo'
- }
-
- // 触发表单提交
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', taskData)
-
- // 检查任务是否被添加到列表中
- const taskList = wrapper.findComponent({ name: 'TaskList' })
- expect(taskList.props('tasks')).toHaveLength(1)
- expect(taskList.props('tasks')[0].title).toBe(taskData.title)
- })
- it('filters tasks based on status', async () => {
- // 添加多个任务
- const tasks = [
- { title: 'Todo Task', status: 'todo' },
- { title: 'In Progress Task', status: 'inProgress' },
- { title: 'Completed Task', status: 'completed' }
- ]
-
- for (const task of tasks) {
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', task)
- }
-
- // 检查所有任务是否显示
- let taskList = wrapper.findComponent({ name: 'TaskList' })
- expect(taskList.props('tasks')).toHaveLength(3)
-
- // 应用过滤器
- const filterButtons = wrapper.findAll('.filter-btn')
- await filterButtons[1].trigger('click') // "To Do" filter
-
- // 检查过滤结果
- taskList = wrapper.findComponent({ name: 'TaskList' })
- expect(taskList.props('tasks')).toHaveLength(1)
- expect(taskList.props('tasks')[0].status).toBe('todo')
-
- // 应用另一个过滤器
- await filterButtons[2].trigger('click') // "In Progress" filter
-
- // 检查过滤结果
- taskList = wrapper.findComponent({ name: 'TaskList' })
- expect(taskList.props('tasks')).toHaveLength(1)
- expect(taskList.props('tasks')[0].status).toBe('inProgress')
- })
- it('updates task statistics when tasks are added', async () => {
- // 初始状态
- let stats = wrapper.findAll('.stat')
- expect(stats[0].find('.stat-value').text()).toBe('0') // Total
- expect(stats[1].find('.stat-value').text()).toBe('0') // To Do
- expect(stats[2].find('.stat-value').text()).toBe('0') // In Progress
- expect(stats[3].find('.stat-value').text()).toBe('0') // Completed
-
- // 添加任务
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
- title: 'Todo Task',
- status: 'todo'
- })
-
- // 检查统计更新
- stats = wrapper.findAll('.stat')
- expect(stats[0].find('.stat-value').text()).toBe('1') // Total
- expect(stats[1].find('.stat-value').text()).toBe('1') // To Do
-
- // 添加另一个任务
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
- title: 'In Progress Task',
- status: 'inProgress'
- })
-
- // 检查统计更新
- stats = wrapper.findAll('.stat')
- expect(stats[0].find('.stat-value').text()).toBe('2') // Total
- expect(stats[1].find('.stat-value').text()).toBe('1') // To Do
- expect(stats[2].find('.stat-value').text()).toBe('1') // In Progress
- })
- it('persists tasks to localStorage', async () => {
- // 添加任务
- await wrapper.findComponent({ name: 'TaskForm' }).vm.$emit('submit', {
- title: 'Persistent Task',
- status: 'todo'
- })
-
- // 检查是否调用了localStorage.setItem
- expect(localStorageMock.setItem).toHaveBeenCalled()
-
- // 检查保存的数据
- const savedData = JSON.parse(localStorageMock.setItem.mock.calls[0][1])
- expect(savedData).toHaveLength(1)
- expect(savedData[0].title).toBe('Persistent Task')
- })
- it('loads tasks from localStorage on initialization', () => {
- // 设置localStorage中的初始数据
- const initialTasks = [
- { id: 1, title: 'Loaded Task', status: 'todo' }
- ]
- localStorageMock.getItem.mockReturnValue(JSON.stringify(initialTasks))
-
- // 重新挂载组件
- wrapper = mount(App)
-
- // 检查任务是否被加载
- const taskList = wrapper.findComponent({ name: 'TaskList' })
- expect(taskList.props('tasks')).toHaveLength(1)
- expect(taskList.props('tasks')[0].title).toBe('Loaded Task')
- })
- })
复制代码
总结
通过本文的学习,我们深入了解了Vue3单元测试的各个方面,包括:
1. 基础测试技术:如何测试Vue3组件、组合式函数、事件和Props
2. 测试工具:Vitest、Vue Test Utils等工具的使用方法
3. 测试驱动开发(TDD):通过红-绿-重构循环开发高质量代码
4. 高级测试技巧:测试Pinia状态管理、Vue Router、异步组件等
5. 最佳实践:测试组织、覆盖率、隔离、模拟外部依赖等
6. 真实项目案例:通过任务管理应用综合应用各种测试技术
单元测试不仅能够提高代码质量,还能促进更好的设计和架构。通过测试驱动开发,我们可以更加自信地重构和扩展代码,减少生产环境中的bug。
在Vue3项目中,合理的测试策略应该包括:
• 对组件进行单元测试,验证其渲染、事件和交互
• 对组合式函数进行单元测试,确保逻辑正确性
• 对状态管理进行测试,确保数据流正确
• 编写集成测试,验证组件间的交互
• 设置适当的测试覆盖率目标,确保关键代码被测试覆盖
通过持续集成和自动化测试,我们可以在开发过程中及时发现问题,提高开发效率和代码质量。
希望本文能够帮助你在Vue3项目中更好地实践单元测试,构建更加健壮、可维护的应用程序。 |
|