使用Jest实现vue的单元测试
目录
前言
Jest是什么?Jest是facebook开发用来对编写的代码构建测试用例进行自动化测试的一个开源javascript库。前端的测试可以分为3类:单元测试,集成测试,UI测试。本文介绍如何在vue-cli创建的项目中使用Jest进行单元测试。
文档推荐
- Jest(Jest Api)
- Vue Test Utils(Vue.js 官方的单元测试实用工具库。)
安装
npm install ts-jest babel-jest vue-jest jest-css-modules @babel/core @types/jest @vue/test-utils --save-dev
vue-jest: 用于解析vue文件
jest-css-modules: 解析样式文件
@babel/core: Jest是不支持em语法,所以需要引入@babel/core进行转化
@vue/test-utils:Vue.js 官方的单元测试实用工具库。
配置
jest.config.js
在vue项目根目录下增加jest.config.js;
module.exports = {
verbose: true,
testEnvironment: 'jsdom',
// rootDir: path.resolve(__dirname, "../../"),
preset: "ts-jest",
coverageDirectory: 'coverage',
moduleFileExtensions: ['js','jsx','ts', 'tsx', 'json', 'vue'],
setupFilesAfterEnv: ["<rootDir>/__tests__/setup-jest-env.ts"],
transform: {
"^.+\\.js$": "<rootDir>/node_modules/babel-jest",
"^.+\\.(ts|tsx)$": "<rootDir>/node_modules/ts-jest",
"^.+\\.vue$": "<rootDir>/node_modules/vue-jest"
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^libs/(.*)$": "<rootDir>/src/libs/$1",
"^bui/(.*)$": "<rootDir>/src/components/$1",
"^@orca/ui$": "<rootDir>/../../packages/ui/src/index.js",
"^plugins/(.*)$": "<rootDir>/src/plugins/$1",
"\\.(css|less|scss|sss|styl)$": "<rootDir>/node_modules/jest-css-modules"
},
testMatch: [
'**/__tests__/unit/**/*.spec.(js|jsx|ts|tsx)'
],
transformIgnorePatterns: ['<rootDir>/node_modules/(?!(byte-size))']
}
babel.config.js
const exportEnv = {
"dev": {
"presets": [
"@vue/cli-plugin-babel/preset"
]
},
"test": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
],
"plugins": [
"transform-es2015-modules-commonjs"
]
}
}
module.exports = exportEnv[process.env.NODE_ENV] || exportEnv.dev
.eslintrc.js
module.exports = {
root: true,
env: {
"browser": true,
"node": true,
"commonjs": true,
"amd": true,
"es6": true,
"mocha": true
},
extends: [
'plugin:vue/essential',
'eslint:recommended',
'@vue/typescript/recommended'
],
parserOptions: {
ecmaVersion: 2020,
},
rules: {
"no-prototype-builtins": 0,
"no-useless-escape": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-this-alias": 0,
"@typescript-eslint/no-var-requires": 0,
"no-console": 0,
"no-debugger": process.env.NODE_ENV === "production" ? 1 : 0
},
"overrides": [
{
"files": [
"**/__tests__/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"jest": true
}
}
]
}
package.json
script中增加unit命令
"unit": "vue-cli-service test:unit --coverage"
测试用例
在根目录下创建__tests__/unit文件夹,在unit下创建以spec.ts结尾的文件
功能函数测试用例
import sanitizeHtml from "sanitize-html";
/**
* @Author: xuml31350
* @description: 去除html片段内得xss攻击
* @param {string} htmlStr html 片段
* @param {any} options 配置
* @return {*} void
*/
export const sanitizeHtmlXSS = (htmlStr: string, options?) => {
options = options || {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['font']),
allowedAttributes: {
"*": ["style", "width", "height", "class"],
"a": ["href", "target"]
},
transformTags: {
'a': function (tagName, attribs) {
Object.keys(attribs).filter(attr => {
if (attr === "href" && !/^(\/|https:\/\/|http:\/\/)/.test(attribs[attr])) {
attribs[attr] = ""
}
});
return {
tagName: tagName,
attribs: attribs
};
}
}
}
return htmlStr ? sanitizeHtml(htmlStr, options) : htmlStr;
}
/**
* @Author: xuml31350
* @description: 防抖
* @param {Function} fn 函数
* @param {number} time 时间
* @return {Function} Function
*/
export const debounce = (fn: Function, time = 1000): Function => {
let timer: any;
return (...args) => {
if (timer) { clearTimeout(timer); }
timer = setTimeout(() => {
timer = null;
fn(...args);
}, time);
};
};
/**
* @Author: xuml31350
* @description: 节流
* @param {Function} fn 函数
* @param {number} time 时间
* @return {Function} Function
*/
export const throttle = (fn: Function, time = 10000): Function => {
let timer: any;
let timeStart = 0;
return (...args) => {
const newTime = Date.now();
if (newTime - timeStart >= time) {
fn(...args);
timeStart = Date.now();
return;
}
if (timer) { clearTimeout(timer); }
timer = setTimeout(() => {
fn(...args);
timeStart = Date.now();
}, time - (newTime - timeStart));
};
};
import {
sanitizeHtmlXSS,
debounce,
throttle
} from '@/libs/util';
jest.useFakeTimers()
describe("@/libs/util", () => {
it("sanitizeHtmlXSS", () => {
// 去除不正规a标签链接函数
const text = sanitizeHtmlXSS("<a href='sdf/a/b'></a>")
expect(text).toMatch("<a href></a>");
});
it("debounce", () => {
let count = 0
function a() {
count++
}
const debounceA = debounce(a);
for (let i = 0; i < 10; i++) {
debounceA()
}
jest.runAllTimers()
expect(count).toEqual(1);
})
it("throttle", () => {
let count = 0
function a() {
count++
}
const throttleA = throttle(a, 1000);
throttleA()
expect(count).toEqual(1);
throttleA()
expect(count).toEqual(1);
jest.setSystemTime(+new Date() + 2000)
throttleA()
expect(count).toEqual(2);
jest.getRealSystemTime();
})
})
组件测试
<template>
<div class="button-group" :class="`button-group-${shape}`">
<template v-for="btn in options">
<h-tooltip
v-if="tooltip || btn.tooltip"
:key="btn.value"
:content="btn.tooltip || btn.label"
:placement="placement"
>
<h-button
:disabled="btn.disabled || disabled"
:class="{ select: btn.value === value }"
@click="clickBtn(btn)"
><i
v-if="btn.icon"
class="iconfont see-iconfont"
:class="btn.icon"
style="margin-right: 6px"
></i>
<span style="vertical-align: middle">{{
btn.label || btn.value
}}</span></h-button
>
</h-tooltip>
<h-button
v-else
:key="btn.value"
:disabled="btn.disabled || disabled"
:class="{ select: btn.value === value }"
@click="clickBtn(btn)"
><i
v-if="btn.icon"
class="iconfont see-iconfont"
:class="btn.icon"
style="margin-right: 6px"
></i>
<span style="vertical-align: middle">{{
btn.label || btn.value
}}</span></h-button
>
</template>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from "vue-property-decorator";
@Component
export default class OButtonGroup extends Vue {
@Prop() options;
@Prop() value;
@Prop({ type: Boolean, default: false }) disabled;
@Prop({ type: String, default: "default" }) shape;
@Prop({ type: Boolean, default: false }) tooltip;
@Prop({ type: String, default: "bottom" }) placement;
oldSelect = "";
@Watch("value")
watchValue(val) {
this.oldSelect = val;
}
clickBtn(btn) {
if (this.oldSelect === btn.value) {
return;
}
this.$emit("on-change", btn.value, this.oldSelect, btn);
this.$emit("input", btn.value);
}
}
</script>
import ButtonGroup from "@/components-v2/o-buttom-group/button-group.vue";
import { mount } from "@vue/test-utils";
function getOptions(): any {
return [
{
label: "xx",
value: "xx",
icon: "xx"
},
{
label: "yy",
value: "yy"
}
]
}
// 使用虚拟timer
jest.useFakeTimers()
describe("@/components-v2/o-buttom-group/button-group.vue", () => {
let options = getOptions();
// 模拟emit触发的函数
const inputFn = jest.fn();
const changeFn = jest.fn();
const wrapper: any = mount(ButtonGroup, {
propsData: {
options,
value: "xx"
},
listeners: {
"input": inputFn,
"on-change": changeFn
}
})
it("default", () => {
// 验证 props正确
expect(JSON.stringify(wrapper.props().options)).toBe(JSON.stringify(options))
expect(wrapper.classes('button-group-default')).toBe(true)
expect(wrapper.props().value).toBe('xx')
const buttonWrappers = wrapper.findAllComponents({ name: "Button" })
// 验证 根据 options 渲染数量
expect(buttonWrappers).toHaveLength(2);
// 验证根据value选中
expect(buttonWrappers.at(0).classes("select")).toBe(true);
expect(buttonWrappers.at(1).classes("select")).toBe(false);
// 验证icon
const iconI = buttonWrappers.at(0).find("i.iconfont.see-iconfont");
expect(iconI.classes(options[0].icon)).toBe(true);
// 验证文本渲染
const span = buttonWrappers.at(0).find("span");
expect(span.text()).toBe(options[0].label);
});
// prop value
it("value", async () => {
await wrapper.setProps({
value: "yy"
})
const buttonWrappers = wrapper.findAllComponents({ name: "Button" })
expect(wrapper.vm.value).toBe("yy");
expect(wrapper.vm.oldSelect).toBe("yy");
expect(buttonWrappers.at(0).classes("select")).toBe(false);
expect(buttonWrappers.at(1).classes("select")).toBe(true);
});
// prop shape
it("shape", async () => {
await wrapper.setProps({
shape: "xxx"
})
expect(wrapper.classes('button-group-xxx')).toBe(true)
expect(wrapper.vm.shape).toBe("xxx")
});
// prop disabled
it("disabled", async () => {
options = getOptions();
options[0].disabled = true;
await wrapper.setProps({
options
})
const buttonWrappers = wrapper.findAllComponents({ name: "Button" });
expect(buttonWrappers.at(0).vm.disabled).toBe(true);
expect(buttonWrappers.at(1).vm.disabled).not.toBe(true);
options = getOptions();
await wrapper.setProps({
options,
disabled: true
})
expect(buttonWrappers.at(0).vm.disabled).toBe(true);
expect(buttonWrappers.at(1).vm.disabled).toBe(true);
});
// tooltip
it("tooltip", async () => {
options = getOptions();
options[0].tooltip = "xxx";
await wrapper.setProps({
options
})
let tooltipWrappers = wrapper.findAllComponents({ name: "Tooltip" });
expect(tooltipWrappers).toHaveLength(1);
const tooltipWrapper1 = tooltipWrappers.at(0);
expect(tooltipWrapper1.vm.content).toBe(options[0].tooltip);
expect(tooltipWrapper1.vm.placement).toBe("bottom");
await wrapper.setProps({
tooltip: true
})
expect(wrapper.vm.tooltip).toBe(true);
tooltipWrappers = wrapper.findAllComponents({ name: "Tooltip" });
expect(tooltipWrappers).toHaveLength(2);
const tooltipWrapper2 = tooltipWrappers.at(1);
expect(tooltipWrapper2.vm.content).toBe(options[1].label);
});
it("clickBtn", async () => {
options = getOptions();
await wrapper.setProps({
options,
value: "yy",
disabled: false
})
const buttonWrappers = wrapper.findAllComponents({ name: "Button" });
await buttonWrappers.at(1).trigger("click");
// 此时点击按钮已经是选中所以不触发emit事件
expect(inputFn).toHaveBeenCalledTimes(0);
expect(changeFn).toHaveBeenCalledTimes(0);
await buttonWrappers.at(0).trigger("click");
// 模拟的emit事件被触发
expect(inputFn).toHaveBeenCalled();
expect(changeFn).toHaveBeenCalled();
});
})
测试报告指标含义
- %stmts是语句覆盖率(statement coverage):是不是每个语句都执行了?
- %Branch分支覆盖率(branch coverage):是不是每个if代码块都执行了?
- %Funcs函数覆盖率(function coverage):是不是每个函数都调用了?
- %Lines行覆盖率(line coverage):是不是每一行都执行了?