使用 Jest 进行前端单元测试

时间:2022-04-25
本文章向大家介绍使用 Jest 进行前端单元测试,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

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"); //要测试的文件不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:

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/