一,依赖安装
-
安装
Jest和Vue Test Utils(Vue.js官方的单元测试实用工具库 )yarn add --dev jest @vue/test-utils -
安装
vue-jest预处理器(告诉Jest如何处理*.vue文件)yarn add --dev vue-jest -
安装
babel配置的相关依赖(jest基于node环境,为了在node中使用import等ES modules语法)yarn add --dev babel-jest @babel/preset-env // Babel 7 或更高版本,你需要在你的 devDependencies 里添加 babel-bridge yarn add --dev babel-core@^7.0.0-bridge.0 // 对于使用import等方式导入*.ts文件,需要再安装babel-ts相关依赖 yarn add --dev ts-babel ts-jest @babel/preset-typescript -
安装
js-dom和 一些jest插件工具// 用 JSDOM 在 Node 虚拟浏览器环境运行测试 yarn add --dev jsdom jsdom-global // 模拟代码里import的一些静态资源,如css,svg等 yarn add --dev jest-transform-stub
二,Jest 及babel配置文件的创建和修改
-
为了让
jest开启ES modules的转译,新建一个.bablerc.js文件module.exports = { env: { // 为了仅在测试时应用这些选项,可以把它们放到一个独立的 env.test 配置项中 (这会被 babel-jest 自动获取)。 test: { presets: [ [ '@babel/preset-env', { targets: { // 告诉 babel-preset-env 面向我们使用的 Node 版本。这样做会跳过转译不必要的特性使得测试启动更快 node: 'current', }, }, ], '@babel/preset-typescript', ], }, }, }; -
在项目根目录生成一个
jest配置文件jest.config.jsmodule.exports = { // A set of global variables that need to be available in all test environments globals: { 'ts-jest': { tsconfig: { target: 'ES2019', }, }, }, // An array of file extensions your modules use moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx', 'vue'], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { '^~/(.*)$': '<rootDir>/$1', '.*(css|less|sass|scss|styl)$': '<rootDir>/__tests__/__mocks__/stub.css', }, // A list of paths to directories that Jest should use to search for files in roots: ['<rootDir>'], // The paths to modules that run some code to configure or set up the testing environment before each test setupFiles: ['<rootDir>/__tests__/global.js'], // The test environment that will be used for testing testEnvironment: 'node', // The glob patterns Jest uses to detect test files testMatch: ['**/__tests__/**/*.test.(js|jsx|ts|tsx)'], // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href testURL: 'http://localhost', // A map from regular expressions to paths to transformers transform: { '^.+\.vue$': 'vue-jest', '.+\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'jest-transform-stub', '^.+\.js?$': 'babel-jest', '^.+\.(ts|tsx)$': 'ts-jest', }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation transformIgnorePatterns: ['/node_modules/'], };
编写配置文件
jest.config.js时遇到的问题和解决方案:-
在测试代码中导入带可选链语法(class?.pass?.finished_time)语法的typescript文件时,如:
import clazz from '~/clazz.ts'时,
jest会报错SyntaxError: Unexpected token '.'。原因是在ts的配置文件中:// ts.config.js: target: "esnext"所以
jest跑的时候,ts的编译器忽略了?.这个语法,因此要在配置文件中告诉jest, 把ts按照es2019的标准去编译// A set of global variables that need to be available in all test environments globals: { 'ts-jest': { tsconfig: { target: 'ES2019', }, }, }, -
jest无法识别由webpack等工具配置好的一些路径的别名, 如在项目代码中有~等路径别名:// vue.config.js // 自定义webpack配置 configureWebpack: { resolve: { alias: { '~': path.resolve(__dirname, '../../'), 'assets': path.resolve(__dirname, '../../assets'), }, }, },这里需要在配置文件
jest.config.js中通过moduleNameMapper做一个映射:// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module moduleNameMapper: { '^~/(.*)$': '<rootDir>/$1', // 同时,在实践中发现了通过import语法导入的css文件在jest运行时会报错说找不到文件,因此也做一个css文件的映射 '.*(css|less|sass|scss|styl)$': '<rootDir>/__tests__/__mocks__/stub.css', }, -
对于每份测试代码来说,有一些公共的配置,为了不重复编写这些代码,我们可以利用
setupFiles这个配置项:setupFiles: ['<rootDir>/__tests__/global.js'], // global.js' // 全局引入js-dom, vue, vue-test-utils等依赖 require('jsdom-global')(); const testUtils = require('@vue/test-utils'); const Vue = require('vue'); // 用jest模拟axios jest.mock('axios'); // 定义一些windows上的属性/方法 window.Bus = new Vue(); // 一些公有工具函数的导入 import('~/plugins/base/utils.ts').then(() => { // console.log('import utils ==> successed'); });
三, 利用
jest模拟重点模块-
模拟一些挂载在Vue实例上的插件
实际上,在项目中有许多像
this.$toast,this.$http这样的挂载在全局Vue实例当中的插件。如果不对这些插件进行模拟的话,在运行测试脚本时,会出现找不到this.$toast这样的报错。我们可以使用jest来模拟这些插件,甚至随心所欲地改变他们的实现。import { createLocalVue } from '@vue/test-utils'; const localVue = createLocalVue(); const toast = {}; toast.install = (Vue) => { // 把 this.$toast 模拟成 console.log Vue.prototype.$toast = jest.fn(function() { console.log('toast:', ...arguments); }); }; localVue.use(toast); const docWarpper = shallowMount(doc, { localVue, }); -
模拟Axios的返回
在课堂模块中,我们不希望测试脚本真正地发出一个学习上报的请求,因为这样前端无法对返回的数据进行干预和模拟。我们期望返回的数据可以在测试脚本中由前端来决定:比如,我们希望上报接口去返回当前课程的学习时长为1分钟
import Axios from 'axios'; jest.mock('axios'); Axios.put.mockResolvedValue({ data: { data: { id: 42, learn_time: 60, }, code: 0, }, }; });当然,在项目的课堂模块中,
axios其实被进一步封装成了this.$http,我们需要先简单地模拟$http的实现, 进一步优化为:import Axios from 'axios'; import { createLocalVue } from '@vue/test-utils'; jest.mock('axios'); const mockLearnTimeReturn = (learnTime) => { return { data: { data: { id: 42, learn_time: learnTime, }, code: 0, }, }; }; const localVue = createLocalVue(); const axios = {}; axios.install = (Vue) => { Vue.prototype.$http = Axios; }; localVue.use(axios); const docWarpper = shallowMount(doc, { localVue, }); // 接口返回learn_time为 60 Axios.put.mockResolvedValue(mockLearnTimeReturn(60)); // 接口返回learn_time为 120 Axios.put.mockResolvedValue(mockLearnTimeReturn(120)); -
模拟组件中的函数并监听其调用情况
当我们需要监控课堂中的
report函数的调用次数时,我们可以使用jest.fn()对其进行包装:docWarpper = shallowMount(doc); // 包裹关键函数,来监控他们的调用情况 docWarpper.vm.report = jest.fn(docWarpper.vm.report); // 期望report函数被调用了4次 expect(docWarpper.vm.report).toHaveBeenCalledTimes(4);实际上,
jest.fn还可以用于修改组件内函数的具体实现,使我们在不改变业务逻辑的情况下更随心所欲地进行测试docWarpper.vm.report = jest.fn(() => { console.log('Have been reported'); }); docWarpper.vm.report(); // console: Have been reported -
模拟定时器Timer
课堂模块如今是每隔30s进行一次学习上报的,我们可以利用
jest去模拟js Timer,可以做到一些意想不到的事情:jest.useFakeTimers(); // 计时器快进120s jest.advanceTimersByTime(1000 * 120); // 用同步的方法执行所有计时器 jest.runAllTimers(); // 只执行pending状态下的计时器 jest.runOnlyPendingTimers();
四,代码逻辑梳理和测试样例编写
在理解上面的一些基础
API的作用和使用方法后,可以开始尝试梳理项目课堂模块的代码逻辑,并编写简单的测试脚本来验证成果:课堂模块的主要流程图为:
-
根据流程图,编写出以下的测试样例,此测试样例主要用于验证:
-
此文档课程的剩余学习时间为
2min -
在
2min内,学习上报了4次 -
在倒计时为0时,
checkCountDownAccuracy函数被调用了,用于检查后端返回的时间是否与前端倒计时一致 -
检查完成后,前端能正常显示剩余学习时间为0,学习结束
-
学习结束后,
refreshPointStatus函数应该被调用了1次, 用于轮询关卡状态test('测试文档课堂学习上报-(120秒内应该上报4次)', (done) => { jest.useFakeTimers(); // 渲染文档课堂组件, getDocMountOptions中初始化了组件的propsData, $route, $store等数据 docWarpper = shallowMount(doc, getDocMountOptions()); // 学习时长为120秒 expect(docWarpper.vm.remainTime).toBe(120); // 开始学习倒计时 docWarpper.vm.countdown(); // 倒计时结束时Axios应该返回learn_time为 120 Axios.put.mockResolvedValue(mockLearnTimeReturn(120)); // 手动模拟已经过去了120s jest.advanceTimersByTime(1000 * 120); // report函数应该被调用了4次 expect(docWarpper.vm.report).toHaveBeenCalledTimes(4); // checkCountDownAccuracy函数应该被调用了一次 expect(docWarpper.vm.checkCountDownAccuracy).toHaveBeenCalledTimes(1); // 结束后,剩余学习时间变为0 expect(docWarpper.vm.remainTime).toBe(0); // 学习结束后应该轮询关卡状态, refreshPointStatus应该被调用了1次 expect(docWarpper.vm.refreshPointStatus).toHaveBeenCalledTimes(1); done(); });然而,此测试样例实际上是存在问题的,执行
yarn test时控制台报错如下:
-
可以看到,
checkCountDownAccuracy的确被调用了一次,但之后剩余学习时间remainTime并没有设置为0,而是为1。为了探究这个问题,我详细了解了jest中mock Timer的实现。实际上,
jest内部对于Timer模拟有一个不完善的地方:当调用jest.advanceTimersByTime这个API时,所有的Timer中的回调函数是会从异步执行转化为同步执行的。所以,jest并不会等待Timer的回调中的await, promise等异步代码的resolved。然而,我们的代码在Timer的回调函数中,其实发起了ajax异步请求,并等待接口的返回值learn_time来重置倒计时的。这就导致了测试脚本中的剩余时间remainTime永远停留在了剩余1秒时,无法被正确地修改,导致测试结果不符合预期。通过查找
jest官方代码库上的issue, 找到了暂时的解决方案:通过await让测试代码等待所有的promise resolve// jest.advanceTimersByTime 会让 timer 变成同步的方式执行,但是不会等待timer中的 promise resolve,应该每次都应该手动 resove 队列中的 promise const flushPromises = () => new Promise((res) => setImmediate(res)); test('测试文档课堂学习上报-(120秒内应该上报4次)', async (done) => { jest.useFakeTimers(); // 渲染文档课堂组件, getDocMountOptions中初始化了组件的propsData, $route, $store等数据 docWarpper = shallowMount(doc, getDocMountOptions()); // 学习时长为120秒 expect(docWarpper.vm.remainTime).toBe(120); // 开始学习倒计时 docWarpper.vm.countdown(); // 倒计时结束时Axios应该返回learn_time为 120 Axios.put.mockResolvedValue(mockLearnTimeReturn(120)); // 手动模拟已经过去了120s jest.advanceTimersByTime(1000 * 120); // 等待所有的promise resolve await flushPromises(); // report函数应该被调用了4次 expect(docWarpper.vm.report).toHaveBeenCalledTimes(4); // checkCountDownAccuracy函数应该被调用了一次 expect(docWarpper.vm.checkCountDownAccuracy).toHaveBeenCalledTimes(1); // 结束后,剩余学习时间变为0 expect(docWarpper.vm.remainTime).toBe(0); jest.runOnlyPendingTimers(); // 学习结束后应该轮询关卡状态, refreshPointStatus应该被调用了1次 expect(docWarpper.vm.refreshPointStatus).toHaveBeenCalledTimes(1); done(); });