使用Jest实现vue的单元测试

265 阅读2分钟

使用Jest实现vue的单元测试

目录

前言
文档推荐
安装
配置
测试用例

前言

  Jest是什么?Jest是facebook开发用来对编写的代码构建测试用例进行自动化测试的一个开源javascript库。前端的测试可以分为3类:单元测试,集成测试,UI测试。本文介绍如何在vue-cli创建的项目中使用Jest进行单元测试。

文档推荐

安装

  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();

  })
})

image.png

组件测试

<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();
  });
})

image.png

测试报告指标含义

  • %stmts是语句覆盖率(statement coverage):是不是每个语句都执行了?
  • %Branch分支覆盖率(branch coverage):是不是每个if代码块都执行了?
  • %Funcs函数覆盖率(function coverage):是不是每个函数都调用了?
  • %Lines行覆盖率(line coverage):是不是每一行都执行了?