TDD开发Vue3组件指南

1,516 阅读6分钟

前言

Jest是Facebook推出的一个单元测试框架,支持断言、异步操作、单元测试覆盖率的统计。
本文将简单的介绍使用Jest使用TDD的方式开发一个vue3的组件。

1 安装Jest

1.1 在项目目录中安装Jest

执行脚本

yarn add jest@27.2.5 -D

1.2 安装Jest的Vue的依赖

执行脚本

yarn add @vue/compiler-sfc@3.2.47 -D 
yarn add @vue/test-utils@2.3.0 -D
yarn add vue3-jest@27.0.0-alpha.1 -D 

依赖说明

依赖用途
@vue/compiler-sfc处理一些需要使用.vue文件的模块系统,将vue的单文件组件(SFCs)转化为JavaScript
@vue/test-utilsVue3的组件测试工具
vue3-jestJest针对vue3单文件组件的转换器

1.3 安装Jest的TypeScript依赖

执行脚本

yarn add babel-jest@27.5.1 -D
yarn add @babel/core@7.21.0 -D 
yarn add @babel/preset-env@7.20.2 -D
yarn add @babel/preset-typescript@7.21.0 -D
yarn add @types/jest@29.4.0 -D
yarn add typescript@4.9.5 -D

依赖说明

在Jest的单元测试中使用TypeScript时,需要依赖Babel。

依赖用途
babel-jestJest中使用Babel编译JavaScript时需要安装babel-jest、@babel/core、@babel/preset-env
@babel/core-
@babel/preset-env-
@babel/preset-typescript使用TS时,需要针对TS进行预处理,编译为JS,再使用Babel编译
@types/jestJest的ts声明文件
typescriptTypeScript

1.4 添加测试脚本

jest默认使用的是Node环境进行单元测试,由于vue用于开发WebApp,需要使用浏览器的一些API因此,在启动测试时,需要将env切换为jsdom,用以模拟浏览器的运行环境。

// package.json
{
  "scripts": {
    "test": "jest --watch --env=jsdom"
  }
}

1.5 正确的配置babel、jest和ts

babel配置

// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

ts配置

// tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "useDefineForClassFields": true,
    "module": "esnext",
    "moduleResolution": "node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["esnext", "dom"],
    "types": ["@types/jest"]
  },
  "include": [] // ts file path
}

当在Jest中使用TS对.vue文件进行测试时,需要对.vue文件补充他的模块声明

// shim.d.ts
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent
  export default component
}

jest配置

jest的配置文件可以通过jest命令行工具进行生成

jest --init
// or
npx jext --init
// jest.config.js
/*
 * For a detailed explanation regarding each configuration property and type check, visit:
 * https://jestjs.io/docs/configuration
 */

module.exports = {
  // Indicates whether the coverage information should be collected while executing the test
  collectCoverage: true,
  
  // The directory where Jest should output its coverage files
  coverageDirectory: 'coverage',

  // The test environment that will be used for testing
  testEnvironment: 'jsdom',

  // The glob patterns Jest uses to detect test files
  testMatch: ['**/?(*.)+(spec).[jt]s?(x)'], // ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],

  // A map from regular expressions to paths to transformers
  transform: {
    // .vue文件用 vue-jest 处理
    '^.+\\.vue$': 'vue3-jest',
    // .js或者.jsx用 babel-jest处理
    '^.+\\.jsx?$': 'babel-jest',
    //.ts文件用ts-jest处理
    '^.+\\.ts$': 'ts-jest'
  }
}

1.6 运行单元测试

开启单元测试

yarn test

运行效果

image.png

2 TDD开发一个Vue3组件

2.1 梳理需求

功能说明
列表展示
1、列表具有序号、名字、编码、状态、操作三列;
2、操作列有编辑、查看、删除三个按钮,点击出现对应的对话框
列表筛选
1、有搜索输入框,可输入字符串,有搜索和重置两个按钮
2、点击搜索按钮,在输入非空时,可以进行表格的筛选展示、点击重置按钮,可以清空输入框中已经输入的内容

2.2 需求拆解

上面的业务组件里可以分成两块,一块是表格组件、一块是输入搜索组件,因此我们需要两块的单元测试,分别测试这两个组件的单一功能。

2.3 测试用例

搜索组件需要使用v-model的方式进行数据的双向绑定 先新建一个SearchInput的组件

<template>
    <div class='search-input-container'>
      <el-input class="input"></el-input>
      <el-button class="search"  type="primary" size="default" @click=""></el-button>
      <el-button class="reset" type="primary" size="default" @click=""></el-button>
    </div>
</template>

编写组件的单元测试

import { mount } from '@vue/test-utils'
import { ElButton, ElInput } from 'element-plus'
import SearchInput from '../components/SearchInput.vue'

// 模拟搜索组件的挂载事件
const searchInputComp = mount(SearchInput, {
  // 模拟调用时 v-model="Search input test"
  props: {
      modelValue: 'Search input test',
      'onUpdate:modelValue': (e:string) => searchInputComp.setProps({ modelValue: e })
  },
  // element组件在项目启动时全局挂载,jest无法处理这一点,因此需要在这里显示的生命注册的组件
  components: {
    'el-input': ElInput,
    'el-button': ElButton
  },
})

/** 
 * 获取输入组件,find和findComponent的注意事项可以参考官方文档
 * https://v1.test-utils.vuejs.org/api/wrapper/#findcomponent
 */
const inputEle = searchInputComp.findComponent<typeof ElInput>('.input')
const searchBtn = searchInputComp.findComponent<typeof ElButton>('.search')
const resetBtn = searchInputComp.findComponent<typeof ElButton>('.reset')

test('测试搜索组件组件挂载',()=>{
   /**
   * 测试各个组件是否正常挂载
   */
  expect(inputEle.exists()).toBe(true)
  expect(searchBtn.exists()).toBe(true)
  expect(resetBtn.exists()).toBe(true)
})

单元测试执行结果

image.png

继续补充测试用例,测试组件的v-model功能

test('测试搜索组件的v-model功能',()=>{
  /**
   * 使用findComponent和getComponent时需要注意事项
   * https://test-utils.vuejs.org/guide/advanced/component-instance.html#usage-with-getcomponent-and-findcomponent
   */
  const inputEle = searchInputComp.findComponent<typeof ElInput>('.input')
  expect(inputEle.props('modelValue')).toBe('Search input test')

  inputEle.setValue('new value')
  expect(searchInputComp.props('modelValue')).toBe('new value')
})

测试结果

image.png

在这个时候,我们的组件没有完成v-model的功能,此时的单元测试是失败的,接下来,我们去完善SearchInput组件的v-model功能

<template>
    <div class='search-input-container'>
      <el-input v-model="inputVal" class="input" @input="handleChange()"></el-input>
      <el-button class="search"  type="primary" size="default" @click=""></el-button>
      <el-button class="reset" type="primary" size="default" @click=""></el-button>
    </div>
</template>

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

const props = defineProps<{
  modelValue: string
}>()
const emits = defineEmits(['update:modelValue'])
const inputVal = ref('')

watch(()=>props.modelValue,()=>{
  inputVal.value = props.modelValue
},{
  immediate:true
})

const handleChange = ()=>{
  emits('update:modelValue', inputVal.value)
}
</script>

此时单元测试就能够通过了

image.png

接下来需要编写重置搜索功能的单元测试

test('测试搜索重置功能',async ()=>{
  await inputEle.find('input').setValue('Test search input string')
  // trigger返回一个Promise,因此测试阶段依赖于该trigger行为的后续测试需要等待vue的更新完毕
  await resetBtn.trigger('click')
  expect(searchInputComp.props('modelValue')).toBe('')
})

此时单元测试的结果如下

image.png

继续完善重置搜索的功能

<template>
  <div class="search-input-container">
    <el-input v-model="inputVal" class="input" @input="handleChange()"></el-input>
    <el-button class="search" type="primary" size="default" @click=""></el-button>
    <el-button class="reset" type="primary" size="default" @click="resetSearch()"></el-button>
  </div>
</template>

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

const props = defineProps<{
  modelValue: string
}>()
const emits = defineEmits(['update:modelValue'])
const inputVal = ref('')

watch(
  () => props.modelValue,
  () => {
    inputVal.value = props.modelValue
  },
  {
    immediate: true
  }
)

const handleChange = () => {
  emits('update:modelValue', inputVal.value)
}

const resetSearch = () => {
  inputVal.value = ''
  emits('update:modelValue', inputVal.value)
}
</script>

单元测试通过

image.png

接下来就是组件交互逻辑的最后一步,我们需要在组件内点击搜索按钮时,组件的调用处可以接收到search事件

// 可以查看官方示例:https://test-utils.vuejs.org/guide/essentials/event-handling.html#the-counter-component
test('测试搜索按钮点击搜索功能',async ()=>{
  await inputEle.find('input').setValue('search keyword')
  await searchBtn.trigger('click')
  const searchEvent = searchInputComp.emitted('search')
  expect(searchEvent).toBeTruthy()
  /* 测试触发的次数,多次触发视为异常,多次触发时数据如下
   * [ [第一次触发的数据载体], [第二次触发的数据载体]... ]
   * 以下测试用例表示只触发一次
   */
  expect(searchEvent?.length).toBe(1)
  // 测试event payload, 第一个次触发的第一个参数 
  expect(searchEvent![0][0]).toBe('search keyword')
})

image.png

在组件中补全对应的时间触发逻辑

<template>
  <div class="search-input-container">
    <el-input v-model="inputVal" class="input" @input="handleChange()"></el-input>
    <el-button class="search" type="primary" size="default" @click="handleSearch()"></el-button>
    <el-button class="reset" type="primary" size="default" @click="resetSearch()"></el-button>
  </div>
</template>

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

const props = defineProps<{
  modelValue: string
}>()
const emits = defineEmits(['update:modelValue', 'search'])
const inputVal = ref('')

watch(
  () => props.modelValue,
  () => {
    inputVal.value = props.modelValue
  },
  {
    immediate: true
  }
)

const handleChange = () => {
  emits('update:modelValue', inputVal.value)
}

const resetSearch = () => {
  inputVal.value = ''
  emits('update:modelValue', inputVal.value)
}

const handleSearch = () => {
  emits('search', inputVal.value)
}
</script>

image.png

至此,关于搜索组件的input功能已经完全开发完成了,接下来我们需要进行人机交互进行组件的自测。

2.4 交互验证

// 调用组件
<template>
  <SearchInput v-model="input" @search="handleSearch"></SearchInput>
  <p>{{ input }}</p>
</template>

<script setup lang="ts">
import { ElMessageBox } from 'element-plus';
import { ref } from 'vue';
import SearchInput from './components/SearchInput.vue';

const input = ref('')
const handleSearch = (searchVal:string)=>{
  ElMessageBox.alert(searchVal)
}
</script>

实际效果 20230308162543_rec_.gif

为了使组件的交互更加的严谨,可以继续的添加单元测试,并完善组件的逻辑,至此,就已经使用TDD的方式完成了一个vue组件的开发,接下来就可以针对组件进行UI层面的调整和优化了。 在后续组件的重构时,当测试用例可以通过,就表示组件的优化和变更,没有影响到正常的功能,可以作为一个重构和优化的重要指标。

参考资料

示例代码地址:github.com/Arfly/vue-u…
单元测试框架对比:npmtrends.com/ava-vs-jest…
Jest文档:jestjs.io/zh-Hans/doc…
Jest配置:jestjs.io/zh-Hans/doc…
vue-test-utils示例文档:test-utils.vuejs.org/installatio…