前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用 Jest 进行前端单元测试

使用 Jest 进行前端单元测试

作者头像
QQ音乐技术团队
发布2018-01-31 17:53:05
5.6K0
发布2018-01-31 17:53:05
举报
文章被收录于专栏:QQ音乐技术团队的专栏

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

代码语言:javascript
复制
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

代码语言:javascript
复制
jest.unmock("../writeUser"); //要测试的文件不mockjest.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:

代码语言:javascript
复制
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 后,可以对其行为做各种定制和修改,达到想要的情景:

代码语言:javascript
复制
mockFn.mockClear()
mockFn.mockReset()
mockFn.mockImplementation(fn)
mockFn.mockImplementationOnce(fn)
mockFn.mockReturnThis()
mockFn.mockReturnValue(value)
mockFn.mockReturnValueOnce(value)

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

代码语言:javascript
复制
var myMock = jest.fn();
myMock('1');
myMock('a', 'b');console.log(myMock.mock.calls);
> [ [1], ['a', 'b'] ]

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

代码语言:javascript
复制
expect(fetchUser).toHaveBeenCalled();

可用的断言 API:

代码语言:javascript
复制
.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 掉:

代码语言:javascript
复制
// 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:

代码语言:javascript
复制
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

代码语言:javascript
复制
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

代码语言:javascript
复制
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

代码语言:javascript
复制
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 就好。

代码语言:javascript
复制
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

代码语言:javascript
复制
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 呢?

代码语言:javascript
复制
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

代码语言:javascript
复制
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

代码语言:javascript
复制
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/
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2017-01-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 QQ音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档