Jest单元测试进阶
Jest 命令行窗口中的指令
在学习Jest单元测试入门的时候,给Jest命令提供了一个参数 --watchAll, 让它监听测试文件或测试文件引入的文件的变化,从而时时进行测试。但这样做也带来一个问题,只要改变一点内容,Jest就会把所有的测试都跑一遍,有点浪费资源。有没有可能对--watchAll模式进行进一步的优化?在命令窗口中执行npm run test 看一看就知道了, 测试完成后,你会发现还有很多提示(Watch Usage),这些就是对--watchAll模式的优化
Press f to run only failed tests. 按f 键,只测试以前失败的测试。执行npm run test 的时候,发现有一个测试失败了,这时我们只想测试这个失败的测试,可以按f了。演示一下,随便把一个测试用例改为错误,比如 把request 的mock 改为name: 'jason'
jest.mock('request', () => { return (url, callback) => { callback(null, 'ok', {name: 'jason'}) } });
测试重新跑了一遍了(watchAll 模式),命令窗口中显示了错误, 并且在最下面显示press w to show more, 同时光标在闪烁,等待输入。此时按w, 显示了上图中的内容,再按f, 只跑了失败的测试,因为三个测试skipped 了, 当然肯定还是有错误,因为我们还没有修改测试代码
修改测试代码到正确并保存,它只跑了刚才失败的测试。但此时你再修改func.test.js 文件或其它测试用例,发现测试不会再运行了,显示No failed test found,按f键退出
因为f模式是测试以前,就是上一次,失败的测试,我们已经修改好了上一次失败的测试,所以它就不会再进行测试了。按f, 重新回到了watchAll 模式。
总结一下,f 模式的使用就是,npm run test 有失败测试,按f, 修改失败到成功,再按f 退出该模式。
Press o to only run tests related to changed files. 按o ,只会去测试和当前被改变文件相关的测试。但这时,你按o,发现报错了。为什么呢?因为让Jest 去测试改变文件中的测试,但Jest它自己并不知道哪个文件发生了变化,Jest本身,不具备比较文件改变的功能,那怎么办?需要借助git. 因为git 就是追踪文件变化的,只要把工作区和仓库区的代码一对比,就知道哪个文件发生变化了。因此需要把项目变成git 项目。在根目录下,先建.gitignore 文件,再执行git init, 把项目变成git 项目,否则会把node_modules 放到 git 仓库中。为了演示,把fetchData的测试从func.test.js拆分为出来,就叫fetchData.test.js
jest.mock('request', () => { return (url, callback) => { callback(null, 'ok', {name: 'sam'}) } }); const fetchData = require('./func').fetchData; test('should return data when fetchData request success', () => { return fetchData().then(res => { expect(res).toEqual({name: 'sam'}) }) })
git add . and git commit -m "init git" 把文件提交到git 仓库。执行npm run test 启动测试,按o 进入到o 模式, 可以看到如下提示,没有文件发生变化。
这时更改一个文件,如forEach 加一个空行,看一下控制台,只有func.test.js 测试文件执行了,其它测试文件并没有执行, 再改一个fetchData.test.js 文件,两个测试文件执行了,还是只跑改变文件中的测试。这时让我想起了jest 命令的另一个参数,--watch, o 模式 不就是--watch 吗。把package.json 中的 --watchAll 改成 --watch
"scripts": { "test": "jest --watch" },
重新启动npm run test, 有了-a模式,run all the tests, 这不就是--watchAll, 原来 --watch, --watchAll, a 模式,o 模式,是这样互通的。
再看一下p, 按照文件名执行测试,我们提供一个文件名,它只会对该文件进行测试,可以使用正则表达式来匹配文件名。按p, 提示输入pattern, 再输入fetch, 它就会用fetch 去匹配所有的测试文件名,找到了fetchData.test.js 测试文件,然后它就执行了。如果找不到任何测 试文件,它什么测试都不会执行。
t 则是匹配的test 名字,每一个test 都有一个描述,这个描述可以称之为test 的名字。提供一个test 的名字,它只跑这个test,用法和p 一样。
q 退出watch, enter 就是跑一次单元测试,无论是在什么模式下,只要按enter,就会跑一次对应模式的测试。
Jest 快照测试
传统的单元测试通常都是,执行代码,断言比较,看看是不是和预期的效果一致。但这种断言测试在某些情况下不太合适。比如配置文件,配置文件本来就是放在那里,不用执行代码,如果进行比较断言,就是它和它本身进行比较,肯定相等,这种测试不是太好。之所以对配置文件进行测试,就是希望它不要被随便改了。如果改了,要通知到同事。还有就是UI,它就长这样,不用断言了,我们更希望UI 做好以后,不要随便改了,确定要改了,那就通知大家。对于这两种不希望被改的场景,更好的测试办法,就是把它保存起来,以后每次测试的时候,就和保存的内容进行对比,看有没有改变。把某一时刻的内容保存起来,就是形成了快照,快照就是用来保存的某一时刻的状态。Jest的快照测试也是如此,保存与对比。它提供了一个toMatchSnapshot() 方法,当第一次运行测试的时候,没有快照,那就把这一时刻的状态保存起来,形成快照。以后再运行测试的时候,它会生成一个新的那一时该的快照与以前的快照进行比较,如果两者一致,就表明内容没有变化,测试通过,如果两者不一致了,就表示内容发生改变,jest 就会报错了。新建config.js,
export const axiosConfig = { devUrl: 'local', productUrl: '/', header: { 'accet': 'json' } }
config.test.js 写一个快照测试
import { axiosConfig } from './config'; test('axios config', () => { expect(axiosConfig).toMatchSnapshot(); })
npm run test, 这时项目根目录下生成了一个文件夹__snapshots__,下面有一个config.test.js.snap, 这就是生成的快照文件,它的名字和测试文件的名字一致。打开看一看,它就是把文件内容用字符串的形式保存起来了。这时不管运行多少次npm run test, 测试都会通过,因为config.js 没有改。改变一下,比如加一下id
export const axiosConfig = { devUrl: 'local', productUrl: '/', header: { 'accet': 'json' }, id: '23' }
这时jest 报错了,如果确定要改成这样,那就需要更新快照了。命令行中按w,多了u和i, u就是表示更新失败快照,i 则表示交互式的更新快照。 此时按u, 测试重跑,更新成功。那什么是交互式的更新快照呢?它是针对多个失败的快照而言的,按i,你可以一个一个进行快照的确认和更新,如果按u,则是一次性全部更新所有快照,可能不是你想要的结果。如果两个快照失败了,一个要更新,一个不要更新,那u就无能为例了,只能使用i。在config.js再写一个配置
export const fetchConfig = { method: 'post', time: '2019' }
那测试文件中,再写一个快照测试
test('fetch config', () => { expect(fetchConfig).toMatchSnapshot(); })
这时测试肯定没有问题,同时改一下两个配置文件,id改了'24', time改为'2019/11', 2个快照测试都失败了。此时按i, 你会发现只显示一个快照失败,表示这次只确认这一个失败的快照,它也提示了u更新快照,s 跳过这个测试,q退出该交互模式,如果更新,按u,此时又显示了一个失败的快照,再按u,更新完毕。这就是交互式,一个一个的更新,更为灵活。
但有的时候,time是动态生成的,比如fetchConfig中的time 改成new Date(), 每一次跑单元测试的时候,time 都不一样,jest肯定报错。这时可以给toMatchSnapshot() 方法传递一个参数{ time: expect.any(Date) 表示什么时间都可以,就不要匹配时间了。
test('fetch config', () => { expect(fetchConfig).toMatchSnapshot({ time: expect.any(Date) }); })
Jest Manual Mock
在以前mock 函数的时候,我们都会把mock 函数的实现放到测试文件中。manual mock 则是创建一个文件夹__mocks__, 把所有mock 函数的实现放到该文件夹下,不过这里要注意 __mocks__ 文件夹的位置,你要mock 哪个文件中的函数,__mocks__文件夹就要和哪个文件放到同一级目录中。新建__mocks__文件夹之后,再在其下面新建一个和要mock的文件的同名文件,在这个文件中就可以写函数的实现。 比如我们要mock func.js 中fetchData, 那就要在func.js 同一级目录(根目录)中新建__mocks__, 然后在其下面建func.js 文件件,在func.js 中就可以mock fetchData。
当在测试文件中,jest.mock(./func.js),然后引入fetchData 时,jest 自动会到__mocks__ 目录中找func.js 文件,取里面的fetchData 函数,这就是mock的函数了。fetchData.test.js
jest.mock('./func'); const fetchData= require('./func').fetchData; test('should return data when fetchData request success', () => { return fetchData().then(res => { expect(res).toEqual({name: 'sam'}) }) })
这种mock 有一个问题,在fetchData.test.js 里面测试 一个add.
const add = require('./func').add; test('add', () => { let sum = add(3, 2); expect(sum).toBe(5) })
测试报错,add is not a function. 这是因为jest.mock('./func.js'); 整个func.js 模块被mock了,require('./func').add 的时候,它是从mock 的func.js 模块中,就是__mocks__
文件下的func.js 里面去找add, 很显然,没有,所以报错了。怎么解决,不使用require了,使用jest.requireActual(), 字面意思,require真实的,就是从真实的模块,而不是从mock 的模块中引入。 jest.requireActual('./func').add, 从真的func.js中引入add.
const add = jest.requireActual('./func').add;
有mock 就有unmock(), 取消mock。
jest.unmock('./func.js');
如果你mock 的是node_modules 第三方模块,那就要在根目录(node_modules同级目录)新建__mocks__ 文件夹,然后在其下面新建和要mock 模块同名的文件句,如mock request.js 模块,使用request.js 的时候,我们使用的是require('request'), 所以就可以在__mocks__ 文件中建一个request.js.
// 自动mock 这个模块(request) 所有暴露出来的方示 jest.genMockFromModule('request'); let request = require('request'); request = jest.fn((url, fn) => { fn('error', 'body', {name: 'sam'}); } ) module.exports = request;
当mock的node_modules中的模块时,jest 是自动mock, 执行测试的时候,如果看到你require 第三方模块,它自动会从__mocks__文件夹中找这个模块,肯定是mock的模块。
mock timer
它主要是针对定时器setTimeout, setTimeinterval 提出的。比如在代码中有一个函数需要3s 之后执行,那么在测试的时候,就要在3s以后,测试函数有没有执行,有点浪费时间
function lazy(fn) { setTimeout(() => { fn(); }, 3000); } test('should call fn after 3s', (done) => { const callback = jest.fn(); lazy(callback); setTimeout(() => { expect(callback).toBeCalled(); done() }, 3001); })
所以jest 提供了mock timer 的功能,不要再使用真实的时间在这里等了,一个假的时间模拟一下就可以了。首先是jest.useFakeTimers() 的调用,它就告诉jest 在以后的测试中,可以使用假时间。当然只用它还不行,因为它只是表示可以使用,我们还要告诉jest在哪个地方使用,当jest 在测试的时候,到这个地方,它就自动使用假时间。两个函数,jest.runAllTimers(), 它表示把所有时间都跑完。jest.advanceTimer() 快进几秒。具体到我们这个测试,我们希望执完lazy(callback) 就调用, 把lazy函数中的3s时间立刻跑完。可以使用jest.runAllTimers();
jest.useFakeTimers(); // 可以使用假函数 test('should call fn after 3s', () => { const callback = jest.fn(); lazy(callback); jest.runAllTimers(); // 在这里,把lazy函数里面的3s立即执行完 expect(callback).toBeCalledTimes(1); })
但如果我们的lazy 函数中有两个setTimeout 函数,runAllTimers 就会有问题,因为它把所有时间都跑完了,不管有几个setTimeout. 把lazy 函数改为如下
function lazy(fn) { setTimeout(() => { fn(); setTimeout(() => { fn(); }, 2000); }, 3000); }
你会发现fn 被调用了两次。但有时,只想测试最外层的setTimeout有没有被调用,这时就要用jest.advanceTimersByTime(3000)
test('should call fn after 3s', () => { const callback = jest.fn(); lazy(callback); jest.advanceTimersByTime(3000); // 快进3秒 expect(callback).toBeCalledTimes(1); })
没有问题,如果再想测试内层的setTimout 有没有被调用,再快进就好了,不过要注意快进的时间,2s, 因为它会在上一个advanceTimerByTime的时间基础上进行快进
test('should call fn after 3s', () => { const callback = jest.fn(); lazy(callback); jest.advanceTimersByTime(3000); // 快进3秒 expect(callback).toBeCalledTimes(1); jest.advanceTimersByTime(2000); // 再快进2秒 expect(callback).toBeCalledTimes(2); })
现在你会发现,如果在一开始的时候,直接快进5s,它的效果就和runAlltimers 一样了。最后一个问题,就是多个测试中都使用advanceTimersByTime,因为它是累加时间的,第二个测试的advanceTimersByTime的时间肯定会在第一个测试中的advanceTimersByTime 时间上相加。解决办法是beforeEach(). 在beforeEach 中调用jest.useFackTimers,每次测试之前,先初始化timer,把timer归零
beforeEach(() => { jest.useFakeTimers(); // 可以使用假函数 }) test('should call fn after 3s', () => { const callback = jest.fn(); lazy(callback); jest.advanceTimersByTime(3000); // 快进3秒 expect(callback).toBeCalledTimes(1); jest.advanceTimersByTime(2000); // 再快进2秒 expect(callback).toBeCalledTimes(2); }) test('should call fn after 3s', () => { const callback = jest.fn(); lazy(callback); jest.advanceTimersByTime(3000); // 快进3秒 expect(callback).toBeCalledTimes(1); jest.advanceTimersByTime(2000); // 再快进2秒 expect(callback).toBeCalledTimes(2); })