完全掌握vue全家桶单元测试 : 6. 深入理解组件测试

前置知识

观念改变

在这一章,会讲到如何测试组件,我在这一章只讲组件测试的基本操作,不会去讲测试的心法,后面章节再专门讲测试的心法,我会带着大家,把复杂的业务点,拆成一个一个细点,让大家先把测试用例写出来,后面再考虑写不写得好。就好像先学 typescript 基本的类型操作就能覆盖日常开发的 90%,想把 typescript 写的更好,那就得学typescript 体操,而 typescript 体操平时开发可能只占不到 10%

异步

在JavaScript中执行异步代码是很常见的,我们前面学的 it 上下文,是一个同步的上下文,在我们测试组件的时候,尤其是 vue 更新 setData 的时候,是异步的。这就需要在一个异步的 it 上下文中执行断言代码

之前我们学过的同步上下文

ts 复制代码
  it('mount first render', () => {
   
  })

异步上下文只需要加一个 async,然后就可以使用 await,类似 js 的 async await 用法一样

ts 复制代码
  it('update multiple render', async () => {
    // async await
    await nextTick()
  })

如何设置 Data

我们之前说了 mount 和 shallowMounnt, 其实 mount 还有第二个参数,可以设置

ts 复制代码
<script setup lang="ts">
import { ref } from 'vue'
const str = ref('')
</script>

<template>
  <div>{{ str }}</div>
</template>
ts 复制代码
  it('mount', async () => {
     const wrapper = mount(Data, {
      setup() {
        return {
          str: 'first render'
        }
      }
    })
    expect(wrapper.text()).toContain('first render')
})

但我们日常业务还是极少会手动设置 data,往往是mount勾子里面去修改data

如何解决 mount 之后的数据变化?

ts 复制代码
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const str = ref('first render')
onMounted(()=>{
  str.value = 'second render'
})
</script>

<template>
  <div>{{ str }}</div>
</template>

因为勾子函数异步更新了 str,所以需要使用await nextTick()等待更新完成之后再断言

ts 复制代码
  it('mount', async () => {
    const wrapper = mount(Data, {})
    expect(wrapper.text()).toContain('first render')
    await nextTick()
    console.log('wrapper.text()', wrapper.html())
    expect(wrapper.text()).toContain('second render')
  })

如何临时修改组件的值?

ts 复制代码
  it('mount', async () => {
    const wrapper = mount(Data, {})
    expect(wrapper.text()).toContain('first render')
    await nextTick()
    expect(wrapper.text()).toContain('second render')

    wrapper.vm.str = 'third render'
    await nextTick()
    console.log('wrapper.text()', wrapper.html())
    expect(wrapper.vm.str).toBe('third render') // data 有没有值
    expect(wrapper.text()).toContain('third render') // data 是否正确渲染在页面上
  })

props

ts 复制代码
<script setup lang="ts">
defineProps<{
  msg: string
}>()
</script>

<template>
  <div>{{ msg }}</div>
</template>
ts 复制代码
  it('mount props', async () => {
    const wrapper = mount(Props, {
      props: {
        msg: 'props msg'
      }
    })
    expect(wrapper.text()).toContain('props msg')
  })

动态 setProps

ts 复制代码
  it('update props', async () => {
    const wrapper = mount(Props, {
      props: {
        msg: 'props msg'
      }
    })
    expect(wrapper.text()).toContain('props msg')
    await wrapper.setProps({
      msg: 'second render'
    })
    expect(wrapper.props('msg')).toBe('second render') // props 有没有值
    expect(wrapper.text()).toContain('second render') // props 是否正确渲染在页面上
  })

日志输出

ts 复制代码
    console.log('wrapper',wrapper.props('msg'))

如何测试 emits

要修改和添加案例

ts 复制代码
<script setup lang="ts">
interface Emits {
  (e: 'change', value: string)
  (e: 'update:pageIndex', value: number)
  (e: 'update:pageSize', value: string, size: number)
}

const emits = defineEmits<Emits>()

const resetPage = (value: string) => {
  emits('update:pageSize', value, 10)
  emits('update:pageIndex', 1)
  emits('change', value)
}
</script>

<template>
  <div @click="resetPage('customer')" data-testid="button">button</div>
</template>
ts 复制代码
  it('mount', async () => {
    const wrapper = mount(Emitted)
    const button = wrapper.find('[data-testid="button"]')
    await button.trigger('click')
    const emits = wrapper.emitted()
    console.log('emits', emits)
    // emits {
    //   'update:pageSize': [ [ 'customer', 10 ] ],
    //   'update:pageIndex': [ [ 1 ] ],
    //   change: [ [ 'customer' ] ],
    //   click: [ [ [MouseEvent] ] ]
    // }
    expect(emits).toHaveProperty('update:pageIndex')
    expect(emits).toHaveProperty('update:pageSize')
    expect(emits).toHaveProperty('change')
  })

wrapper.emitted() 是一个数组,可以获取到 emit 事件的记录,根据数组里面的内容去断言

provide/inject

父组件向下传递 this is parent data

Parent.vue

ts 复制代码
provide('parentValue', 'this is parent data')

按钮组件拿到parent 传递过来的 parentValue

Button.vue

ts 复制代码
const text = inject('parentValue')
<div> {{ text }}</div>
ts 复制代码
describe('测试 provide', () => {
  it('测试顶层组件渲染正确传递值给子组件', async () => {
    const wrapper = mount(Parent)
    expect(wrapper.text()).toContain('this is parent data')
  })

  it('测试子组件能拿到顶层组件传递的值', async () => {
    const wrapper = mount(Button,{
      global: {
        provide: {
          parentValue: 'test provide'
        }
      }
    })
    expect(wrapper.text()).toContain('test provide')
  })
})

directive

从业务角度,从组件的角度

ts 复制代码
<script setup lang="ts">
const vTooltip = {
  beforeMount(el: Element) {
    el.classList.add('with-tooltip')
  }
}
</script>

<template>
  <div>
    <div v-tooltip data-testid="tooltip">show tooltip</div>
  </div>
</template>
ts 复制代码
  it('tooltip', async () => {
    const wrapper = mount(Directive)
    const tooltip = wrapper.find('[data-testid="tooltip"]')
    expect(tooltip.html()).toContain('with-tooltip')
  })

如果是全局的自定义指令,就得如下写法,需要在 main.ts 里面全局定义指令

ts 复制代码
// main.ts
app.directive('tooltip', vTooltip)
ts 复制代码
<script setup lang="ts">
</script>

<template>
  <div>
    <div v-tooltip data-testid="tooltip">show tooltip</div>
  </div>
</template>

测试用例需要 directives 注入进来

ts 复制代码
  it('tooltip', async () => {
    const wrapper = mount(Directive, {
      global: {
        directives: {
          tooltip: vTooltip
        }
      }
    })
    const tooltip = wrapper.find('[data-testid="tooltip"]')
    expect(tooltip.html()).toContain('with-tooltip')
  })

components

当我们在使用一些第三方组件的时候,可能第三方组件就是全局注册的,我们就不需要在每一个组件里面每次引入使用的组件,例如我有个 GlobalComponent 组件,被全局注册了

ts 复制代码
import GlobalComponent from './components/6/GlobalComponent.vue'
app.component('GlobalComponent', GlobalComponent)

那么在使用的时候就不需要在当前组件引入了,直接使用就行

ts 复制代码
<template>
  <div>
    <GlobalComponent></GlobalComponent>
  </div>
</template>

<script setup lang="ts">
</script>

我们直接 mount 一下这个

ts 复制代码
  it('mount error component', async () => {
    const wrapper = mount(Global)
    console.log(wrapper.html())
    expect(wrapper.text()).toContain('My Global Component')
  })

需要在测试的时候,把组件注册进去

ts 复制代码
  it('mount success component', async () => {
    const wrapper = mount(Global, {
      global: {
        components: {
          GlobalComponent
        }
      }
    })
    expect(wrapper.text()).toContain('My Global Component')
  })

plugins

我们再来看看 plugins, 插件很常见,vuex、vue-router 都是插件,我们如何测试插件呢,我列了一个平常可能会使用到的 i18n 插件,

ts 复制代码
// i18n.ts
const i18nPlugin = {
  install(app: any, options: PluginOptions = {}) {
    const messages = options.messages ?? {}

    app.config.globalProperties.$t = function (key: string) {
      const language = options.defaultLanguage ?? 'en'
      return messages[language]?.[key] || key
    }
  }
}
html 复制代码
<!-- Plugin.vue -->
<template>
  <div>{{ $t('hello') }}</div>
</template>

<script setup lang="ts">
</script>

main.ts 里面需要注册插件,这里我们直接定义 hello 的值是 'Hello Plugin'

ts 复制代码
app.use(i18nPlugin, {
  defaultLanguage: 'en',
  messages: {
    en: {
      hello: 'Hello Plugin'
    }
  }
})
ts 复制代码
describe('测试 plugin', () => {
  it('uses i18n plugin', () => {
    const wrapper = mount(Plugin, {
      global: {
        plugins: [
          [
            i18nPlugin,
            {
              defaultLanguage: 'en',
              messages: {
                en: {
                  hello: 'Hello test i18nPlugin'
                }
              }
            }
          ]
        ]
      }
    })
    expect(wrapper.text()).toBe('Hello test i18nPlugin')
  })
})

attachTo

我们有时候会在组件里面直接操作 dom,例如下面一个组件一开始渲染了 first render onMounted之后,获取 dom 之后,直接把h4里的内容改成111

ts 复制代码
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
  const ele = document.querySelector('h4') as Element
  ele.innerHTML = '111'
})
</script>

<template>
  <h4 style="color: red">first render</h4>
</template>

如果不使用 attachTo, 会渲染报错

ts 复制代码
  it('attach render error', async () => {
    const wrapper = mount(Attach)
    await nextTick()
    expect(wrapper.text()).toContain('111')
  })

正确的用法是 attachTo 到 body 上面或者其他的 DOM 上

ts 复制代码
  it('attach success render', async () => {
    // const div = document.createElement('div')
    // document.body.appendChild(div)
    const wrapper = mount(Attach, {
      attachTo: document.body
      // attachTo: div // 任意一个 dom
    })
    await nextTick()
    console.log('wrapper', wrapper.html())
    expect(wrapper.text()).toContain('111')
  })

teleport

Vue 3 配备了一个新的内置组件:它允许组件将其内容 "传送 "到其自身之外,当我们直接 mount 一个包含 teleport 组件的时候,是不会展示具体内容的,只会显示

xml 复制代码
<!--teleport start-->
<!--teleport end-->

如何测试呢? MyTeleport.Vue 组件使用了 Teleport 功能,Teleport 包括了一个子组件 Signup.vue

ts 复制代码
<template>
  <Teleport to="#modal">下面渲染子组件 <Signup></Signup> </Teleport>
</template>

<script lang="ts" setup>
import Signup from './Signup.vue'
</script>

Signup.vue 是一个表单,用于验证用户名是否大于 8 个字符。如果大于 8 个字符,就把输入的用户名 emit 到父组件

ts 复制代码
<template>
  <div>
    <form @submit.prevent="submit">
      <input v-model="username" />
    </form>
  </div>
</template>

<script lang="ts" setup>
import { ref, computed, defineEmits } from 'vue'

const emit = defineEmits(['signup'])

const username = ref('')

const error = computed(() => {
  return username.value.length < 8
})

const submit = () => {
  if (!error.value) {
    emit('signup', username.value)
  }
}
</script>

因为 teleport 到了当前组件的外部,需要额外使用 getComponent 或者 findComponent来直接获取 Signup.vue,然后再进行相关的断言操作

ts 复制代码
beforeEach(() => {
  const el = document.createElement('div')
  el.id = 'modal'
  document.body.appendChild(el)
})

afterEach(() => {
  document.body.outerHTML = ''
})

test('teleport', async () => {
  const wrapper = mount(Teleport)
  console.log(wrapper.html())
  const signup = wrapper.getComponent(Signup)
  await signup.get('input').setValue('valid_username')
  await signup.get('form').trigger('submit.prevent')

  expect(signup.emitted().signup[0]).toEqual(['valid_username'])
})

ref

我们有时需要访问 DOM 元素或子组件,以手动操作它们,而不是依赖数据绑定,例如一个 进入页面之后,自动聚焦的输入框,如何使用 ref 自动聚焦?

ts 复制代码
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const input = ref<HTMLInputElement | null>(null)
const model = defineModel<string>()

onMounted(() => {
  input.value?.focus()
})
</script>

<template>
  <div>
    <input ref="input" v-model="model" />
  </div>
</template>

我们要测两点东西

  1. ref 获取 input 元素存在
  2. input 元素被聚焦了
ts 复制代码
import { shallowMount } from '@vue/test-utils'
import Ref from './Ref.vue'

describe('Ref', () => {
  it('自动聚焦的输入框', () => {
    const wrapper = shallowMount(Ref, {
      attachTo: document.body
    })
    const input = wrapper.find<HTMLInputElement>({
      ref: 'input'
    })
    expect(document.activeElement).toBe(input.element)
  })
})

defineAsyncComponent

异步加载组件的加载可能还比较少人用过,具体可以看看,

看一个异步加载到的 demo , Lazy.vue 组件内部有一个异步的 pdf 预览组件.

ts 复制代码
// Lazy.vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// 简单用法
const AsyncPdf = defineAsyncComponent({
  loader: () => import('./AsyncPdf.vue'),
  delay: 200,
})
</script>

<template>
  <h4 style="color: red">测试 async</h4>
  <br />
  <AsyncPdf></AsyncPdf>
</template>
ts 复制代码
// AsyncPdf.vue
<script setup lang="ts">
</script>

<template>
  <div>
    <div>pdf file</div>
    <div v-if="false" data-testid="if">if button</div>
    <div v-show="false" data-testid="show">show button</div>
  </div>
</template>

我们只需要测试,1秒之后,页面出现 pdf

ts 复制代码
import { mount } from '@vue/test-utils'
import Lazy from './Lazy.vue'

describe('Lazy', () => {
  it('renders Lazy component', async () => {
    const wrapper = mount(Lazy)
    expect(wrapper.text()).not.toContain('pdf')
    await new Promise((resolve) => setTimeout(resolve, 1000))
    expect(wrapper.text()).toContain('pdf')
  })
})

课件地址

上面的代码,都放到了 github 上,欢迎点赞收藏,我会持续更新代码和文章

往期文章

完全掌握vue全家桶单元测试 : 1. 为什么需要前端测试

完全掌握vue全家桶单元测试 : 2. 搭建 vitest 环境

完全掌握vue全家桶单元测试 : 3. vitest 用法概览

完全掌握vue全家桶单元测试 : 4.断言常用方法

完全掌握vue全家桶单元测试 : 5.组件基本测试

相关推荐
ywf12151 小时前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭1 小时前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf7 小时前
2026 年前端面试问什么
前端·面试
还是大剑师兰特7 小时前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷8 小时前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
mengchanmian8 小时前
前端node常用配置
前端
华洛9 小时前
利好打工人,openclaw不是企业提效工具,而是个人助理
前端·javascript·产品经理
xkxnq9 小时前
第六阶段:Vue生态高级整合与优化(第93天)Element Plus进阶:自定义主题(变量覆盖)+ 全局配置与组件按需加载优化
前端·javascript·vue.js
A黄俊辉A10 小时前
vue css中 :global的使用
前端·javascript·vue.js
小码哥_常10 小时前
被EdgeToEdge适配折磨疯了,谁懂!
前端