blog icon indicating copy to clipboard operation
blog copied to clipboard

让低代码开发更稳健:Vue 3 组件测试实战—Jest 与 @vue/test-utils 的结合

Open yanyue404 opened this issue 5 months ago • 0 comments

单元测试的重要性

在低代码项目中,公共模块的复用度通常较高,为了提高代码质量,实施单元测试显得尤为重要。在持续迭代低代码平台的过程中,单元测试有助于提升自测效率,使我们能够尽早发现和修复潜在的 bug,从而保证项目的稳定性和可靠性。

低代码环境

运行环境

组件库相关配置

Vite 插件

初始化测试环境

技术方案

我们选择使用 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/

yanyue404 avatar Sep 13 '24 05:09 yanyue404