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

17 阅读2分钟

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

组件的本质:输入与输出

<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 组件:

<!-- Greeting.vue -->
<template>
  <h1>Hello, {{ name }}!</h1>
</template>

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

其对应的测试:

// 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 值的组件:

<!-- 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>

其对应的测试:

// 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 对象的组件:

<!-- 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>

其对应的测试:

// 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 发生变化时,如何测试呢?

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

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

其对应的测试:

// 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%')
  })
})

测试事件 - 验证输出

事件测试的核心

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

基础事件测试

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

<!-- 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>

其对应的测试:

// 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()
  })
})

测试多个事件

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

<!-- 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>

其对应的测试:

// 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()
  })
})

测试事件顺序

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

<!-- 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>

其对应的测试:

// 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])
  })
})

测试插槽 - 验证内容分发

插槽测试的核心

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

测试默认插槽

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

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

其对应的测试:

// 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('自定义内容')
  })
})

测试具名插槽

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

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

其对应的测试:

// 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('')
  })
})

测试作用域插槽

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

<!-- 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>

其对应的测试:

// 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')
  })
})

完整示例 - 表单组件测试

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

<!-- 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>

其对应的测试:

// 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('登录中...')
    })
  })
})

常见问题与解决方案

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

// ❌ 错误:没有等待 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')
})

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

// ❌ 错误:没有正确传递插槽内容
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>
    `
  }
})

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

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

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

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

// ❌ 错误:测试内部方法
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 是输入,事件是输出,插槽是扩展点。把握这三个核心,就能写出高效、可靠的组件测试。

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