组件测试策略:测试 Props、事件和插槽

前言:为什么组件测试要关注 Props、事件和插槽?

组件的本质:输入与输出

html 复制代码
<template>
  <!-- Props 是输入 -->
  <ChildComponent 
    :user="userData"
    :showDetails="true"
    @update="handleUpdate"
    @delete="handleDelete"
  >
    <!-- 插槽也是输入 -->
    <template #header>
      <h1>标题</h1>
    </template>
  </ChildComponent>
</template>

组件的测试的关注点

  1. Props 输入是否正确渲染
  2. 事件输出是否正确触发
  3. 插槽内容是否正确分发

为什么这三个要素最重要?

要素 作用 测试重点
Props 父组件向子组件传递数据 组件是否能正确接收并渲染数据
事件 子组件向父组件通信 交互是否能正确触发事件
插槽 父组件控制子组件内容 内容是否能被正确分发和渲染

测试 Props - 验证输入

Props 测试的核心

给子组件输入数据,看它能不能正确显示。

最简单的 Props 测试

我们先来看一个简单的 Props 组件:

html 复制代码
<!-- Greeting.vue -->
<template>
  <h1>Hello, {{ name }}!</h1>
</template>

<script setup>
defineProps(['name'])
</script>

其对应的测试:

javascript 复制代码
// Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'

describe('Greeting', () => {
  it('显示传入的名字', () => {
    // 传入 name="张三"
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    
    // 验证是否显示了"Hello, 张三!"
    expect(wrapper.text()).toBe('Hello, 张三!')
  })
})

测试多种 Props 值

我们再来看一个有多种 Props 值的组件:

html 复制代码
<!-- Button.vue -->
<template>
  <button 
    :class="['btn', `btn-${type}`]"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
defineProps({
  text: String,
  type: { type: String, default: 'primary' },
  disabled: { type: Boolean, default: false }
})
</script>

其对应的测试:

javascript 复制代码
// Button.spec.js
import { mount } from '@vue/test-utils'
import Button from './Button.vue'

describe('Button', () => {
  it('显示正确的文字', () => {
    const wrapper = mount(Button, {
      props: { text: '点击我' }
    })
    expect(wrapper.text()).toBe('点击我')
  })
  
  it('默认类型是 primary', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮' }
    })
    expect(wrapper.classes()).toContain('btn-primary')
  })
  
  it('可以设置 type 为 danger', () => {
    const wrapper = mount(Button, {
      props: { text: '删除', type: 'danger' }
    })
    expect(wrapper.classes()).toContain('btn-danger')
  })
  
  it('disabled 属性可以禁用按钮', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮', disabled: true }
    })
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
})

测试复杂 Props 对象

我们再来看一个复杂 Props 对象的组件:

html 复制代码
<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <span v-if="showBadge" class="badge">{{ user.status }}</span>
  </div>
</template>

<script setup>
defineProps({
  user: {
    type: Object,
    required: true
  },
  showBadge: Boolean
})
</script>

其对应的测试:

javascript 复制代码
// UserCard.spec.js
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

const mockUser = {
  name: '张三',
  email: 'zhangsan@example.com',
  status: 'active'
}

describe('UserCard', () => {
  it('显示用户信息', () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('showBadge 为 true 时显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: true 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(true)
    expect(wrapper.find('.badge').text()).toBe('active')
  })
  
  it('showBadge 为 false 时不显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: false 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(false)
  })
})

测试 Props 变化后的响应

当 Props 发生变化时,如何测试呢?

html 复制代码
<!-- ProgressBar.vue -->
<template>
  <div class="progress-bar">
    <div class="progress-fill" :style="{ width: percent + '%' }"></div>
  </div>
</template>

<script setup>
defineProps(['percent'])
</script>

其对应的测试:

javascript 复制代码
// ProgressBar.spec.js
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar.vue'

describe('ProgressBar', () => {
  it('根据 percent 设置宽度', () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 50%')
  })
  
  it('percent 变化时宽度也跟着变', async () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    // 修改 props
    await wrapper.setProps({ percent: 80 })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 80%')
  })
})

测试事件 - 验证输出

事件测试的核心

触发组件的事件交互,看它能不能正确发出事件。

基础事件测试

我们来看一个基础事件组件:

html 复制代码
<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['click'])

const handleClick = () => {
  emit('click', 'button clicked')
}
</script>

其对应的测试:

javascript 复制代码
// SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from './SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 click 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    // 模拟点击
    await wrapper.trigger('click')
    
    // 验证事件被触发
    expect(wrapper.emitted('click')).toBeTruthy()
    
    // 验证事件参数
    expect(wrapper.emitted('click')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeFalsy()
  })
})

测试多个事件

我们再来看一个有多个事件的组件:

html 复制代码
<!-- SearchInput.vue -->
<template>
  <div>
    <input 
      v-model="value"
      @input="handleInput"
      @keyup.enter="handleEnter"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <button @click="handleClear" v-if="value">清除</button>
  </div>
</template>

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

const value = ref('')
const emit = defineEmits(['input', 'search', 'focus', 'blur', 'clear'])

const handleInput = (e) => {
  value.value = e.target.value
  emit('input', value.value)
}

const handleEnter = () => {
  emit('search', value.value)
}

const handleFocus = () => emit('focus')
const handleBlur = () => emit('blur')
const handleClear = () => {
  value.value = ''
  emit('clear')
}
</script>

其对应的测试:

javascript 复制代码
// SearchInput.spec.js
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'

describe('SearchInput', () => {
  it('输入时触发 input 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    
    expect(wrapper.emitted('input')).toBeTruthy()
    expect(wrapper.emitted('input')[0]).toEqual(['vue'])
  })
  
  it('按回车时触发 search 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    await input.trigger('keyup.enter')
    
    expect(wrapper.emitted('search')).toBeTruthy()
    expect(wrapper.emitted('search')[0]).toEqual(['vue'])
  })
  
  it('获得焦点时触发 focus 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('focus')
    
    expect(wrapper.emitted('focus')).toBeTruthy()
  })
  
  it('失去焦点时触发 blur 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('blur')
    
    expect(wrapper.emitted('blur')).toBeTruthy()
  })
  
  it('点击清除按钮触发 clear 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('test')
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('clear')).toBeTruthy()
  })
})

测试事件顺序

当值发生变化时,如果确定事件的测试顺序呢?

html 复制代码
<!-- Counter.vue -->
<template>
  <div>
    <span>{{ count }}</span>
    <button @click="increment">+1</button>
  </div>
</template>

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

const count = ref(0)
const emit = defineEmits(['change'])

const increment = () => {
  const oldValue = count.value
  count.value++
  emit('change', oldValue, count.value)
}
</script>

其对应的测试:

javascript 复制代码
// Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('点击时触发 change 事件,参数是旧值和新值', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')[0]).toEqual([0, 1])
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')[1]).toEqual([1, 2])
  })
})

测试插槽 - 验证内容分发

插槽测试的核心

给组件填充内容,看它能不能正确显示。

测试默认插槽

我们先来看一个默认插槽的组件:

html 复制代码
<!-- Card.vue -->
<template>
  <div class="card">
    <div class="content">
      <slot></slot>  <!-- 默认插槽 -->
    </div>
  </div>
</template>

其对应的测试:

javascript 复制代码
// Card.spec.js
import { mount } from '@vue/test-utils'
import Card from './Card.vue'

describe('Card', () => {
  it('显示默认插槽的内容', () => {
    const wrapper = mount(Card, {
      slots: {
        default: '<p class="custom">自定义内容</p>'
      }
    })
    
    expect(wrapper.find('.custom').exists()).toBe(true)
    expect(wrapper.find('.custom').text()).toBe('自定义内容')
  })
})

测试具名插槽

我们再来看一个具名插槽的组件:

html 复制代码
<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

其对应的测试:

javascript 复制代码
// Layout.spec.js
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'

describe('Layout', () => {
  it('渲染所有插槽', () => {
    const wrapper = mount(Layout, {
      slots: {
        header: '<h1>页面标题</h1>',
        default: '<p>主要内容</p>',
        footer: '<p>版权信息</p>'
      }
    })
    
    expect(wrapper.find('header h1').text()).toBe('页面标题')
    expect(wrapper.find('main p').text()).toBe('主要内容')
    expect(wrapper.find('footer p').text()).toBe('版权信息')
  })
  
  it('没有插槽时不显示对应的区域', () => {
    const wrapper = mount(Layout, {
      slots: {
        default: '<p>内容</p>'
      }
    })
    
    expect(wrapper.find('header').exists()).toBe(true)
    expect(wrapper.find('header').text()).toBe('')
    expect(wrapper.find('footer').exists()).toBe(true)
    expect(wrapper.find('footer').text()).toBe('')
  })
})

测试作用域插槽

我们再来看一个作用域插槽的组件:

html 复制代码
<!-- DataTable.vue -->
<template>
  <table>
    <tbody>
      <tr v-for="item in data" :key="item.id">
        <td>
          <!-- 作用域插槽,把数据传给父组件 -->
          <slot name="cell" :item="item">
            {{ item.name }}  <!-- 默认内容 -->
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
defineProps({
  data: Array
})
</script>

其对应的测试:

javascript 复制代码
// DataTable.spec.js
import { mount } from '@vue/test-utils'
import DataTable from './DataTable.vue'

const mockData = [
  { id: 1, name: '张三', age: 25 },
  { id: 2, name: '李四', age: 30 }
]

describe('DataTable', () => {
  it('默认插槽显示 name', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData }
    })
    
    const cells = wrapper.findAll('td')
    expect(cells[0].text()).toBe('张三')
    expect(cells[1].text()).toBe('李四')
  })
  
  it('自定义插槽显示 age', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData },
      slots: {
        cell: `
          <template #cell="{ item }">
            <span class="age">{{ item.age }}</span>
          </template>
        `
      }
    })
    
    const ages = wrapper.findAll('.age')
    expect(ages[0].text()).toBe('25')
    expect(ages[1].text()).toBe('30')
  })
})

完整示例 - 表单组件测试

我们来看一个复杂的表单组件:

html 复制代码
<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="username"
        placeholder="用户名"
        @blur="validateUsername"
      />
      <span v-if="usernameError" class="error">{{ usernameError }}</span>
    </div>
    
    <div>
      <input 
        v-model="password"
        type="password"
        placeholder="密码"
        @blur="validatePassword"
      />
      <span v-if="passwordError" class="error">{{ passwordError }}</span>
    </div>
    
    <div>
      <label>
        <input type="checkbox" v-model="remember" />
        记住我
      </label>
    </div>
    
    <button type="submit" :disabled="!isValid">
      {{ loading ? '登录中...' : '登录' }}
    </button>
    
    <div class="footer">
      <slot name="footer"></slot>
    </div>
  </form>
</template>

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

const username = ref('')
const password = ref('')
const remember = ref(false)
const loading = ref(false)

const usernameError = ref('')
const passwordError = ref('')

const emit = defineEmits(['submit', 'loading-change'])

const validateUsername = () => {
  if (!username.value) {
    usernameError.value = '用户名不能为空'
  } else if (username.value.length < 3) {
    usernameError.value = '用户名至少3个字符'
  } else {
    usernameError.value = ''
  }
}

const validatePassword = () => {
  if (!password.value) {
    passwordError.value = '密码不能为空'
  } else if (password.value.length < 6) {
    passwordError.value = '密码至少6个字符'
  } else {
    passwordError.value = ''
  }
}

const isValid = computed(() => {
  return !usernameError.value && !passwordError.value && 
         username.value && password.value
})

const handleSubmit = async () => {
  if (!isValid.value) return
  
  loading.value = true
  emit('loading-change', true)
  
  await new Promise(resolve => setTimeout(resolve, 1000))
  
  emit('submit', {
    username: username.value,
    password: password.value,
    remember: remember.value
  })
  
  loading.value = false
  emit('loading-change', false)
}
</script>

其对应的测试:

javascript 复制代码
// LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'

describe('LoginForm', () => {
  // 1. Props 测试(这里没有 Props,跳过)
  
  // 2. 事件测试
  describe('Events', () => {
    it('提交时触发 submit 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeTruthy()
      expect(wrapper.emitted('submit')[0]).toEqual([{
        username: 'testuser',
        password: 'password123',
        remember: false
      }])
    })
    
    it('表单无效时不触发 submit', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeFalsy()
    })
    
    it('提交时触发 loading-change 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('loading-change')).toBeTruthy()
      expect(wrapper.emitted('loading-change')[0]).toEqual([true])
      
      // 等待异步完成
      await new Promise(resolve => setTimeout(resolve, 1100))
      
      expect(wrapper.emitted('loading-change')[1]).toEqual([false])
    })
  })
  
  // 3. 插槽测试
  describe('Slots', () => {
    it('显示 footer 插槽内容', () => {
      const wrapper = mount(LoginForm, {
        slots: {
          footer: '<a href="/register">注册新账号</a>'
        }
      })
      
      expect(wrapper.find('.footer a').text()).toBe('注册新账号')
    })
  })
  
  // 4. 验证逻辑测试
  describe('Validation', () => {
    it('用户名太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      
      expect(wrapper.text()).toContain('用户名至少3个字符')
    })
    
    it('用户名正确时清除错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).toContain('用户名至少3个字符')
      
      await usernameInput.setValue('abc')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).not.toContain('用户名至少3个字符')
    })
    
    it('密码太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const passwordInput = wrapper.find('input[placeholder="密码"]')
      
      await passwordInput.setValue('123')
      await passwordInput.trigger('blur')
      
      expect(wrapper.text()).toContain('密码至少6个字符')
    })
  })
  
  // 5. 按钮状态测试
  describe('Submit Button', () => {
    it('表单无效时按钮禁用', async () => {
      const wrapper = mount(LoginForm)
      const button = wrapper.find('button[type="submit"]')
      
      expect(button.attributes('disabled')).toBeDefined()
    })
    
    it('表单有效时按钮启用', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      
      // 触发验证
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.attributes('disabled')).toBeUndefined()
    })
    
    it('提交时按钮文字变成"登录中..."', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.text()).toBe('登录')
      
      await button.trigger('click')
      
      expect(button.text()).toBe('登录中...')
    })
  })
})

常见问题与解决方案

问题一:异步更新导致断言失败

typescript 复制代码
// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 await
it('updates count', async () => {
  wrapper.vm.count++
  await wrapper.vm.$nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题二:插槽内容没有正确渲染

typescript 复制代码
// ❌ 错误:没有正确传递插槽内容
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': '<span class="status">{{ props.value }}</span>'
  }
})

// ✅ 正确:使用模板字符串
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': `
      <template #cell-status="{ value }">
        <span class="status">{{ value }}</span>
      </template>
    `
  }
})

问题三:事件发射次数断言错误

typescript 复制代码
// ❌ 错误:只检查存在性
expect(wrapper.emitted('click')).toBeTruthy()

// ✅ 正确:检查具体次数和参数
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')?.[0]).toEqual([expectedData])

问题四:过度依赖实现细节

typescript 复制代码
// ❌ 错误:测试内部方法
it('calls validateForm method', () => {
  const validateSpy = vi.spyOn(wrapper.vm, 'validateForm')
  // ...
  expect(validateSpy).toHaveBeenCalled()
})

// ✅ 正确:测试用户可见的行为
it('shows validation error when form is invalid', async () => {
  // 操作表单
  // 断言错误消息出现
})

组件测试的最佳实践

Props 测试

  • 基础渲染
  • 默认值
  • 不同值
  • Props 变化后的响应
  • 边界情况(空值、长文本)

事件测试

  • 事件是否触发
  • 事件参数是否正确
  • 事件触发次数
  • 事件顺序
  • 条件触发(禁用时)

插槽测试

  • 默认插槽内容
  • 具名插槽内容
  • 作用域插槽的 props
  • 没有插槽时的行为

结语

组件测试不是测试每一行代码,而是测试组件的行为是否符合预期。 Props 是输入,事件是输出,插槽是扩展点。把握这三个核心,就能写出高效、可靠的组件测试。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
大哥,带带弟弟8 小时前
Grafana 前端嵌入与 JWT 鉴权实战
前端·grafana
小小小小宇8 小时前
前端 V8 引擎垃圾回收机制与内存问题排查
前端
前端老石人8 小时前
CSS 值定义语法
前端·css
sheeta19988 小时前
Vue 前端基础笔记
前端·vue.js·笔记
小小小小宇8 小时前
GitLab + GitLab Runner + Qiankun 微前端 + Nginx + Node 中间件 前端开发机从零搭建 CI/CD 全流程
前端
前端那点事9 小时前
别再写垃圾组件!Vue3 如何设计「真正可复用」的高质量通用组件
前端·vue.js
卷帘依旧9 小时前
JavaScript 中的 Symbol
前端·javascript
老王以为9 小时前
Claude Code 从 GUI 到 TUI:开发者界面的范式回归
前端·人工智能·全栈
JYeontu9 小时前
正方体翻滚Loading 2.0
前端·javascript·css
llq_3509 小时前
React 组件处理 Props
前端