完全掌握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.组件基本测试

相关推荐
有梦想的刺儿3 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具24 分钟前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
清灵xmf1 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据1 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
334554322 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro