利用 Jest 進行測試

Jest

經常使用的單元測試框架是 jasmine ,Mocha + Chai,不一樣於這些測試框架,jest 的集成度更高,提供的功能也更豐富,利用好 jest 所提供的功能,能大大提高測試用例的執行效率。javascript

Jest 特色:java

  1. 測試用例並行執行,更高效
  2. 強大的 Mock 功能
  3. 內置的代碼覆蓋率檢查,不須要在引入額外的工具
  4. 集成 JSDOM,能夠直接進行 DOM 相關的測試
  5. 更易用簡單,幾乎不須要額外配置
  6. 能夠直接對 ES Module Import 的代碼測試
  7. 有快照測試功能,可對 React 等框架進行 UI 測試

斷言庫基本用法

jest 使用的斷言風格與 jasmine 是相似的。git

// be and equal
expect(4 * 2).toBe(8);                      // ===
expect({bar: 'bar'}).toEqual({bar: 'baz'}); // deep equal
expect(1).not.toBe(2);

// boolean
expect(1 === 2).toBeFalsy();
expect(false).not.toBeTruthy();

// comapre
expect(8).toBeGreaterThan(7);
expect(7).toBeGreaterThanOrEqual(7);
expect(6).toBeLessThan(7);
expect(6).toBeLessThanOrEqual(6);

// Promise
expect(Promise.resolve('problem')).resolves.toBe('problem');
expect(Promise.reject('assign')).rejects.toBe('assign');

// contain
expect(['apple', 'banana']).toContain('banana');
expect([{name: 'Homer'}]).toContainEqual({name: 'Homer'});

// match
expect('NBA').toMatch(/^NB/);
expect({name: 'Homer', age: 45}).toMatchObject({name: 'Homer'});複製代碼

相比於 Mocha 的書寫風格(expect(8).to.be.equal(8)),jest 的斷言風格更加簡潔,同時保持着優秀的可讀性。github

同時能夠根據須要擴展本身的斷言庫:npm

expect.extend({
  toBeEven(received) {
    const even = (received % 2 === 0);
    if (event) {
      return {
        message: () => (`expected ${received} not to be even Number`),
        pass: true,
      };
    } else {
      return {
        message: () => (`expected ${received} to be even number`),
        pass: false,
      };
    }
  },
});

expect(10).toBeEven();
expect(9).not.toBeEven();複製代碼

Mock Function

以上的斷言基本是用在測試同步函數的返回值,若是所測試的函數存在異步邏輯。那麼在測試時就應該利用 jest 的 mock function 來進行測試。經過 mock function 能夠輕鬆地獲得回調函數的調用次數、參數等調用信息,而不須要編寫額外的代碼去獲取相關數據,讓測試用例變得更可讀。api

function getDouble(val, callback) {
  if(val < 0) {
    return;
  }
  setTimeout(() => {
    callback(val * val);
  }, 100);
};

const mockFn = jest.fn();
getDouble(10, mockFn);

expect(mockFn).not.toHaveBeenCalled()
setTimeout(() => {
  expect(mockFn).toHaveBeenCalledTimes(1);
  expect(mockFn).toHaveBeenCalledWith(20);
}, 110);複製代碼

除了能夠建立一個 mock function 做爲回調函數之外,jest 還能夠利用 mock function 跟蹤對象上已有的方法。數據結構

const api = {
  getRandom(range) {
    return Math.floor(Math.Random() * range);
  },
};

const spy = hest.spyOn(api, 'getRandomID');

api.getRandom(1000);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(1000);

spy.mockReset();
spy.mockRestore();      // 恢復原有的方法複製代碼

Mock 模塊

某些狀況下,咱們要測試模塊可能會依賴於另外一個模塊。app

// getRandom.js
export default function getRandom(range) {
  return Math.floor(Math.Random() * range);
};

// createModule.js
import getRandom from './getRandom';
export default function createModule(name) {
  return {
    name,
    id: `${name}-${getRandom(10000)}`,
  };
};複製代碼

getRandom 返回的值是隨機的,這樣每次調用 createModule 時獲得的 id 是沒法肯定的,意味着沒法對 id 的值進行全等的斷言測試。框架

爲此,jest 提供了 mock 功能,讓咱們能夠對這些依賴模塊進行 mock。dom

getRandom.js 的同一級路徑下,建立一個子目錄__mocks__,並把新寫的 mock 模塊放到裏面。

//__mocks__/getRandom.js
let num = 0;
function getRandom() {
  return num;
}
getRandom.__set = function(_num) {
  num = _num;
};
export default getRandom;複製代碼

那麼在測試腳本當中,調用 jest.mock 方法就可實現對該模塊的 mock。

import getRandom from './getRandom';
import createModule from './createModule';
jest.mock('./getRandom');

describe('test mock', function() {
  it('test', function() {
    getRandom.__set(100);
    const module = createModule('module');
    expect(module.id).toBe('module-100');
  });
});複製代碼

對模塊進行 mock 的最大好處不只僅在於方便地控制所依賴模塊的返回值,還能夠提升測試執行的效率。假若有一個模塊須要調用 fs 模塊進行文件讀寫,在進行測試時,就能夠對這個模塊進行 mock。那麼在測試中,就不須要真正地去進行硬盤讀寫,提高了測試的效率。

// __mocks__/fs.js
const path = require('path');
const fs = jest.genMockFromModule('fs');

let mockFiles = Object.create(null);
function __setMockFiles(newMockFiles) {
  mockFiles = Object.create(null);
  for (const file in newMockFiles) {
    const dir = path.dirname(file);

    if (!mockFiles[dir]) {
      mockFiles[dir] = [];
    }
    mockFiles[dir].push(path.basename(file));
  }
}

function readdirSync(directoryPath) {
  return mockFiles[directoryPath] || [];
}

fs.__setMockFiles = __setMockFiles;
fs.readdirSync = readdirSync;

export default fs;複製代碼

除了 fs 模塊,在封裝與異步請求相關的接口時,也能夠經過這個功能對異步請求返回的數據進行 mock,而沒必要要建一個 mock server 去執行真正的異步請求。

Mock Timers

jest 除了爲咱們提供 mock 整個模塊的功能外,還繼承了對 timers mock,也就是 jest 能夠劫持 setTimoutsetIntervalclearTimeoutclearInterval 等方法,模擬 timer 的功能。

例如本文中的第一個例子,被測試函數中須要用到定時器,在這裏就能夠利用 jest.useFaceTimers 來進行 mock。這樣,就不須要真正地去等待 timers 執行完纔去進程斷言。

function getDouble(val, callback) {
  if(val < 0) {
    return;
  }
  setTimeout(() => {
    callback(val * val);
  }, 100);
};

jest.useFakeTimers();
const mockFn = jest.fn();
getDouble(10, mockFn);

expect(mockFn).not.toHaveBeenCalled()
jest.runAllTimers();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(20);
jest.useRealTimers();複製代碼

再看一個簡單的 debounce 的例子。

function debounce(fn, wait) {
  let timestamp = null, timer = null, context;
  return function(...args) {
    timestamp = +new Date();
    context = this;
    function later() {
      const last = (+new Date()) - timestamp;
      if (last < wait && last > 0) {
        clearTimer(timer);
        timer = setTimeout(later, wait - last);
      } else {
        fn.call(context, ...args);
        clearTimer(timer);
      }
    }
    if (!timer) {
      timer = setTimeout(later, wait);
    }

  };
}複製代碼

測試腳本以下

describe('debounce', function() {
  it('should be called after 100 ms', function() {
    const mockFn = jest.fn();
    const run = debounce(mockFn, 100);
    jest.useFakeTimers();

    run();

    jest.runTimersToTime(50);   // 第 50 ms
    run();
    expect(mockFn).not.toHaveBeenCalled();

    jest.runTimersToTime(50);   // 第 100 ms
    expect(mockFn).not.toHaveBeenCalled();

    jest.runTimersToTime(50);   // 第 150 ms
    expect(mockFn).toHaveBeenCalledTime(1);

    jest.useRealTimers();
  });
});複製代碼

經過 mock timer,咱們期待的是當定時器運行到第 150 ms 時,mockFn 纔會執行一次。但這個用例會執行失敗。由於當定時器運行到第 100 ms 時,mockFn 就被執行了。這是因爲 jest 的 mock timer 的實現機制致使的。

jest 會將 setTimeout 替換爲自帶的 setTimeout 方法,該方法調用時會將對應的回調函數登記到對應的_timers列表當中。當中會經過 expiry 記錄定時器的到期時間。當執行 jest.runTimersToTime(time) 時,就會進行判斷 _now + time >= _timer.expiry ,若是達到過時時間,_timer.callback 就會被當即執行。

// _timers 列表中的數據結構
{
  callback: () => callback.apply(null, args),
  expiry: _now + delay,
  interval: null,
  type: 'timeout',
}複製代碼

所以在 jest 的 mock timer 環境下, 定時器回調函數的執行實際上已經變成了同步的了,它會在調用 jest.runTimers 這類方法時進行判斷並執行符合條件的回調方法。

在定時器回調執行時,實現的時間並無流逝,而 debounce 方法中須要經過 Date 類記錄調用時間。因此不管是在第 50 ms 仍是第 100 ms, run 執行時的 timestamp 跟第一次執行時是同樣的,因此在第 100 ms時, last === 0,所以 mockFn 被執行。

mock timer 能模擬定時器的行爲,但並非真正地加速時間運行,因此經過 Date 獲取的時間不會跟着一塊兒增長。當被測模塊需中還須要調用 Date 時,就還須要對 Date 進行模擬。爲此咱們能夠利用 mockdate 這個 npm 包。

mockdate 經過修改全局的 Date 類,達到控制 Date 的時間的目的。

所以測試用例須要做調整,在調用 jest.runTimersToTime 以前先修改 Date 的當前時間。

import MockDate from 'mockdate';
let now = +new Date();
function fastforward(time) {
  now += time;
  MockDate.set(now);
  jest.runTimersTo(time);
}

describe('debounce', function() {
  it('should be called after 100 ms', function() {
    const mockFn = jest.fn();
    const run = debounce(mockFn, 100);
    jest.useFakeTimers();

    run();

    fastforward(50);   // 第 50 ms
    run();
    expect(mockFn).not.toHaveBeenCalled();

    fastforward(50);   // 第 100 ms
    expect(mockFn).not.toHaveBeenCalled();

    fastforward(50);   // 第 150 ms
    expect(mockFn).toHaveBeenCalledTime(1);

    jest.useRealTimers();
    MockDate.reset();
  });
});複製代碼

在 mock timer 的幫助下,咱們能夠測試在實際使用時可能會用到很長時間間隔定時器的模塊,例如一個跨天的倒計時模塊。它能夠方便地讓測試覆蓋到代碼的每個分支。

總結

代碼測試可以保障代碼的質量和功能,在開發過程進行測試可以提早發現 bug,在進行代碼維護、移植或重構時,測試可以保障代碼功能的完整性。對於一些複用度高、須要長期維護的公用代碼來講,利用測試來進行質量保障是很是有必要的。

by ELCARIM

相關文章
相關標籤/搜索