Vue2接入单元测试指北

1,271 阅读6分钟

什么是单元测试

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。

单元的大小或范围,并没有一个明确的标准,可以是一个函数、方法、类、功能模块或者子系统。

单元测试的意义

  • 提升效率:可以通过 mock 数据,尽早发现问题,减少线上Bug
  • 程序优化:可以充当一个设计工具,帮助于开发人员去思考代码结构的设计,编写出更有利于测试的代码
  • 测试全面:可以覆盖到一些业务场景无法触及的测试场景,如异常处理
  • 降低风险:重构或升级基础组件时,如果依赖于该组件的高阶组件单元测试都能通过,可以降低本次变更带来的风险

适用范围

本文只适用于在Vue2版本下接入前端单元测试

  • vue: "^2.7.7"
  • @vue/test-utils: "^1.3.5"
  • jest: "^29.5.0"

选择单元测试接入方式

Vue Test Utils 是 Vue 组件单元测试的官方库,提供了非常完备的Vue组件测试支持,包括:

  • 挂载 API 创建一个内部挂载了测试组件的容器(Vue实例)
  • 挂载选项: 提供了mock外部数据的能力,如全局属性、异步行为、组件插槽等
  • 容器操作: 提供读取容器内容的能力,如容器属性、选择器、DOM事件处理、组件断言等
  • 兼容性: 兼容几乎所有的 JS 测试运行器。Vue CLI 的 webpack 模板对 Karma 和 Jest 这两个测试运行器都支持,并且在 Vue Test Utils 的文档中有一些引导

选择测试运行器

测试运行器 (test runner) 就是运行测试的程序。vue推荐使用Jestmocha-webpack。对比如下:

  • Jest

    • 配置简单: 默认安装用例执行环境JSDOM,无需额外配置
    • 功能全面: 内置断言,并提供测试覆盖率报告
    • 支持处理Vue单文件: 提供vue-jest预处理器
    • 支持TS: 支持TypeScrpit,方便测试用例编写
  • webpack + Mocha:包含了更顺畅的接口和侦听模式,方便开发者能够通过 webpack + vue-loader 得到完整的单文件组件支持。但是配置非常繁琐。

用 Jest 测试 Vue 组件

安装 Jest 和 Vue Test Utils

首先安装 Jest 和 Vue Test Utils:

$ npm install --save-dev jest @vue/test-utils

然后在 package.json 中定义一个单元测试的脚本

// package.json
{
  "scripts": {
    "test": "jest"
  }
}

为 Jest 配置 Babel

最新版本的 Node 已经支持绝大多数的 ES2015 特性,但如果测试中使用 ES modules 语法和 stage-x 的特性。需要安装 babel-jest

$ npm install --save-dev babel-jest

然后在 package.jsonjest.transform 里添加一个入口,来告诉 Jest 用 babel-jest处理 js 测试文件:

// package.json
{
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "transform": {
      "^.+\.js$": "<rootDir>/node_modules/babel-jest",
      ".*\.(vue)$": "vue-jest"
    }
  }
}

在 Jest 中处理单文件组件

Jest 要处理 *.vue 文件,需安装和配置 vue-jest 预处理器

$ npm install --save-dev vue-jest

package.json 中配置 jest 选项

// package.json
{
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "vue" // 告诉 Jest 处理 `*.vue` 文件
    ],
    "transform": {
      ".*\.(vue)$": "vue-jest" // 用 `vue-jest` 处理 `*.vue` 文件
    }
  }
}

配置Jest支持TypeScript

需要在 Jest 中设置编译 TypeScript。需安装 ts-jest

$ npm install --save-dev ts-jest @types/jest

然后在 package.json 中设置使用 ts-jest 处理 TypeScript 测试文件:

// package.json
{
  // ...
  "jest": {
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx",
      "vue"
    ],
    "transform": {
      "^.+\.js$": "<rootDir>/node_modules/babel-jest",
      "^.+\.tsx?$": "ts-jest",
      ".*\.(vue)$": "vue-jest"
    }
  }
}

最后在tsconfig.json文件中声明jest

// tsconfig.json
{
  "compilerOptions": {
    //...
    "types": ["jest"]
  }
}

其他配置

处理webpack别名

如果在 webpack 中配置了别名解析,比如把 @ 设置为 /src 的别名,在jest中也需要用 moduleNameMapper 选项配置

{
  // ...
  "jest": {
    // ...
    // 支持源代码中相同的 `@` -> `src` 别名
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1",
      "@types": "<rootDir>/src/types/index"
    }
  }
}

放置测试文件

默认情况下,Jest 将会递归的找到整个工程里所有 .spec.j(t)s.test.j(t)s 扩展名的文件。如果不符合需求,也可以在 package.json 里的jest配置项中段落中配置testRegex字段。

// package.json
{
  // ...
  "jest": {
    // ...
    "testRegex": "(/__tests__/.*|(\.|/)(test|spec))\.(jsx?|tsx?)$"
  }
}

测试覆盖率

通过 jest 配置项collectCoveragecollectCoverageFrom 来定义需要收集测试覆盖率信息的文件

// package.json
{
  // ...
  "jest": {
    // ...
    "collectCoverage": true,
    "collectCoverageFrom": ["**/src/components/** .{js,ts,vue}" ,  "! /node_modules/**"]
  }
}

测试覆盖率参数:

  • Stmts 语句覆盖率
  • Branch 分支覆盖率
  • Funcs 函数覆盖率
  • Lines 行覆盖率

提升测试用例执行效率

在Jest测试用例执行过程中,有三个地方比较耗性能:

  1. 生成虚拟文件系统:在首次启动测试时遍历整个项目生成一个Haste Map,用于热更新场景和监听文件改动
  2. 多线程:生成新线程会额外消耗资源
  3. 文件转译:Jest会在执行到测试文件时即时转译

针对上述问题的解决方法是:

  1. 无解
  2. 具体情况具体分析,非大型项目,单线程足够
  3. 使用更高效率的编译器如esbuild-jest@swc/jest
$ npm install --save-dev @swc/core @swc/jest
// package.json
{
  // ...
  "jest": {
    "transform": {
      "^.+\.js$": "<rootDir>/node_modules/babel-jest",
      "^.+\.tsx?$": "ts-jest",
      "^.+\.(t|j)sx?$": [
        "@swc/jest",
        {
          "jsc": {
            "target": "es2021"
          }
        }
      ],
      ".*\.(vue)$": "vue-jest"
    },
    "maxWorkers": 1,
  }
}

在替换编译器后,首次执行测试用例效率有较大提升

  • ts-jest

  • @swc/jest

可能遇到的问题

到了这一步,基本的配置已经完成,但是可能会因为各个依赖包的版本不匹配导致jest执行失败,可能遇到的情况如下:

未安装测试运行器运行环境jest-environment-jsdom

在介绍测试运行器时有谈到早期的jest会默认安装JSDOM的运行环境,但是在28版本后,已取消默认安装,需要自己手动安装jest-environment-jsdom,执行jest运行环境。

$ npm install --save-dev jest-environment-jsdom
// package.json
{
  // ...
  "jest": {
    // ...
    "testEnvironment": "jsdom"
  }
}

提示:Error: Cannot find module 'babel-core'

查找issue后发现,主要是因为babel-loaderbabel-core版本不匹配导致的。

  • babel-loader v8.x 应该对应 babel-core v7.x
  • babel-loader v7.x 应该对应 babel-core v6.x

所以需降低babel-core版本:

$ npm install --save-dev babel-core@^7.0.0-bridge.0

提示:ReferenceError: Vue is not defined

查找issue后发现,其原因主要是 jest-environment-jsdom@vue/test-utils配置冲突导致的。引入 jest-environment-jsdom时,jest会自动设置特定的导入方式,再引入@vue/test-utils时设置了customExportConditions配置项,导致Jest 最后使用了CommonJS的导入方式,修改如下:

// package.json
{
  // ...
  "jest": {
   // ...
   "testEnvironmentOptions": {
     "customExportConditions": ["node", "node-addons"]
   },
  }
}

提示:TypeError: Cannot destructure property 'createComponentInstance' of 'Vue.ssrUtils' as it is undefined

此问题是由于@vue/test-utilsvue版本不匹配导致的,按照官网的描述:

  • @vue/test-utils v2.x 应该对应 vue v3.x
  • @vue/test-utils v1.x 应该对应 vue v2.x

需对@vue/test-utils进行降级处理:

$ npm install --save @vue/test-utils@v1.3.5

完整依赖列表及配置

  • jest
  • jest-environment-jsdom
  • @vue/test-utils @1.3.5
  • vue-jest
  • babel-jest
  • babel-core @7.0.0-bridge.0
  • ts-jest
  • @types/jest
  • @swc/core
  • @swc/jest
// package.json
{
  // ...
  "jest": {
    "testEnvironment": "jsdom",
    "testEnvironmentOptions": {
      "customExportConditions": [
        "node",
        "node-addons"
      ]
    },
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx",
      "vue"
    ],
    "transform": {
      "^.+\.(t|j)sx?$": [
        "@swc/jest",
        {
          "jsc": {
            "target": "es2021"
          }
        }
      ],
      ".*\.(vue)$": "vue-jest"
    },
    "transformIgnorePatterns": [
      "<rootDir>/node_modules/(?!(element-ui/src/mixins/emitter))"
    ],
    "moduleNameMapper": {
      "^@/(.*)$": "<rootDir>/src/$1",
      "^@types/(.*)$": "<rootDir>/src/types/index"
    },
    "maxWorkers": 1,
    "collectCoverage": true,
    "collectCoverageFrom": ["**/src/components/** .{js,ts,vue}" ,  "! /node_modules/**"]
  }
}

单元测试用例编写指引(测试用例编写过程遇到的坑点)

在测试组件中使用了第三方组件或其他基础组件,如Element UI等

报错场景包括:

  • 测试组件使用了全局定义的第三方组件
Vue.use(ElementUI, { locale, size: 'small' })
  • 测试组件按需引入第三方组件
import { Input } from 'element-ui'
  • 自定义基础组件
import CustomerInput from '@/components/CustomerInput.vue'

解决方式如下:

  1. 在挂载选项中注册对应的组件
const wrapper = mount(CustomerInputNumber, {
  components: {
    'el-input': Input,
    'CustomerInput': CustomerInput
  }
});

其中,注册组件可以是自定义的模拟组件,也可以在测试用例中按需引入的第三方组件

import CustomerInput from '@/components/CustomerInput.vue'

const mockComponent = {
  template: "<div><slot></slot></div>",
  props: {
    color: String
  }
};

const wrapper = mount(CustomerInputNumber, {
  components:{
    'el-input': mockComponent,
    'CustomerInput': CustomerInput
  }
});
  1. 使用临时Vue实例全局注册组件
import { createLocalVue } from '@vue/test-utils'
import { Input } from 'element-ui'

const localVue = createLocalVue()
localVue.use(Input)

const wrapper = mount(CustomerInputNumber, {
  localVue
});

在测试组件中直接使用node_modules中的文件

例如,在测试组件中,直接使用了node_modules中Emitter组件。但是在Jest转译时,会默认将node_modules忽略,所以当测试用例执行到该行时,会因为获取不到导出的Emitter组件而报错。

在报错信息中,Jest给出的处理建议,是将Emitter组件所在目录加入到转译文件中,需要对Jest做如下配置:

// package.json
{
  // ...
  "jest": {
   // ...
    "transformIgnorePatterns": [
      "<rootDir>/node_modules/(?!(element-ui/src/mixins/emitter))"
    ],
  }
}

在测试组件的Constructer中使用全局依赖

如果提示在测试组件中找不到挂载在全局实例上的方法,如 lodash.throttle() 而在实际的测试用例运行时,已经传入了loadshmocks注册,并且在测试组件的mounted生命周期中,Vue实例上可获取到mocks注册的loadsh属性。

// 测试用例
import lodash from 'lodash'

const wrapper = mount(TestComponent, {
  mocks: {
    "_": lodash
  },
});
// 测试组件
export default class TestComponent extends Vue {
  // private onScrollListener = this._.throttle(this.onScroll, 300) // 先注释报错行

  private mounted () {
    console.log(this._) // 可以正常输出
  }
}

原因是,运行测试用例挂载(mountshallowMount)测试组件时,如果测试组件使用了全局注册的属性或方法,只能在mounted生命周期后才能获取到由测试用例mocks注册的模拟属性或方法。而Constructor生命周期在mounted之前执行,此时去获取就会提示undefined

解决方法是:在测试组件中按需引入lodash属性,不使用全局注册,这样就能在Constructor生命周期中获取到依赖:

// 测试组件
import _ from 'lodash'

export default class TestComponent extends Vue {
  private onScrollListener = _.throttle(this.onScroll, 300)
}