前言对于现在的前端工程来说,一个标准完整的项目,通常单元测试是非常必要的。但是很多时候我们只是完成项目而忽略了项目测试。我觉得很大的一个原因是很多人对单元测试的了解不够,所以写了这篇文章。一方面,希望这篇文章能让你对单元测试有一个初步的了解。另一方面,希望通过代码实例,让大家掌握编写单元测试的实践能力。为什么前端需要单元测试?必要性:JavaScript缺乏类型检查,编译时无法定位错误。单元测试可以帮助你测试各种异常情况。正确性:测试可以验证代码的正确性,让你在上线前有个底线。自动化:虽然内部信息可以通过控制台打印出来,但这是一次性的事情。接下来的测试需要从头开始,效率无法保证。通过编写测试用例,可以一次编写,多次运行。保证重构:互联网行业产品迭代速度非常快,迭代之后必然有一个代码重构的过程,那么如何保证重构代码的质量呢?有测试用例做后盾,可以大胆重构。以下是调查样片,抽样依据如下:对200名利益相关者进行了在线问卷调查,其中70人回答了问卷中的问题,前端人员占比81.16%。有兴趣的也可以帮我填写以下问卷(https://www.wjx.cn/vm/Ombu9q1.aspx)数据收集日期:2021.09.21—2021.10.08目标人群:所有开发者组织规模:50人以下,50到100人,100人以上你有没有执行过JavaScript单元测试?该调查的另一个有趣见解是,单元测试在大型组织中更受欢迎。其中一个原因可能是由于需要处理大型组织中的大型产品,以及频繁的功能迭代。这种持续迭代的方法迫使他们投资于自动化测试。更具体地说,单元测试有助于提高产品的整体质量。此外,报告显示,超过80%的人认为单元测试可以有效提升质量,超过60%的人使用过Jest编写过前端单元测试,超过40%的人认为单元测试覆盖率很重要,覆盖率应该大于80%。常用的单元测试工具最常用的前端单元测试框架是Mocha(https://mochajs.cn/)和Jest(https://www.jestjs.cn/),但是我推荐大家使用Jest,因为Jest与Mocha相比,在githubstarts&issues和npm下载量方面优势明显。githubstars和npmdownloads的实时数据见:jestvsmocha(https://www.npmtrends.com/jest-vs-mocha)截图日期为2021.11.25Githubstars&issuesnpmdownloadsJest有一个下载量大,部分原因是create-react-app脚手架默认内置了Jest,大部分react项目都是用它生成的。从githubstarts&issues和npmdownloads来看,Jest的关注度更高,社区也更活跃。Framework对比framework断言Mocha不支持异步代码覆盖(需要其他库支持)和friendly不支持(需要其他库支持)Jest默认支持Mocha。它具有良好的生态,但需要更多的配置才能实现高扩展性。Jest开箱即用,比如写sum函数的用例。/sum.jsfunctionsum(a,b){returna+b;}module.exports=sum;Mocha+Chaimode导入chai或其他断言库进行断言,如果需要查看覆盖率报告还需要安装nyc或其他覆盖率工具。/test/sum.test.jsconst{expect,assert}=require('chai');constsum=require('../sum');describe('sum',function(){it('加1+2等于3',()=>{assert(sum(1,2)===3);});});Jest方法Jest默认支持断言,也默认支持覆盖率测试。/test/sum.test.jsconstsum=require('./sum');describe('求和函数测试',()=>{it('sum(1,2)===3',()=>{expect(sum(1,2)).toBe(3);});//这里test和it没有明显区别,it指的是:应该是xxx,test指的是testxxxtest('sum(1,2)===3',()=>{expect(sum(1,2)).toBe(3);});})可见Jest在流行度和写法上都有很大的优势,所以推荐大家使用开箱即用的Jest。如何开始?1、安装依赖npminstall--save-devjest2。简单示例首先,创建一个sum.js文件。/sum.jsfunctionsum(a,b){returna+b;}module.exports=sum;创建一个名为sum.test.js的文件,其中包含实际测试内容:./test/sum.test.jsconstsum=require('../sum');test('adds1+2toequal3',()=>{期望(总和(1,2)).toBe(3);});将以下配置部分添加到您的包中。jsoninside{"scripts":{"test":"jest"},}运行npmruntest,jest会打印如下信息3.不支持部分ES6语法nodejs使用CommonJS模块化规范,使用require导入模块;而import是ES6的模块化规范关键字如果要使用import,必须引入babelescape支持,通过babel编译,使其成为node模块化代码。比如下面这个文件改写成ES6后,运行npmruntest会报错。/sum.jsexportfunctionsum(a,b){returna+b;}./test/sum.test.jsimport{sum}from'../sum';test('加1+2等于3',()=>{expect(sum(1,2)).toBe(3);});为了使用这些新特性,我们需要使用babel将ES6转ES5的语法解决方案安装依赖npminstall--save-dev@babel/core添加.babelrc{"presets":["@babel/preset-env"]}到@babel/preset-env根目录并再次运行npmruntest。解决问题的原则是在运行jest(jest-babel)时在内部执行,检查是否安装了babel-core,然后将.babelrc中的配置拿过来运行测试。结合babel转换测试用例代码,然后进行测试。4.测试ts文件jest需要使用.babelrc解析TypeScript文件然后测试。安装依赖npminstall--save-dev@babel/preset-typescript**Rewrite**.babelrc{"presets":["@babel/preset-env","@babel/preset-typescript"]}顺序解决编辑器对jest类型错误的断言方法,比如test,expect错误,还需要安装npminstall--save-dev@types/jest./get.ts/***访问嵌套对象,避免类似user&&user.personalInfo在代码中?user.personalInfo.name:null*/exportfunctionget(object:any,path:Array<;编号|string>,defaultValue?:T):T{constresult=path.reduce((obj,key)=>obj!==undefined?obj[key]:undefined,object);返回结果!==undefined?result:defaultValue;}./test/get.test.tsimport{get}from'./get';test('测试嵌套对象中可枚举属性line1的存在',()=>{expect(get({id:101,email:'jack@dev.com',personalInfo:{name:'Jack',address:{line1:'westwishst',line2:'washmasher',city:'wallas',state:'WX'}}},['personalInfo','address','line1'])).toBe('westwishst');});运行npmruntest5。持续监控为了提高效率,可以通过添加启动参数的方式让jest持续监控文件的修改,而不用在每次修改后重新执行测试用例。重写package.json"scripts":{"test":"jest--watchAll"},effect6.生成测试覆盖率报告什么是单元测试覆盖率?单元测试覆盖率是衡量软件测试的指标,是指完成单元测试的代码占所有功能代码的比例。有许多自动化测试框架工具可以提供这种统计数据。最基本的计算方法是:单元测试覆盖率=被测代码行数/被测代码总行数*100%如何生成?添加jest.config.js文件module.exports={//是否显示覆盖率报告collectCoverage:true,//告诉jest哪些文件需要通过单元测试testcollectCoverageFrom:['get.ts','sum.ts','src/utils/**/*'],}再次运行效果参数解释参数名称含义解释%Stmts语句覆盖率是否每条语句都执行?%分支覆盖率每个if代码块都执行了吗?%Funcs函数覆盖率每个函数都调用了吗?是否对每一行都执行了%Lines行覆盖?设置单元测试覆盖率阈值个人认为既然项目中集成了单元测试,那么关注单元测试的质量是非常有必要的,而覆盖率在一定程度上客观的反映了单元测试的质量.同时,我们还可以通过设置单元测试阈值的方式来提示用户是否达到了预期的质量。jest.config.js文件module.exports={collectCoverage:true,//是否显示覆盖率报告collectCoverageFrom:['get.ts','sum.ts','src/utils/**/*'],//告诉jest哪些文件需要通过单元测试testcoverageThreshold:{global:{statements:90,//确保每个语句都被执行functions:90,//确保每个函数都被调用branches:90,//确保everyAllif等分支代码都执行过},},上面的阈值要求我们的测试用例足够,如果我们的用例不充足,下面的错误会帮助你改进7.如何写单元测试下面我们使用fetchEnv方法作为案例,编写了一套完整的单元测试用例,供读者参考。编写fetchEnv方法。/src/utils/fetchEnv.tsfile/***环境参数枚举*/enumIEnvEnum{DEV='dev',//DevelopmentTEST='test',//testPRE='pre',//预发布PROD='prod',//production}/***根据链接获取当前环境参数*@param{string?}url资源链接*@returns{IEnvEnum}环境参数*/export函数fetchEnv(url:string):IEnvEnum{constenvs=[IEnvEnum.DEV,IEnvEnum.TEST,IEnvEnum.PRE];返回envs.find((env)=>url.includes(env))||IEnvEnum.PROD;}编写对应的单元测试./test/fetchEnv.test.ts文件import{fetchEnv}from'../src/utils/fetchEnv';describe('fetchEnv',()=>{it('判断是否是dev环境',()=&g吨;{期望(fetchEnv('https://www.imooc.dev.com/')).toBe('dev');});it('判断是否测试环境',()=>{expect(fetchEnv('https://www.imooc.test.com/')).toBe('test');});it('判断是否前置环境',()=>{expect(fetchEnv('https://www.imooc.pre.com/')).toBe('pre');});it('判断是否为prod环境',()=>{expect(fetchEnv('https://www.imooc.prod.com/')).toBe('prod');});it('判断是否为prod环境',()=>{expect(fetchEnv('https://www.imooc.com/')).toBe('prod');});});执行结果8.常用的断言方法断言方法有很多,这里只介绍常用的方法,如果想了解更多可以去Jest官网API(https://www.jestjs.cn/docs/expect)部分查看.not修饰符允许您测试结果是否不等于某个值。/test/sum.test.jsimport{sum}from'./sum';test('sum(2,4)不等于5',()=>{expect(sum(2,4)).not.toBe(5);}).toEqual匹配器会递归检查对象的所有属性和属性值是否相等,是常用于检测引用类型。/src/utils/userInfo.jsexportconstgetUserInfo=()=>{return{name:'moji',age:24,}}./test/userInfo.test.jsimport{getUserInfo}来自'../src/userInfo.js';test('getUserInfo()返回的对象深度相等',()=>{expect(getUserInfo()).toEqual(getUserInfo());})test('getUserInfo()返回不同的对象内存地址',()=>{expect(getUserInfo()).not.toBe(getUserInfo());}).toHaveLength可以方便的用来测试字符串和数组类型的长度是否符合预期。/src/utils/getIntArray.jsexportconstgetIntArray=(num)=>{if(!Number.isInteger(num)){throwError('"getIntArray"只接受整型参数');}返回[...newArray(num).keys()];};./test/getIntArray.test.js./test/getIntArray.test.jsimport{getIntArray}from'../src/utils/getIntArray';test('getIntArray(3)应该返回长度为3的数组',()=>{expect(getIntArray(3)).toHaveLength(3);}).toThorw允许我们测试被测试的方法是否抛出一个异常是预期的,但需要注意的是:我们必须使用一个函数来包装被测函数,就像下面的getIntArrayWrapFn所做的那样,否则断言将失败,因为函数会抛出错误./test/getIntArray.test.jsimport{getIntArray}来自'../src/utils/getIntArray';test('getIntArray(3.3)应该抛出错误',()=>{functiongetIntArrayWrapFn(){getIntArray(3.3);}expect(getIntArrayWrapFn).toThrow('"getIntArray"只接受整型参数');}).toMatch传入一个正则表达式,可以让我们进行字符串类型的正则匹配。/test/userInfo.test.jsimport{getUserInfo}from'../src/utils/userInfo.js';test("getUserInfo(.nameshouldcontain'mo'",()=>{expect(getUserInfo().name).toMatch(/mo/i);})测试异步函数./servers/fetchUser.js/***获取用户information*/exportconstfetchUser=()=>{returnnewPromise((resole)=>{setTimeout(()=>{resole({name:'moji',age:24,})},2000)})}./test/fetchUser.test.jsimport{fetchUser}from'../src/fetchUser';test('fetchUser()可以请求一个名为moji的用户',async()=>{constdata=awaitfetchUser();expect(data.name).toBe('moji')})在这里你可能会看到这样的东西报错是因为@babel/preset-env不支持async等待。这时候需要增强babel配置,安装@babel/plugin-tran即可sform-runtime这个插件解决了npminstall--save-dev@babel/plugin-transform-runtimewhilerewriting.babelrc{"presets":["@babel/preset-env","@babel/preset-typescript"],"plugins":["@babel/plugin-transform-runtime"]}再次运行就不会报错了。ToContain匹配对象是否包含./test/toContain.test.jsconstnames=['liam','jim','bart'];test('Matchobjectcontains',()=>{expect(names).toContain('jim');})检查一些特殊值(null、undefined和boolean)toBeNull只匹配nulltoBeUndefined只匹配undefinedtoBeDefined而toBeUndefinedtoBeTruthy匹配任何if语句认为是真的toBeFalsy匹配任何if语句认为是假的检查numbertype(number)toBeGreaterThan至少大于toBeGreaterThanOrEqualtoBeLessThan小于toBeLessThanOrEqual最多(小于等于)toBeClose用于匹配浮点数(等于小数点)。以上是文章的全部内容。相信看完本文后,你已经掌握了前端单元测试的基本知识,甚至可以按照文中所教的步骤进行操作。访问项目中的单元测试