blog
blog copied to clipboard
让低代码开发更稳健:Vue 3 组件测试实战—Jest 与 @vue/test-utils 的结合
单元测试的重要性
在低代码项目中,公共模块的复用度通常较高,为了提高代码质量,实施单元测试显得尤为重要。在持续迭代低代码平台的过程中,单元测试有助于提升自测效率,使我们能够尽早发现和修复潜在的 bug,从而保证项目的稳定性和可靠性。
低代码环境
运行环境
- Node.js: 16.16.0
- 包管理工具: [email protected]
组件库相关配置
- Vue.js: 3.2.9
- Pinia: 2.0.36
- Vite: 2.9.17
- Babel: @babel/[email protected]
Vite 插件
-
@vitejs/[email protected]
-
@vitejs/[email protected]
-
@vitejs/[email protected]
初始化测试环境
技术方案
我们选择使用 Jest 搭配 @vue/test-utils 进行单元测试。需要注意的是,由于 Vitest 需要 Vite 版本大于等于 5.0.0,Node 版本则需要大于等于 18.0.0,因此本项目并未采用该方案。
安装依赖
以下命令将安装所需的依赖:
pnpm add [email protected] @vue/[email protected] @vue/[email protected] [email protected] [email protected] -D
处理 Vite 环境的 import.env:
pnpm add [email protected] -D
处理 scss 文件转换
pnpm add [email protected] -D
配置文件
- babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
[
'babel-preset-vite',
{
env: true,
glob: false
}
]
]
}
- jest.config.js
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.js$': 'babel-jest',
'^.+\\.scss$': 'jest-scss-transform'
},
testMatch: ['**/tests/**/*.spec.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^h5-render(.*)$': '<rootDir>/.ssg/index.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/tests/__mocks__/assetsTransformer.js',
'\\.(css|less)$': '<rootDir>/assetsTransformer.js'
},
collectCoverage: true,
collectCoverageFrom: ['src/components/**/*.vue', 'src/modules/**/*.vue']
}
- 在 package.json 中新增
test
命令
{
"scripts": {
"test": "jest"
}
}
简单组件示例
src\components\base\demo\demo.vue
<template>
<div class="c-demo">
<img @click="$emit('clickImg', imgurl)" :src="imgurl" alt="" />
<span @click="$emit('clickText', text)">{{ text }}</span>
</div>
</template>
<script>
export default {
props: {
imgurl: String,
text: String
},
created() {
// console.log(this.$shared) // ! 全局共享数据
// console.log('urlParams:', urlParams) // ! 全局链接参数
// * 注册全局共享数据
this.$shared.registerSharedData('demo', {
imgurl: this.imgurl,
text: this.text
})
// // * 注册全局共享方法
this.$shared.registerSharedFn('getDemoInfo', this.getDemoInfo)
this.$shared.registerSharedFn('previewImg', this.previewImg)
},
methods: {
getDemoInfo() {
console.log('getDemoInfo')
},
previewImg() {
console.log('previewImg')
}
}
}
</script>
tests\components\demo.spec.js
import { mount } from '@vue/test-utils'
import { createShared } from 'h5-render'
import DemoComp from '@/components/base/demo/demo.vue'
test('renders a demo comp', () => {
window.urlParams = { link: 'vue' }
const wrapper = mount(DemoComp, {
props: {
text: 'Learn Vue.js 3',
imgurl: 'https://test-utils.vuejs.org/logo.svg'
},
global: {
mocks: {
$shared: createShared()
}
}
})
expect(wrapper.find('span').text()).toBe('Learn Vue.js 3')
expect(wrapper.get('img').html()).toContain(
'https://test-utils.vuejs.org/logo.svg'
)
})
src\components\biz\auto-renewal-dialog\auto-renewal-dialog.vue
<template>
<van-popup
v-model:show="show"
id="renewal-verify-dialog"
position="center"
:lock-scroll="true"
>
<div id="dialog">
<i class="close-icon" @click="cancel"></i>
<img src="./img/bg.png" alt="" />
<div class="content">
<div class="renewal">
<p>说明文案</p>
</div>
<div class="link" @click="readAgreement">《自动服务协议》</div>
<div class="btns">
<div class="btn-open" @click="open">立即开启</div>
<div class="btn-close"><span @click="cancel">暂不开启</span></div>
</div>
</div>
</div>
</van-popup>
</template>
<script setup>
import { ref } from 'vue'
import '@/scss/default/overlay.scss'
import VanPopup from '@/vant/popup'
import { drawer } from '@/common/components/drawer/index'
// // 始终展示
const show = ref(true)
const props = defineProps({
// 点击事件
handleAction: Function
})
// 点击自动服务协议
const readAgreement = () => {
drawer.open({
title: '自动服务协议',
content: ''
})
}
// 关闭弹窗
const close = () => {
show.value = false
}
// 点击取消
const cancel = () => {
props.handleAction('cancel')
close()
}
const open = () => {
props.handleAction('confirm')
close()
}
</script>
tests\components\auto-renewal-dialog.spec.js
import { mount } from '@vue/test-utils'
import { createApp, nextTick, flushPromises } from 'vue'
import CusComp from '@/components/biz/auto-renewal-dialog/auto-renewal-dialog.vue'
test('renders a auto-renewal-dialog comp, click confirm btn', async () => {
let retVal = ''
const wrapper = mount(CusComp, {
props: {
handleAction: (ret) => {
retVal = ret
}
}
})
await nextTick()
expect(wrapper.find('.btn-open').text()).toBe('立即开启')
// https://test-utils.vuejs.org/guide/essentials/event-handling.html
wrapper.find('.btn-open').trigger('click')
expect(retVal).toEqual('confirm')
})
test('renders a auto-renewal-dialog comp, click cancel btn', async () => {
let retVal = ''
const wrapper = mount(CusComp, {
props: {
handleAction: (ret) => {
retVal = ret
}
}
})
await nextTick()
expect(wrapper.find('.btn-close span').text()).toBe('暂不开启')
wrapper.find('.btn-close span').trigger('click')
expect(retVal).toEqual('cancel')
})
复杂模块示例
src\modules\other-info\other-info.vue
模块功能概述
const schame = {
_name: 'other-info',
fillInfo: { title: '其他信息' },
paywayConfig: {
list: [
'使用链接参数<b>payMode</b>进行配置',
'无payMode参数: <em>不展示支付方式,默认微信</em>',
'payMode=0: <em>展示支付方式,仅展示微信(微信环境下,不展示支付方式)</em>',
'payMode=1: <em>展示两种支付方式:微信、支付宝,默认微信支付</em>',
'payMode=2: <em>展示两种支付方式:微信、支付宝,默认支付宝支付</em>',
'payMode=3: <em>展示支付方式,仅展示支付宝支付(支付宝环境下,不展示支付方式)</em>'
]
},
paymentTypeConfig: {
list: [
'使用链接参数<b>ct</b>进行配置',
'ct=M: <em>显示缴费方式, 默认月缴</em>',
'ct=Y: <em>显示缴费方式, 默认年缴</em>',
'ct=N: <em>只展示一个年缴的缴费方式,默认年缴</em>',
'无ct参数: <em>显示缴费方式,默认月缴(同ct=M)</em>'
],
badge: true
},
renewalTypeConfig: {
list: [
'使用链接参数<b>rt</b>进行配置',
'rt=O: <em>默认开</em>',
'rt=C: <em>默认关</em>',
'无rt参数: <em>展示续保方式,默认开</em>'
],
show: true,
interceptor: true
}
}
tests\modules\other-info.spec.js
import { mount } from '@vue/test-utils'
import { createApp, nextTick } from 'vue'
import { createPinia } from 'pinia'
import { globalShared } from 'h5-render'
import ModuleComp from '@/modules/other-info/other-info.vue'
import { ClientOnly } from '../__mocks__/ClientOnly'
import { mockWeixin, mockZhifubao } from '../__mocks__/ua'
import registerBaseSharedFn from '@/common/base-shared-fn.js'
// 注册测试环境需要的共享方法
registerBaseSharedFn(globalShared)
test('renders other-info module: 链接参数 ct = N, 仅展示年缴, 默认年缴', async () => {
window.urlParams = { ct: 'N' }
const app = createApp(ModuleComp)
const pinia = createPinia()
app.use(pinia)
const wrapper = mount(ModuleComp, {
components: { ClientOnly },
props: {
fillInfo: { title: '其他信息' },
renewalTypeConfig: {},
paymentTypeConfig: {}
}
})
expect(wrapper.find('.title p').text()).toBe('其他信息')
// 等 dom 渲染一下
await nextTick()
expect(wrapper.getComponent(ModuleComp).vm.paymentType).toBe('4')
const html = wrapper.html()
expect(html).toContain('全额缴费')
expect(html).not.toContain('按月缴费')
})
test('renders other-info module: 链接参数 payMode = 3, 环境优先(在微信环境), 不展示支付方式 + 默认微信', async () => {
window.urlParams = { payMode: '3' }
mockWeixin()
const app = createApp(ModuleComp)
const pinia = createPinia()
app.use(pinia)
const wrapper = mount(ModuleComp, {
components: { ClientOnly },
props: {
fillInfo: { title: '其他信息' },
renewalTypeConfig: {},
paymentTypeConfig: {}
}
})
// 等 dom 渲染一下
await nextTick()
// 不展示
expect(wrapper.getComponent(ModuleComp).vm.paywayConfig.show).toBe(false)
// 不展示两种支付方式, 默认微信
expect(wrapper.getComponent(ModuleComp).vm.payway).toBe('1')
const html = wrapper.html()
expect(html).not.toContain('微信')
expect(html).not.toContain('支付宝')
})
// ... 其他测试用例略
- tests__mocks__\assetsTransformer.js
const path = require('path')
module.exports = {
process(src, filename, config, options) {
return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';'
}
}
- tests__mocks__\ClientOnly.js
import { defineComponent, useSlots, ref, onMounted } from 'vue'
export const ClientOnly = defineComponent(function ClientOnly() {
const slots = useSlots()
const mount = ref(false)
onMounted(() => {
mount.value = true
})
return () => (mount.value ? slots.default() : null)
})
- tests__mocks__\ua.js
// 模拟 微信 userAgent
export const mockWeixin = () => {
Object.defineProperty(window.navigator, 'userAgent', {
value:
'Mozilla/5.0 (Linux; Android 10; SM-G9730) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Mobile Safari/537.36 MicroMessenger/8.0.1.1950(0x2800013D)',
configurable: true // 允许后续修改
})
}
// 模拟 支付宝 userAgent
export const mockZhifubao = () => {
Object.defineProperty(window.navigator, 'userAgent', {
value:
'Mozilla/5.0 (Linux; Android 10; SM-G9730) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 AliApp(AlipayClient) 10.2.36.0512000 AWE/0.5.0.4 Mobile Safari/537.36',
configurable: true // 允许后续修改
})
}
测试结果与覆盖率情况
指标说明
指标 | 说明 |
---|---|
%stmts(statement coverage) | 语句覆盖率:是不是每个语句都执行了? |
%Branch(branch coverage) | 分支覆盖率:是不是每个 if 代码块都执行了? |
%Funcs(function coverage) | 函数覆盖率:是不是每个函数都调用了? |
%Lines(line coverage) | 行覆盖率:是不是每一行都执行了? |
开启单元测试校验时机
不在提交代码时做校验,而在部署发布时。
{
"scripts": {
"build": "npm run test && sh ./build.sh",
"test": "jest"
}
}
Node 内存溢出问题修复
以进行全局设置: 【1】打开一个cmd窗口; 【2】跑setx NODE_OPTIONS --max_old_space_size=10240; 【3】关闭所有cmd代码编辑器; 【4】重新打开cmd并再次运行节点命令(npm等);
参考
- https://test-utils.vuejs.org/guide/
- https://jestjs.io/docs/