最近学习了一段时间的jest 单元测试,以前也学习过,不过仅仅是一点皮毛,并且也没有在项目真正实践过。所以现在就是从头开始学习,还是挺费劲的。不过,好在项目中用上了,感觉算是入门了,就把学习中的思考和感悟记录一下。
首先是为什么要写单元测试?主要还是测试我们写的代码有没有达到预期的效果,这样产品上线后心里也有点底。如果严格按照TDD(测试驱动开发)的流程来进行开发的话,我们还会对产品功能更加熟悉。因为TDD是测试放到第一位,写代码之前,先写单元测试。怎么写测试,肯定是思考产品的各种使用场景,以及在每种场景下,会有什么效果,思考好了,测试写完了,整个产品功能也就是一清二楚了。真正写代码的时候,只是把测试场景用代码实现,这些所谓的场景就是一个个测试用例。
其次是具体怎么写单元测试。通过上面的描述,我们可以知道,测试的目的就是看看代码有没有达到我们的预期,实现产品的功能。那肯定是先引入代码,然后是运行代码,最后再和我们的预期做比较,如果和预期一致,就表明代码没有问题,如果没有达到预期, 就是代码有问题。和预期进行比较,就是断言。这也就是意味着,每一个测试中最终都会落脚到一个断言。如果没有断言(比较),测试也没有意思,代码运行完了,结果呢?不知道。再简单说一下断言,以前学习mocha 的时候,只知道它没有断言库,需要安装chai. 但一直不知道断言是什么意思, 现在想明白了。断言就是比较,判断正不正确,对不对,1 + 1 是不是等于2, 就是一个最简单的断言,1+1是计算,程序的运行,2就是期望或预期,等于就是判断或比较,具体到测试代码(jest)中,就是expect(1+1).toBe(2). 在测试程序中, 只要看到expect 什么, to 什么,这就是断言,还有可能是assert.
最后是运行测试代码。单元测试写了,肯定要运行,不运行,怎么知道结果。这里是我学习时最大的困惑,想明白了,有些问题也就迎刃而解了。当我们运行单元测试,就是在命令行中输入jest的时候,实际上是在node 环境下执行js代码。使用jest 进行单元测试,就是启动了一个node程序来执行测试代码。每当遇到测试问题的时候,先想一想这个前提,说不定,问题就解决了。
好了,说的也差不多了,那就一起来学习一下Jest 吧。Jest有一个好处,就是不用配置也能用,开箱即用,它提供了断言,函数的mock等常用的测试功能。npm install jest --save-dev, 安装jest 之后,就可以使用它进行单元测试了。打开git bash,输入mkdir jest-test && cd jest-test && npm init -y, 新建jest-test 项目并初始化为node项目。再npm install jest --save-dev, 安装jest ,现在就可以进行测试了。先从简单的函数开始学起。在项目根目录下,新建一个func.js 文件,写一个greeting 函数,由于Node并没有实现ES6 module, 使用了commonJs 的格式,为什么呢,上面说了,jest 是在Node环境下运行的。
function greeting(guest) { return `Hello ${guest}`;}
那怎么测试这个函数呢,测试代码放到什么地方呢?Jest识别三种测试文件,以.test.js结尾的文件,以.spec.js结尾的文件,和放到__test__ 文件夹中的文件。Jest 在进行测试的时候,它会在整个项目进行查找,只要碰到这三种文件它都会执行。干脆,再写两个函数,用三种测试文件分别进行测试
function greeting(guest) { return `Hello ${guest}`;}function createObj(name, age) { return { name, age }}function isTrueOrFasle(bool) { return bool}module.exports = { greeting, createObj, isTrueOrFasle}
新建一个greeting.test.js来测式greeting 函数,一个createObj.spec.js文件来测试createObj函数,新建一个__tests__ 文件夹,在里面再建一个isTrue.js 来测试isTrueOrFalse 函数, 都是在项目根目录下。 具体到测试代码的书写,jest 也有多种方式,可以直接在测试文件中写一个个的test或it用来测试,也可以使用describe 函数,创建一个测试集,再在describe里面写test或it ,在jest中,it和test 是一模一样的功能,它们都接受两个参数,第一个是字符串,对这个测试进行描述,需要什么条件,达到什么效果。第二个是函数,函数体就是真正的测试代码,jest 要执行的代码。来写一下greeting.test.js 文件,greeting 函数的作用就是当输入guest的时候,返回Hello guest. 那对应的一个测试用例就是 当输入sam 的时候,返回Hello sam. 那描述就可以这么写, should return Hello sam when input sam, 具体的测试代码就是对这个描述的代码实现,input sam 就是调用greeting 函数,传入‘sam’ 参数, should return Hello sam(应该返回Hello sam), 就是作一个断言,函数调用的返回值是不是等于Hello sam. 当然,要先引入greeting 函数,greeting.test.js 如下
const greeting = require('./fun').greeting;test('should return Hello sam when input sam', () => { let result = greeting('sam'); expect(result).toBe('Hello sam');})
这和文章开始说的一样,测试的写法为三步,引入测试内容,运行测试内容,最后做一个断言进行比较,是否达到预期。Jest中的断言使用expect, 它接受一个参数,就是运行测试内容的结果,返回一个对象,这个对象来调用匹配器(toBe) ,匹配器的参数就是我们的预期结果,这样就可以对结果和预期进行对比了,也就可以判断对不对了。按照greeting测试的写法,再写一下createObj的测试,使用it
const createObj = require('./fun').createObj;it('should return {name: "sam", age: 30} when input "sam" and 30', () => { let result = createObj('sam', 30); expect(result).toEqual({name: 'sam', age: 30}); // 使用toEqual})
最后是isTrueOrFalse函数的测试,这里要使用describe(). 因为这个测试分为两种情况,一个it 或test搞不定。对一个功能进行测试,但它分为多种情况,需要个多个test, 最好使用descibe() 把多个test 包起来,形成一组测试。只有这一组都测试完成之后,才能说明这个功能是好的。它的语法和test 的一致,第一个参数也是字符串,对这一组测试进行描述, 第二个参数是一个函数,函数体就是一个个的test 测试。
const isTrueOrFasle = require('../fun').isTrueOrFasle;describe('true or false', () => { it('should return true when input true', () => { let result = isTrueOrFasle(true); expect(result).toBeTruthy(); // toBeTruthy 匹配器 }) test('should return false when input fasle', () => { let result = isTrueOrFasle(false); expect(result).toBeFalsy(); // toBeFalsy 匹配器 })})
三个测试写过完,那运行一个看看,对不对。可以在package.json中的scripts 的test 改成 jest, 然后npm run test 进行测试。不过,对于这种只运行一次的,可以使用npx 命令,在命令行中输入npx jest, 它就会本地的node 包中寻找jest 命令并启动, 可以看到三个测试都通过了。 修改一下,让一个测试不通过,比如isTrue.js中把第一个改成false,
it('should return true when input true', () => { let result = isTrueOrFasle(false); expect(result).toBeTruthy(); // toBeTruthy 匹配器 })
再运行npx jest 命令,
可以看到失败了,也指出了失败的地方,再看一下它的描述,它把组测试放到前面,后面是一个测试用例的描述,这样,我们就很轻松看到哪一个功能出问题了,并且是哪一个case. 这也是把同一个功能的多个test case 放到一起的好处。
我们再把它改回去,再执行npx jest,如果这样改动测试,每一次都要执行测试的时候,使用npx jest 就有点麻烦了,jest 提供了一个watchAll 参数,会对测试文件以及测试文件引用的源文件进行实时监听,如果有变化,立即进行测试。输入npx jest --watch 也可以,不过命令参数多了,还是写一个npm script 比较好。package.json中的 test 写成jest --watchAll
"scripts": { "test": "jest --watchAll"}
npm run test, 就可以启动jest 的实时测试了。当然你也可以随时停止掉,按q 键就可以。
jest 的基本测试就差不多了,现在再来看看它的异步代码的测试, 先把所有的测试文件删掉,再新建一个fun.test.js 文件,现在就只有fun.js 和 fun.test.js 了。处理异步以前是用的回调函数,现在都用promise 了。
回调函数
最常见的回调函数就是ajax请求,返回数据后执行成功或失败的回调。在Node 环境下,有一个npm 包request, 它可以发送异步请求,返回数据后调用回调函数进行处理,和jq 的使用方式一样。先安装一下,npm i request --save. 然后fun.js 修改如下
const request = require('request');function fetchData(callback) { request('https://jsonplaceholder.typicode.com/todos/1', function (error, response, body) { callback(body); });}module.exports = fetchData;
可以先在fun.test.js中引入执行一下,看一下效果。
const fetchData = require('./fun');fetchData((data) => { console.log(data);})
在命令行中,输入node fun.test.js 可以看到输出了data. 那怎么测试,肯定调用fetchData, 那就先要创建一个回调函数传给它,因为fetchData获取到数据后,会调用回调函数,那么就可以在回调函数中创建一个断言,判断返回的数据是不是和期望的一样。fun.test.js 文件修改为测试代码。
const fetchData = require('./fun');test('should return data when fetchData request success', () => { function callback(data) { expect(data).toEqual({ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }) } fetchData(callback);})
执行npm run test 命令,看到pass 测试成功。其实并没有达到预期的效果,在callback 函数中加入console.log(data) , jest 会重新跑一遍测试,因为开启了watch模式,你会发现,它并没有打印出data,callback 函数都没有调用。这是为什么,我在这个地方想了好久,主要还是对异步了解的不够。当执行jest 测试的时候,实际上是node 执行test函数体的代码,首先看到callback的函数声明,它声明函数,然后看到fetchData() ,它就调用这个函数,请求https://jsonplaceholder.typicode.com/todos/1 接口,这个时候,getTodo函数就执行了完了。你可能会想,回调函数都没有执行,这个函数怎么算执行完了呢?回调函数并不是代码执行的,而是node 帮我们执行的。异步的请求,可以看作是一个对话,
执行fetchData: " hi, node, 你帮我执行一个请求,如果请求成功,就执行这个回调函数"
node: "好,我帮你请求”
然后node 就请求了,然后实时监听请求的状态,如果返回数据,它就把回调函数插入到它的异步队列中。Node的事件循环机制,就把这个函数执行了。
这时再看异步函数,其实,异步函数的作用,只是一个告知的作用,告知环境来帮我做事情,只要告知了,函数就算执行完了,其它剩下的事情,请求接口,执行回调函数,就是环境的事了。
只要一告知,getTodo 函数就执行完了,继续向下执行,由于函数的执行是该测试的最后一行代码,它执行完之后,这个测试就执行完了,没有错误,jest 就认为是成功的 pass.但是该测试并没有覆盖到callback函数的调用,实际上在背后,node是帮我们发送请求,执行callback 的。这也就是官网说的,By default, Jest tests complete once they reach the end of their execution. That means this test will not work as intended: The problem is that the test will complete as soon as fetchData
completes, before ever calling the callback.
那怎么办,官方的建议是使用done. 就是test的第二个参数接受一个done, 然后在callback 里面加done(), 如下所示
test('should return data when fetchData request success', (done) => { function callback(data) { console.log(data); expect(data).toEqual({ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }) done(); } fetchData(callback);});
加上done 是什么意思呢?等待。当加上done 以后,这个测试有没有执行完的依据就不是执行到最后一行代码,而是看done() 有没有被调用。当node执行到fetchData(callback) 这一行代码的时候,默认情况下,它认为这个测试执行完了,就执行下一个测试,但有了done就不一样了,它执行完这一行代码后,并不会继续执行下一个测试,而是等待,等待done() 函数的执行,只有done()函数执行了,它才会执行下一个测试,当然它也不会一直等着,默认是5s,如果5s后done 还没有执行,它就执行下一个测试,这也表明测试失败了。
这时候,再跑我们的测试,也报错了,那是因为断言写错了,表明callback 调用了,达到了预期的效果。data 是一个字符串,toEqual了一个对象,所以测试失败了。Json parse 一个data 就可以了。
expect(JSON.parse(data)).toEqual({ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false })
测试通过了。但有时,网落太慢或没有网的时候, 你再跑这个测试,你会现一个
这就是5s内没有调用 done() 测试失败的例子。这也给我们提个醒,如果有done 参数,一定要在测试的某个地方触发done. 所以测试回调函数有两点要注意,一个是使用done 作为参数,一个是在测试的某个地方触发或者调用done()
Promise
Promise 相对好测试一点,因为promise 可以使用then的链式调用。只要等待它的resolve, 然后调用then 来接受返回的数据进行对比就可以了,如果没有resolve 肯定是失败了。等待resolve,在测试中是使用的return, return Promise 的调用,就是等待它的resolve. 把fetchData 函数转化成使用promise 进行请求
const request = require('request');function fetchData() { return new Promise((resolve, reject) => { request('https://jsonplaceholder.typicode.com/todos/1', function (error, response, body) { if (error) { reject(error); } resolve(body); }); })}module.exports = fetchData;
测试函数改为
test('should return data when fetchData request success', () => { return fetchData().then(data => { expect(JSON.parse(data)).toEqual({ "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }) })});
进行promise测试的时候,在测试代码中,一定要注意使用return, 如果没有return,就没有等待,没有等待,就没有resolve,then 也就不会执行了,测试效果达不到。如果你想测试error, 把测试代码改成error ,
test('should return err when fetchData request error', () => { return fetchData() .catch(e => { expect(e).toBe('error') })});
jest 显示pass, 但这个error 的测试并没有执行,因为 fetchData 返回数据了,没有机会执行catch error。按理说,这种情况要显示fail,表示没有执行到。怎么办,官网建议使用expect.assertions(1); 在测试代码之前,添加expect.assertions(1);
test('should return err when fetchData request error', () => { expect.assertions(1); // 测试代码之前添加 return fetchData() .catch(e => { expect(e).toBe('error') })});
这时jest 显示fail 了。expect.assertions(1); 表示要执行一次断言。后面的数字表示,在一个test中,执行断言的次数,执行多少次断言,就是进行多少次对比,后面的数字就是几。如果没有执行catch,也就没有执行断言,和 这里的1不符,也就达到了测试的目的。
对于promise的测试,还有一个简单的方法,因为promise 只有两种情况,一个是fullfill, 一个是reject,expect() 方法提供了resolves 和rejects 属性,返回的就是resolve和reject的值,可以直接调用toEqual等匹配器。看一下代码就知道了
test('should return data when fetchData request success', () => { return expect(fetchData()).resolves.toMatch(/userId/); // 直接在expect函数中调用 fetchData 函数});test('should return err when fetchData request error', () => { return expect(fetchData()).rejects.toBe('error');});
还可以使用async/await 对promise 进行测试,因为 await后面的表达式就是promise. 这时test的第二个函数参数就要加上async 关键字了。
test('should return data when fetchData request success', async () => { let expectResult = { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }; let data = await fetchData(); expect(JSON.parse(data)).toBe(expectResult); // 直接在expect函数中调用 fetchData 函数});test('should return err when fetchData request error', async () => { expect.assertions(1); try { await fetchData(); } catch (e) { expect(e).toBe('error'); }});
当然,也可以把async/await 与resolves 和rejects 相结合,
test('should return data when fetchData request success', async () => { await expect(fetchData()).resolves.toMatch(/userId/); // 直接在expect函数中调用 fetchData 函数});test('should return err when fetchData request error', async () => { await expect(fetchData()).rejects.toBe('error');});
Mock 函数
进行单元测试时,有时你会发现,要测试的内容有依赖,比如上面的异步请求,依赖网络,很可能造成测试达不到效果。那怎么办? 最好就是把依赖变成可控的内容,这就用到了Mock。Mock 就是通过把依赖替换成我们可控的内容,实现测试的内容和它的依赖项隔离。那怎么才能把依赖替换成我们可控的内容,那就是使用Mock 函数。在jest中,当我们谈论Mock的时候,其实谈论的就是Mock 函数。Mock函数就是一个虚拟的或假的函数,用来实现依赖的全部功能,从而起到代换的作用。在jest 创建一个Mock 函数最简单的方法就是调用jest.fn() 方法。有了这个函数(虽然是假的,但是也是函数),就可以调用这个函数,然后判断这个调用是不是达到预期,比如,调用了多少次,调用传参是不是和预期一致,为此每一个mock 函数还有一个mock 属性。写一个测试
Jest coverage 会生成一个测试报告,在report下, 可以看到有测试了哪些目录,点击目录,可以看到具体的文件,打开测试文件以后,在每一行前面会标有1x, 6x 等等,这表示这行代码执行了多小次。在代码内容上,它还有 一些标识, 比如 黑色的方块I,E, 还有黄色的标识,这都表示这个branch 没有测试,标红的代码则是直接没有测试到,需要我们去覆盖。