微信号:gh_287053a877e6

介绍:QQ音乐开发团队公众帐号.关注技术分享与交流

使用 Jest 进行前端单元测试

2017-01-13 15:42 mc-zone

Jest 是一款 Facebook 开源的 JS 单元测试框架,具有 auto mock、自带 mock API、前端友好(集成JSDOM)、环境隔离等特点和优势。Jest 默认使用 Jasmine 语法,支持直接使用 Promise 和 async/await 进行异步测试,支持对 React 组件进行快照监控, 扩展和集成 Babel 等常用工具集也很方便。目前 Jest 已经在 Facebook 开源的 React, React Native 等前端项目中被做为标配测试框架。


下面简单介绍一些 Jest 比较有用的功能和用法。


Mock

Jest 自带一个 mock 系统,并支持自动和手动 mock。


通常项目中,要测试的文件可能带有很多调用依赖,另外单元测试环境和真实环境可也能存在差异,使得脱离真实环境不能直接运行。我们在写一个测试用例前,如果能对非关键的依赖进行 mock,只约定好最后的返回,就不用再先解决一堆依赖和环境问题,把精力集中在要测试的单元上来编写 test case ,同时也缩短测试用例执行的时间,做到最小化测试。


例如下面这段典型的前端业务代码,涉及到网络请求、DOM操作等多个步骤,不在浏览器环境中是无法直接执行。


./writeUser.js

import $ from 'jquery'; import fetchUser from './fetchUser'; export function bind(){  $('#button').click(() => {    fetchUser((err, user) => {
     if(err){        alert(err.message);      }else{        $('#nick').text(user.nick);      }    });  }); }


这种情况使用 Jest 的 mock 功能处理起来却很轻松。如果我们开启了 auto mock,所有文件都会被 mock 掉不会被真实执行到。我们只要稍作加工,就可以指定各个文件的行为,并模拟我们想要的情况来进行不同的测试,例如本例中控制 fetchUser 的返回。


而在最后的 DOM 操作上由于有 JSDOM 模拟浏览器环境,我们可以指定不去 mock jQuery,让其正常执行,并且还能用来辅助测试。


./tests/writeUser.test.js

jest.unmock("../writeUser"); //要测试的文件不mock
jest.unmock("jquery"); //有JSDOM环境可以用
import $ from 'jquery'; import fetchUser from '../fetchUser'; import { bind } from '../writeUser'; describe('拉取成功时', () => {  beforeAll(() => {
   /* 指定 fetchUser 的行为 */    fetchUser.mockImplementation(cb => {      cb(null, {nick: 'mc-zone'});    });    
   /* 初始化 Document */    document.body.innerHTML =    '<div id="nick"></div><button id="button"></button>';  });  it('拉取到信息后改写 DOM Text', () => {    bind();    $('#button').click();    expect(fetchUser).toHaveBeenCalled();    expect($('#nick').text()).toEqual('mc-zone');  }); });


最后可以测试执行的结果:



此外,Jest 提供的 mock API 也非常丰富。


常用的 mock 相关 API:

require.requireActual(moduleName)
require.requireMock(moduleName) jest.resetAllMocks() jest.disableAutomock() jest.enableAutomock() jest.fn(?implementation) jest.isMockFunction(fn) jest.genMockFromModule(moduleName) jest.mock(moduleName, ?factory, ?options) jest.resetModules() jest.setMock(moduleName, moduleExports) jest.unmock(moduleName)

在生成了 mock function 后,可以对其行为做各种定制和修改,达到想要的情景:

mockFn.mockClear() mockFn.mockReset() mockFn.mockImplementation(fn) mockFn.mockImplementationOnce(fn) mockFn.mockReturnThis() mockFn.mockReturnValue(value) mockFn.mockReturnValueOnce(value)

在被调用后,mock function 会自动记录每次的调用信息,例如我想拿到第 m 次被调用时的第 n 个参数,就可以通过 mock.calls 来访问到:

var myMock = jest.fn(); myMock('1'); myMock('a', 'b');
console.log(myMock.mock.calls); > [ [1], ['a', 'b'] ]

也可以通过 expert 对 mock function 做调用的断言,就像刚刚对 fetchUser 那样:

expect(fetchUser).toHaveBeenCalled();

可用的断言 API:

.toHaveBeenCalled() .toHaveBeenCalledWith(arg1, arg2, ...) .toHaveBeenCalledTimes(number) .toHaveBeenLastCalledWith(arg1, arg2, ...)


详细的可以看 官网文档 [附1]


Timer

业务代码中如果有 setTimeout 这样的计时器,在测试过程中如果真实的去执行,可能会严重拖慢整个测试项目的执行时间,设想一个功能有 n 个用例去测试,延时就会被重复 n 倍。


Jest 对所有的 Timer (setTimeout, setInterval, clearTimeout, clearInterval 等)都提供了 mock 和 API,让你可以在测试时反客为主,方便自如的控制它们。例如使用 jest.useFakeTimers() 把遇到的计时器挂起,在必要时再使用 jest.runOnlyPendingTimers() 执行掉已经挂起的计时器。


下面一个官网的 Demo,可以看到在用例不必关心 Timer 执行结果的场景下完全可以 mock 掉:

// timerGame.js
'use strict';

function timerGame(callback) {
 console.log('Ready....go!');  setTimeout(() => {    
   console.log('Times up -- stop!');    callback && callback();  }, 1000); }

module
.exports = timerGame;


// __tests__/timerGame-test.js
'use strict'; jest.useFakeTimers(); it('waits 1 second before ending the game', () => {
 const timerGame = require('../timerGame');  timerGame();  expect(setTimeout.mock.calls.length).toBe(1);  expect(setTimeout.mock.calls[0][1]).toBe(1000); });

Jest 的 Timer API:

jest.clearAllTimers() jest.runAllTicks() jest.runAllTimers() jest.runTimersToTime(msToRun) jest.runOnlyPendingTimers() jest.useFakeTimers() jest.useRealTimers()


React 支持

为了能够通过测试用例实现对 React 组件的变化做监控,14.0 以后版本的 Jest 提供了 React 组件快照功能(React Tree Snapshot Testing)。可以通过 react-test-renderer,把 React 组件生成快照并暂存下来,在之后跑用例时如果组件结果发生了改变则报错提醒。


例如下面做个简单的例子:


./reactApp.js

import React, { Component } from "react"; export default class App extends Component {  render(){
   return (
     <div>        <h1>{this.props.title}</h1>        {this.props.children}
     </div>    )  } };


./tests/reactApp.test.js

import React from "react"; import App from "../reactApp"; import renderer from 'react-test-renderer'; it("react render", () => {
 const component = renderer.create(
   <App title="Hello React" >      <span>test text</span>    </App>  );  let tree = component.toJSON();  expect(tree).toMatchSnapshot(); });


这时运行测试用例,将生成一个 "App" 组件的快照。

如果把上面的 tree 打印出来可以看到是一个 React 组件的 JSON tree。




这时候如果我们改动一下代码:


./reactApp.js

import React, { Component } from "react"; export default class App extends Component {  render(){
   return (
     <div>        <h1>{this.props.title + "mutate"}</h1>        {this.props.children}
     </div>    )  } };

再执行测试用例,将会看到报错:




提示我们组件的结果和上一次保存的快照不同。这样就可以达到监控的目的。


另外如果修改了组件代码,需要更新快照,则带上参数 -u 重新运行一次即可,快照就会更新。


详细的解释和说明建议阅读作者的这篇文章 [附2]


除此之外 Jest 也可以结合 enzyme 更好的在 React 项目中进行测试(enzyme 是 airbnb 开源的一个 React 测试工具,通过 Shallow Rendering 的实现对 React 生成的组件节点进行断言和测试)。要了解更多可以阅读 官方文档 [附3] 和 enzyme [附4] 


异步支持

如果有使用过 node-tap 之类的老测试框架,在遇到异步情况时候肯定感受过麻烦了。现代的测试框架对异步的支持都是必需的。在 Jest 中也不用像 mocha 那样通过执行 done 来通知异步结束,而是直接返回 Promise 和 async/await 就好。

it('works with promises', () => {
 return user.getUserName(5)    .then(name => expect(name).toEqual('Paul')); }); it('works with async/await', async () => {
 const userName = await user.getUserName(4);  expect(userName).toEqual('Mark'); });


环境隔离

在 Jest 中,不同的测试文件是分开独立执行的,如果担心各种 mock 和 unmock 在不同测试用例之间造成冲突,可以按照分类把用例分开放到不同文件内。Jest 利用了多核 CPU 来并行执行测试文件,并且对环境做了隔离,这一点和 AVA 一样。


控制台输出

另外还有良好的控制台输出,执行顺序调整,代码覆盖率统计等等。


下图为在 react-native 源项目中执行 verbose 的 jest test 时,控制台的实时输出:



Jest 的覆盖率统计:



详细报错定位:



总之 Jest 是一款上手很快,功能齐全,高定制性的测试框架。社区的活跃程度也和其他 Facebook 项目一样,值得一试。


扩展:关于编写可测试的代码

最后再来一个关于写 mock 的实例。


我们都知道保持编写可测试的代码的习惯是非常重要的。可测试性差的代码,在写测试用例时也会花费成倍的时间。例如下面这个例子:


./renderUser.js

import fetch from 'fetch'; export default function(){
 return Promise.all([    fetch("http://example.com/getUserInfo?uid=123")      .then(response => response.json())      .then(json => {
       if(json.code == 0){
         return json.data;        }else{
         throw new Error(json.message);        }      }),    fetch("http://example.com/getUserLevel?uid=123")      .then(response => response.json())      .then(json => {                if(json.code == 0 && json.data && json.data.level){
         return json.data.level;        }else{                    throw new Error(json.message);        }      })  ])  .then(([userInfo, level]) => {
   const text = "昵称:" + userInfo.nick + "等级:" + level;    $("#container").text(text);  }).catch(err => {    alert(err)  }); }


这里有对 getUserInfo 和 getUserLevel 两个接口的拉取,测试用例的关注点应是要确保取到正确数据后能够正常写到 DOM 上,应该把网络拉取部分 mock 掉,构造测试数据返回,在当前的代码就是 fetch 部分。 具体如何写 mock 呢?

jest.mock("fetch"); import fetch from "fetch"; fetch.mockImplementation((url, params) => {
 let data;
 if(/getUserInfo/.test(url)){    data = {      nick:"Bob"    };  }else if(/getUserLevel/.test(url)){    data = {      level:12    };  }
 
 return new Promise((resolve, reject) => {    resolve({      json:() => {
       return new Promise((_resolve, _reject) => {          _resolve({            code:0,            data:data          });        });      }    });  }); }); it("render", () => {
 document.body.innerHTML = '<div id="container"></div>';
 return renderUser().then(() => {    expect($("#container").text()).toBe("昵称:Bob等级:12");  }); });

看到在现在的情况下,两次类似的 fetch 调用使得需要在 mock 中对不同参数做判断。另外因为在 fetch 的 promise 链上的连续操作,mock 时还要注意实现 response.json() 等操作。


这样的代码不仅显得比较长,单独一个测试用例的 mock 也很长。可以设想如果代码中间的过程再增加,相应的 mock 还要再修改。要怎么写才能够更加方便测试呢?


我们可以把调用的代码稍微封装一下,把网络请求和数据处理相关的内容抽离出去。改写后的 renderUser 模块:


./renderUser.js

import fetchUserInfo from './fetchUserInfo'; import fetchUserLevel from './fetchUserLevel';
export default function(){
 return Promise.all([    fetchUserInfo({ uid:123 }),    fetchUserLevel({ uid:123 })  ])  .then(([user, level]) => {
   const text = "昵称:" + user.nick + "等级:" + level;    $("#container").text(text);  })  .catch(err => {    alert(err)  }); }

这样再做 mock 测试就很简单:


./tests/renderUser.test.js

jest.mock("../fetchUserInfo"); jest.mock("../fetchUserLevel"); import fetchUserInfo from "../fetchUserInfo"; import fetchUserLevel from "../fetchUserLevel"; import renderUser from "../renderUser";
import $ from "jquery"; fetchUserInfo.mockImplementation(params => {
 const data = {    nick:"Bob"  };
 return Promise.resolve(data); }); fetchUserLevel.mockImplementation(params => {
 const level = 12;
 return Promise.resolve(level); }); it("render", () => {    document.body.innerHTML = '<div id="container"></div>';
 return renderUser().then(() => {    expect($("#container").text()).toBe("昵称:Bob等级:12");  }); });




优化一下结构,写出更好测试的代码其实很容易。


最后总结一下,编写可测试的代码,其实可以遵循这几个点来规范:


  • 功能最小化,单一职责的函数

  • 抽离业务逻辑中的公共部分

  • 细分文件依赖

  • 避免函数副作用(不修改实参)


其他还有很多可以优化的点不再阐述,感兴趣的推荐阅读一下 编写可测试的JavaScript代码[附5] 这本书。



附 文中链接:

  1. http://facebook.github.io/jest/docs/mock-functions.html#content

  2. http://facebook.github.io/jest/blog/2016/07/27/jest-14.html

  3. http://facebook.github.io/jest/docs/tutorial-react.html#dom-testing

  4. https://github.com/airbnb/enzyme

  5. https://book.douban.com/subject/26348084/

 
QQ音乐技术团队 更多文章 使用 Jest 进行前端单元测试 蓝牙协议中的SBC编码 iOS开发之动画中的时间 Android Auto开发初探 OpenSL ES那些事
猜您喜欢 集群管理新模式-Docker 【推荐】Auto-Generating Clickbait With Recurrent Neural Networks Android Realm详解 极拍,五分钟搭建直播业务后端及 App 有一个程序员男朋友是什么样的体验?