第12章 测试策略

第32节 单元测试

12.32.1 Vitest环境配置

什么是Vitest?

Vitest是一个基于Vite的单元测试框架,它具有以下特点:

  • 快速的启动速度和热更新
  • 与Vite配置无缝集成
  • 支持TypeScript
  • 支持ESM
  • 支持组件测试
  • 支持快照测试
  • 支持覆盖率报告

安装Vitest

首先,我们需要安装Vitest及其相关依赖:

npm install vitest @vue/test-utils jsdom -D

其中:

  • vitest:测试框架
  • @vue/test-utils:Vue组件测试工具库
  • jsdom:浏览器环境模拟库

配置Vitest

在项目根目录下创建vitest.config.js文件:

// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    // 测试环境
    environment: 'jsdom',
    // 开启全局API
    globals: true,
    // 测试文件匹配模式
    include: ['**/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    // 覆盖率报告配置
    coverage: {
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: ['src/main.js', 'src/App.vue', 'src/router/**', 'src/store/**']
    }
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  }
})

配置package.json

package.json中添加测试脚本:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

其中:

  • test:启动Vitest开发模式,支持热更新
  • test:run:运行所有测试并退出
  • test:coverage:运行所有测试并生成覆盖率报告

创建测试目录结构

通常,我们会在src目录下创建__tests__目录来存放测试文件,或者在组件文件旁边创建*.test.js*.spec.js文件。

src/
├── components/
│   ├── HelloWorld.vue
│   └── HelloWorld.test.js  # 组件测试文件
├── utils/
│   ├── math.js
│   └── math.test.js        # 工具函数测试文件
└── __tests__/              # 集中测试目录
    └── app.test.js

12.32.2 组件测试基础

测试工具函数

我们先从简单的工具函数测试开始,了解Vitest的基本用法。

创建一个简单的工具函数:

// src/utils/math.js

/**
 * 加法函数
 * @param {number} a - 第一个数
 * @param {number} b - 第二个数
 * @returns {number} - 和
 */
export function add(a, b) {
  return a + b
}

/**
 * 减法函数
 * @param {number} a - 被减数
 * @param {number} b - 减数
 * @returns {number} - 差
 */
export function subtract(a, b) {
  return a - b
}

/**
 * 乘法函数
 * @param {number} a - 第一个数
 * @param {number} b - 第二个数
 * @returns {number} - 积
 */
export function multiply(a, b) {
  return a * b
}

/**
 * 除法函数
 * @param {number} a - 被除数
 * @param {number} b - 除数
 * @returns {number} - 商
 */
export function divide(a, b) {
  if (b === 0) {
    throw new Error('除数不能为0')
  }
  return a / b
}

创建测试文件:

// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add, subtract, multiply, divide } from './math'

describe('math.js', () => {
  describe('add', () => {
    it('should return the sum of two numbers', () => {
      expect(add(1, 2)).toBe(3)
      expect(add(-1, 1)).toBe(0)
      expect(add(0, 0)).toBe(0)
    })
    
    it('should handle floating point numbers', () => {
      expect(add(0.1, 0.2)).toBeCloseTo(0.3)
    })
  })
  
  describe('subtract', () => {
    it('should return the difference of two numbers', () => {
      expect(subtract(5, 3)).toBe(2)
      expect(subtract(3, 5)).toBe(-2)
      expect(subtract(0, 0)).toBe(0)
    })
  })
  
  describe('multiply', () => {
    it('should return the product of two numbers', () => {
      expect(multiply(2, 3)).toBe(6)
      expect(multiply(-2, 3)).toBe(-6)
      expect(multiply(0, 5)).toBe(0)
    })
  })
  
  describe('divide', () => {
    it('should return the quotient of two numbers', () => {
      expect(divide(6, 3)).toBe(2)
      expect(divide(5, 2)).toBe(2.5)
    })
    
    it('should throw an error when dividing by zero', () => {
      expect(() => divide(1, 0)).toThrow('除数不能为0')
    })
  })
})

运行测试:

npm run test

测试组件基础

创建一个简单的组件:

<!-- src/components/Counter.vue -->
<template>
  <div class="counter">
    <h3>Counter</h3>
    <div class="counter-value">{{ count }}</div>
    <div class="counter-buttons">
      <button @click="increment">+</button>
      <button @click="decrement">-</button>
      <button @click="reset">Reset</button>
    </div>
  </div>
</template>

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

const props = defineProps({
  initialCount: {
    type: Number,
    default: 0
  }
})

const emit = defineEmits(['update:count'])

const count = ref(props.initialCount)

const increment = () => {
  count.value++
  emit('update:count', count.value)
}

const decrement = () => {
  count.value--
  emit('update:count', count.value)
}

const reset = () => {
  count.value = props.initialCount
  emit('update:count', count.value)
}
</script>

<style scoped>
.counter {
  text-align: center;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 4px;
}

.counter-value {
  font-size: 48px;
  font-weight: bold;
  margin: 20px 0;
  color: #409eff;
}

.counter-buttons {
  display: flex;
  gap: 10px;
  justify-content: center;
}

button {
  padding: 10px 20px;
  font-size: 18px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #fff;
  cursor: pointer;
  transition: all 0.3s;
}

button:hover {
  background-color: #f5f7fa;
  border-color: #c6e2ff;
}
</style>

创建组件测试文件:

// src/components/Counter.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  let wrapper
  
  // 在每个测试前创建新的组件实例
  beforeEach(() => {
    wrapper = mount(Counter)
  })
  
  it('should render the component', () => {
    expect(wrapper.exists()).toBe(true)
    expect(wrapper.find('h3').text()).toBe('Counter')
  })
  
  it('should display the initial count', () => {
    expect(wrapper.find('.counter-value').text()).toBe('0')
  })
  
  it('should display the custom initial count', () => {
    const customWrapper = mount(Counter, {
      props: {
        initialCount: 5
      }
    })
    expect(customWrapper.find('.counter-value').text()).toBe('5')
  })
  
  it('should increment the count when clicking the + button', async () => {
    await wrapper.find('button:nth-child(1)').trigger('click')
    expect(wrapper.find('.counter-value').text()).toBe('1')
  })
  
  it('should decrement the count when clicking the - button', async () => {
    await wrapper.find('button:nth-child(2)').trigger('click')
    expect(wrapper.find('.counter-value').text()).toBe('-1')
  })
  
  it('should reset the count when clicking the Reset button', async () => {
    // 先增加计数
    await wrapper.find('button:nth-child(1)').trigger('click')
    await wrapper.find('button:nth-child(1)').trigger('click')
    expect(wrapper.find('.counter-value').text()).toBe('2')
    
    // 重置计数
    await wrapper.find('button:nth-child(3)').trigger('click')
    expect(wrapper.find('.counter-value').text()).toBe('0')
  })
  
  it('should emit update:count event when count changes', async () => {
    await wrapper.find('button:nth-child(1)').trigger('click')
    expect(wrapper.emitted('update:count')).toBeTruthy()
    expect(wrapper.emitted('update:count')[0]).toEqual([1])
  })
})

12.32.3 Props、Slots、Events测试

测试Props

创建一个带有Props的组件:

<!-- src/components/MessageCard.vue -->
<template>
  <div class="message-card" :class="type">
    <div class="message-header">
      <h4>{{ title }}</h4>
    </div>
    <div class="message-content">
      <slot></slot>
    </div>
    <div class="message-footer" v-if="showFooter">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  type: {
    type: String,
    default: 'info',
    validator: (value) => {
      return ['info', 'success', 'warning', 'error'].includes(value)
    }
  },
  showFooter: {
    type: Boolean,
    default: false
  }
})
</script>

<style scoped>
.message-card {
  padding: 16px;
  border-radius: 4px;
  margin: 8px 0;
  border: 1px solid #eee;
}

.message-card.info {
  border-left: 4px solid #409eff;
  background-color: #ecf5ff;
}

.message-card.success {
  border-left: 4px solid #67c23a;
  background-color: #f0f9eb;
}

.message-card.warning {
  border-left: 4px solid #e6a23c;
  background-color: #fdf6ec;
}

.message-card.error {
  border-left: 4px solid #f56c6c;
  background-color: #fef0f0;
}

.message-header {
  margin-bottom: 8px;
}

.message-header h4 {
  margin: 0;
  font-size: 16px;
}

.message-content {
  margin-bottom: 8px;
}

.message-footer {
  font-size: 14px;
  color: #909399;
  text-align: right;
}
</style>

测试Props:

// src/components/MessageCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MessageCard from './MessageCard.vue'

describe('MessageCard.vue', () => {
  it('should render with default props', () => {
    const wrapper = mount(MessageCard, {
      props: {
        title: 'Test Title'
      },
      slots: {
        default: 'Test Content'
      }
    })
    
    expect(wrapper.exists()).toBe(true)
    expect(wrapper.classes()).toContain('info')
    expect(wrapper.find('.message-header h4').text()).toBe('Test Title')
    expect(wrapper.find('.message-content').text()).toBe('Test Content')
    expect(wrapper.find('.message-footer').exists()).toBe(false)
  })
  
  it('should render with custom props', () => {
    const wrapper = mount(MessageCard, {
      props: {
        title: 'Success Message',
        type: 'success',
        showFooter: true
      },
      slots: {
        default: 'This is a success message',
        footer: 'Footer Content'
      }
    })
    
    expect(wrapper.classes()).toContain('success')
    expect(wrapper.find('.message-footer').exists()).toBe(true)
    expect(wrapper.find('.message-footer').text()).toBe('Footer Content')
  })
  
  it('should apply correct styles based on type', () => {
    const types = ['info', 'success', 'warning', 'error']
    
    types.forEach(type => {
      const wrapper = mount(MessageCard, {
        props: {
          title: `${type} Message`,
          type
        }
      })
      expect(wrapper.classes()).toContain(type)
    })
  })
})

测试Slots

创建一个带有多种Slots的组件:

<!-- src/components/Layout.vue -->
<template>
  <div class="layout">
    <header class="layout-header">
      <slot name="header">Default Header</slot>
    </header>
    <main class="layout-main">
      <slot></slot>
    </main>
    <aside class="layout-aside">
      <slot name="aside">Default Sidebar</slot>
    </aside>
    <footer class="layout-footer">
      <slot name="footer">Default Footer</slot>
    </footer>
  </div>
</template>

<script setup>
</script>

<style scoped>
.layout {
  display: grid;
  grid-template-columns: 200px 1fr;
  grid-template-rows: auto 1fr auto;
  grid-template-areas: 
    "header header"
    "aside main"
    "footer footer";
  gap: 16px;
  height: 100vh;
  padding: 16px;
}

.layout-header {
  grid-area: header;
  background-color: #409eff;
  color: white;
  padding: 16px;
  border-radius: 4px;
}

.layout-main {
  grid-area: main;
  background-color: #f5f7fa;
  padding: 16px;
  border-radius: 4px;
}

.layout-aside {
  grid-area: aside;
  background-color: #e6a23c;
  color: white;
  padding: 16px;
  border-radius: 4px;
}

.layout-footer {
  grid-area: footer;
  background-color: #67c23a;
  color: white;
  padding: 16px;
  border-radius: 4px;
  text-align: center;
}
</style>

测试Slots:

// src/components/Layout.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'

describe('Layout.vue', () => {
  it('should render default slots', () => {
    const wrapper = mount(Layout)
    
    expect(wrapper.find('.layout-header').text()).toBe('Default Header')
    expect(wrapper.find('.layout-aside').text()).toBe('Default Sidebar')
    expect(wrapper.find('.layout-footer').text()).toBe('Default Footer')
    expect(wrapper.find('.layout-main').text()).toBe('')
  })
  
  it('should render custom slots', () => {
    const wrapper = mount(Layout, {
      slots: {
        header: '<h1>Custom Header</h1>',
        default: '<div>Main Content</div>',
        aside: '<nav>Custom Sidebar</nav>',
        footer: '<p>Custom Footer</p>'
      }
    })
    
    expect(wrapper.find('.layout-header').html()).toContain('<h1>Custom Header</h1>')
    expect(wrapper.find('.layout-main').html()).toContain('<div>Main Content</div>')
    expect(wrapper.find('.layout-aside').html()).toContain('<nav>Custom Sidebar</nav>')
    expect(wrapper.find('.layout-footer').html()).toContain('<p>Custom Footer</p>')
  })
  
  it('should render multiple elements in a slot', () => {
    const wrapper = mount(Layout, {
      slots: {
        default: `
          <div>First Element</div>
          <div>Second Element</div>
          <div>Third Element</div>
        `
      }
    })
    
    expect(wrapper.find('.layout-main').findAll('div')).toHaveLength(3)
  })
})

测试Events

创建一个带有事件的组件:

<!-- src/components/TodoItem.vue -->
<template>
  <div class="todo-item" :class="{ completed: todo.completed }">
    <input 
      type="checkbox" 
      v-model="todo.completed" 
      @change="handleChange"
    >
    <span class="todo-text">{{ todo.text }}</span>
    <button class="todo-delete" @click="handleDelete">Delete</button>
  </div>
</template>

<script setup>
const props = defineProps({
  todo: {
    type: Object,
    required: true,
    validator: (value) => {
      return value.hasOwnProperty('id') && 
             value.hasOwnProperty('text') && 
             value.hasOwnProperty('completed')
    }
  }
})

const emit = defineEmits(['update:todo', 'delete'])

const handleChange = () => {
  emit('update:todo', { ...props.todo })
}

const handleDelete = () => {
  emit('delete', props.todo.id)
}
</script>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #909399;
}

.todo-text {
  flex: 1;
  margin: 0 10px;
}

.todo-delete {
  background-color: #f56c6c;
  color: white;
  border: none;
  padding: 5px 10px;
  border-radius: 4px;
  cursor: pointer;
}
</style>

测试Events:

// src/components/TodoItem.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from './TodoItem.vue'

describe('TodoItem.vue', () => {
  const mockTodo = {
    id: 1,
    text: 'Test Todo',
    completed: false
  }
  
  it('should render todo item', () => {
    const wrapper = mount(TodoItem, {
      props: {
        todo: mockTodo
      }
    })
    
    expect(wrapper.exists()).toBe(true)
    expect(wrapper.find('.todo-text').text()).toBe('Test Todo')
    expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false)
  })
  
  it('should emit update:todo event when checkbox is toggled', async () => {
    const wrapper = mount(TodoItem, {
      props: {
        todo: mockTodo
      }
    })
    
    await wrapper.find('input[type="checkbox"]').trigger('change')
    
    expect(wrapper.emitted('update:todo')).toBeTruthy()
    expect(wrapper.emitted('update:todo').length).toBe(1)
    expect(wrapper.emitted('update:todo')[0][0].completed).toBe(true)
  })
  
  it('should emit delete event when delete button is clicked', async () => {
    const wrapper = mount(TodoItem, {
      props: {
        todo: mockTodo
      }
    })
    
    await wrapper.find('.todo-delete').trigger('click')
    
    expect(wrapper.emitted('delete')).toBeTruthy()
    expect(wrapper.emitted('delete').length).toBe(1)
    expect(wrapper.emitted('delete')[0][0]).toBe(1)
  })
  
  it('should display completed style when todo is completed', () => {
    const completedTodo = {
      ...mockTodo,
      completed: true
    }
    
    const wrapper = mount(TodoItem, {
      props: {
        todo: completedTodo
      }
    })
    
    expect(wrapper.classes()).toContain('completed')
    expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
  })
})

最佳实践

  1. 测试命名规范

    • 测试文件命名:*.test.js*.spec.js
    • 测试套件命名:使用describe(&#39;组件名称&#39;, () =&gt; {})
    • 测试用例命名:使用it(&#39;描述测试行为&#39;, () =&gt; {})
  2. 测试覆盖范围

    • 测试组件的各种状态和行为
    • 测试边界情况
    • 测试错误处理
    • 测试Props、Slots、Events
  3. 测试隔离

    • 每个测试用例应该独立运行
    • 使用beforeEachafterEach来设置和清理测试环境
  4. 模拟依赖

    • 使用vi.mock()来模拟外部依赖
    • 避免测试依赖外部服务
  5. 断言简洁

    • 每个测试用例应该只有一个断言点
    • 使用清晰的断言信息
  6. 快照测试

    • 对于UI组件,可以使用快照测试来检测意外的UI变化
    • 但要谨慎使用,避免快照文件过大

小结

本节我们学习了单元测试的基本概念和实践,包括:

  • Vitest环境配置
  • 测试工具函数
  • 测试组件基础
  • 测试Props
  • 测试Slots
  • 测试Events

单元测试是确保代码质量的重要手段,通过编写单元测试,我们可以:

  • 提前发现和修复bug
  • 提高代码的可维护性
  • 方便重构
  • 提供文档

在实际项目中,我们应该根据项目的具体情况来决定测试策略,平衡测试覆盖率和开发效率。

思考与练习

  1. 安装Vitest并配置测试环境。
  2. 编写一个工具函数并为其编写测试用例。
  3. 编写一个组件并测试其Props、Slots和Events。
  4. 运行测试并查看覆盖率报告。
« 上一篇 30-api-interface-management 下一篇 » 32-integration-e2e-testing