使用Jest進行React單元測試

supot發表於2018-08-09

React單元測試方案

前置知識

為什麼要進行測試

  1. 測試可以確保得到預期的結果
  2. 作為現有程式碼行為的描述
  3. 促使開發者寫可測試的程式碼,一般可測試的程式碼可讀性也會高一點
  4. 如果依賴的元件有修改,受影響的元件能在測試中發現錯誤

測試型別

  • 單元測試:指的是以原件的單元為單位,對軟體進行測試。單元可以是一個函式,也可以是一個模組或一個元件,基本特徵就是隻要輸入不變,必定返回同樣的輸出。一個軟體越容易些單元測試,就表明它的模組化結構越好,給模組之間的耦合越弱。React的元件化和函數語言程式設計,天生適合進行單元測試
  • 功能測試:相當於是黑盒測試,測試者不瞭解程式的內部情況,不需要具備程式語言的專門知識,只知道程式的輸入、輸出和功能,從使用者的角度針對軟體介面、功能和外部結構進行測試,不考慮內部的邏輯
  • 整合測試:在單元測試的基礎上,將所有模組按照設計要求組裝成子系統或者系統,進行測試
  • 冒煙測試:在正式全面的測試之前,對主要功能進行的與測試,確認主要功能是否滿足需要,軟體是否能正常執行

開發模式

  • TDD: 測試驅動開發,英文為Testing Driven Development,強調的是一種開發方式,以測試來驅動整個專案,即先根據介面完成測試編寫,然後在完成功能是要不斷通過測試,最終目的是通過所有測試
  • BDD: 行為驅動測試,英文為Behavior Driven Development,強調的是寫測試的風格,即測試要寫的像自然語言,讓專案的各個成員甚至產品都能看懂測試,甚至編寫測試

TDD和BDD有各自的使用場景,BDD一般偏向於系統功能和業務邏輯的自動化測試設計;而TDD在快速開發並測試功能模組的過程中則更加高效,以快速完成開發為目的。

技術選型:Jest + Enzyme

Jest

Jest是Facebook開源的一個前端測試框架,主要用於React和React Native的單元測試,已被整合在create-react-app中。Jest特點:

  1. 易用性:基於Jasmine,提供斷言庫,支援多種測試風格
  2. 適應性:Jest是模組化、可擴充套件和可配置的
  3. 沙箱和快照:Jest內建了JSDOM,能夠模擬瀏覽器環境,並且並行執行
  4. 快照測試:Jest能夠對React元件樹進行序列化,生成對應的字串快照,通過比較字串提供高效能的UI檢測
  5. Mock系統:Jest實現了一個強大的Mock系統,支援自動和手動mock
  6. 支援非同步程式碼測試:支援Promise和async/await
  7. 自動生成靜態分析結果:內建Istanbul,測試程式碼覆蓋率,並生成對應的報告

Enzyme

Enzyme是Airbnb開源的React測試工具庫庫,它功能過對官方的測試工具庫ReactTestUtils的二次封裝,提供了一套簡潔強大的 API,並內建Cheerio,

實現了jQuery風格的方式進行DOM 處理,開發體驗十分友好。在開源社群有超高人氣,同時也獲得了React 官方的推薦。

測試環境搭建

安裝Jest、Enzyme,以及babel-jest。如果React的版本是15或者16,需要安裝對應的enzyme-adapter-react-15和enzyme-adapter-react-16並配置。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });
複製程式碼

在package.json中的script中增加"test: jest --config .jest.js"

.jest.js檔案

module.exports = {
  setupFiles: [
    './test/setup.js',
  ],
  moduleFileExtensions: [
    'js',
    'jsx',
  ],
  testPathIgnorePatterns: [
    '/node_modules/',
  ],
  testRegex: '.*\\.test\\.js$',
  collectCoverage: false,
  collectCoverageFrom: [
    'src/components/**/*.{js}',
  ],
  moduleNameMapper: {
    "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
    "\\.(css|less|scss)$": "<rootDir>/__mocks__/styleMock.js"
  },
  transform: {
    "^.+\\.js$": "babel-jest"
  },
};
複製程式碼
  • setupFiles:配置檔案,在執行測試案例程式碼之前,Jest會先執行這裡的配置檔案來初始化指定的測試環境
  • moduleFileExtensions:代表支援載入的檔名
  • testPathIgnorePatterns:用正則來匹配不用測試的檔案
  • testRegex:正則表示的測試檔案,測試檔案的格式為xxx.test.js
  • collectCoverage:是否生成測試覆蓋報告,如果開啟,會增加測試的時間
  • collectCoverageFrom:生成測試覆蓋報告是檢測的覆蓋檔案
  • moduleNameMapper:代表需要被Mock的資源名稱
  • transform:用babel-jest來編譯檔案,生成ES6/7的語法

Jest

globals API

  • describe(name, fn):描述塊,講一組功能相關的測試用例組合在一起
  • it(name, fn, timeout):別名test,用來放測試用例
  • afterAll(fn, timeout):所有測試用例跑完以後執行的方法
  • beforeAll(fn, timeout):所有測試用例執行之前執行的方法
  • afterEach(fn):在每個測試用例執行完後執行的方法
  • beforeEach(fn):在每個測試用例執行之前需要執行的方法

全域性和describe都可以有上面四個周期函式,describe的after函式優先順序要高於全域性的after函式,describe的before函式優先順序要低於全域性的before函式

beforeAll(() => {
  console.log('global before all');
});

afterAll(() => {
  console.log('global after all');
});

beforeEach(() =>{
  console.log('global before each');
});

afterEach(() => {
  console.log('global after each');
});

describe('test1', () => {
  beforeAll(() => {
    console.log('test1 before all');
  });
  
  afterAll(() => {
    console.log('test1 after all');
  });
  
  beforeEach(() => {
    console.log('test1 before each');
  });
  
  afterEach(() => {
    console.log('test1 after each');
  });
  
  it('test sum', () => {
    expect(sum(2, 3)).toEqual(5);
  });
  
  it('test mutil', () => {
    expect(sum(2, 3)).toEqual(7);
  });
  
});
複製程式碼

使用Jest進行React單元測試

config

Jest擁有豐富的配置項,可以寫在package.json裡增加增加jest欄位來進行配置,或者通過命令列--config來指定配置檔案。

jest物件

  • jest.fn(implementation):返回一個全新沒有使用過的mock function,這個function在被呼叫的時候會記錄很多和函式呼叫有關的資訊
  • jest.mock(moduleName, factory, options):用來mock一些模組或者檔案
  • jest.spyOn(object, methodName):返回一個mock function,和jest.fn相似,但是能夠追蹤object[methodName]的呼叫資訊,類似Sinon

Mock Functions

使用mock函式可以輕鬆的模擬程式碼之間的依賴,可以通過fn或spyOn來mock某個具體的函式;通過mock來模擬某個模組。具體的API可以看mock-function-api

快照

快照會生成一個元件的UI結構,並用字串的形式存放在__snapshots__檔案裡,通過比較兩個字串來判斷UI是否改變,因為是字串比較,所以效能很高。

要使用快照功能,需要引入react-test-renderer庫,使用其中的renderer方法,jest在執行的時候如果發現toMatchSnapshot方法,會在同級目錄下生成一個__snapshots資料夾用來存放快照檔案,以後每次測試的時候都會和第一次生成的快照進行比較。可以使用jest --updateSnapshot來更新快照檔案。

非同步測試

Jest支援對非同步的測試,支援Promise和Async/Await兩種方式的非同步測試。

常見斷言

  1. expect(value):要測試一個值進行斷言的時候,要使用expect對值進行包裹
  2. toBe(value):使用Object.is來進行比較,如果進行浮點數的比較,要使用toBeCloseTo
  3. not:用來取反
  4. toEqual(value):用於物件的深比較
  5. toMatch(regexpOrString):用來檢查字串是否匹配,可以是正規表示式或者字串
  6. toContain(item):用來判斷item是否在一個陣列中,也可以用於字串的判斷
  7. toBeNull(value):只匹配null
  8. toBeUndefined(value):只匹配undefined
  9. toBeDefined(value):與toBeUndefined相反
  10. toBeTruthy(value):匹配任何使if語句為真的值
  11. toBeFalsy(value):匹配任何使if語句為假的值
  12. toBeGreaterThan(number): 大於
  13. toBeGreaterThanOrEqual(number):大於等於
  14. toBeLessThan(number):小於
  15. toBeLessThanOrEqual(number):小於等於
  16. toBeInstanceOf(class):判斷是不是class的例項
  17. anything(value):匹配除了null和undefined以外的所有值
  18. resolves:用來取出promise為fulfilled時包裹的值,支援鏈式呼叫
  19. rejects:用來取出promise為rejected時包裹的值,支援鏈式呼叫
  20. toHaveBeenCalled():用來判斷mock function是否被呼叫過
  21. toHaveBeenCalledTimes(number):用來判斷mock function被呼叫的次數
  22. assertions(number):驗證在一個測試用例中有number個斷言被呼叫
  23. extend(matchers):自定義一些斷言

Enzyme

三種渲染方法

  1. shallow:淺渲染,是對官方的Shallow Renderer的封裝。將元件渲染成虛擬DOM物件,只會渲染第一層,子元件將不會被渲染出來,使得效率非常高。不需要DOM環境, 並可以使用jQuery的方式訪問元件的資訊
  2. render:靜態渲染,它將React元件渲染成靜態的HTML字串,然後使用Cheerio這個庫解析這段字串,並返回一個Cheerio的例項物件,可以用來分析元件的html結構
  3. mount:完全渲染,它將元件渲染載入成一個真實的DOM節點,用來測試DOM API的互動和元件的生命週期。用到了jsdom來模擬瀏覽器環境

三種方法中,shallow和mount因為返回的是DOM物件,可以用simulate進行互動模擬,而render方法不可以。一般shallow方法就可以滿足需求,如果需要對子元件進行判斷,需要使用render,如果需要測試元件的生命週期,需要使用mount方法。

常用方法

  1. simulate(event, mock):模擬事件,用來觸發事件,event為事件名稱,mock為一個event object
  2. instance():返回元件的例項
  3. find(selector):根據選擇器查詢節點,selector可以是CSS中的選擇器,或者是元件的建構函式,元件的display name等
  4. at(index):返回一個渲染過的物件
  5. get(index):返回一個react node,要測試它,需要重新渲染
  6. contains(nodeOrNodes):當前物件是否包含引數重點 node,引數型別為react物件或物件陣列
  7. text():返回當前元件的文字內容
  8. html(): 返回當前元件的HTML程式碼形式
  9. props():返回根元件的所有屬性
  10. prop(key):返回根元件的指定屬性
  11. state():返回根元件的狀態
  12. setState(nextState):設定根元件的狀態
  13. setProps(nextProps):設定根元件的屬性

編寫測試用例

元件程式碼

todo-list/index.js

import React, { Component } from 'react';
import { Button } from 'antd';

export default class TodoList extends Component {
  constructor(props) {
    super(props);
    this.handleTest2 = this.handleTest2.bind(this);
  }
  handleTest = () => {
    console.log('test');
  }

  handleTest2() {
    console.log('test2');
  }

  componentDidMount() {}

  render() {
    return (
      <div className="todo-list">
        {this.props.list.map((todo, index) => (<div key={index}>
          <span className="item-text ">{todo}</span>
          <Button onClick={() => this.props.deleteTodo(index)} >done</Button>
        </div>))}
      </div>
    );
  }
}

複製程式碼

測試檔案setup設定

const props = {
  list: ['first', 'second'],
  deleteTodo: jest.fn(),
};

const setup = () => {
  const wrapper = shallow(<TodoList {...props} />);
  return {
    props,
    wrapper,
  };
};

const setupByRender = () => {
  const wrapper = render(<TodoList {...props} />);
  return {
    props,
    wrapper,
  };
};

const setupByMount = () => {
  const wrapper = mount(<TodoList {...props} />);
  return {
    props,
    wrapper,
  };
};
複製程式碼

使用 snapshot 進行 UI 測試

it('renders correctly', () => {
  const tree = renderer
  .create(<TodoList {...props} />)
          .toJSON();

  expect(tree).toMatchSnapshot();
});
複製程式碼

當使用toMatchSnapshot的時候,會生成一份元件DOM的快照,以後每次執行測試用例的時候,都會生成一份元件快照和第一次生成的快照進行對比,如果對元件的結構進行修改,那麼生成的快照就會對比失敗。可以通過更新快照重新進行UI測試。

對元件節點進行測試

it('should has Button', () => {
  const { wrapper } = setup();
  expect(wrapper.find('Button').length).toBe(2);
});

it('should render 2 item', () => {
  const { wrapper } = setupByRender();
  expect(wrapper.find('button').length).toBe(2);
});

it('should render item equal', () => {
  const { wrapper } = setupByMount();
  wrapper.find('.item-text').forEach((node, index) => {
    expect(node.text()).toBe(wrapper.props().list[index])
  });
});

it('click item to be done', () => {
  const { wrapper } = setupByMount();
  wrapper.find('Button').at(0).simulate('click');
  expect(props.deleteTodo).toBeCalled();
});
複製程式碼

判斷元件是否有Button這個元件,因為不需要渲染子節點,所以使用shallow方法進行元件的渲染,因為props的list有兩項,所以預期應該有兩個Button元件。

判斷元件是否有button這個元素,因為button是Button元件裡的元素,所有使用render方法進行渲染,預期也會找到連個button元素。

判斷元件的內容,使用mount方法進行渲染,然後使用forEach判斷.item-text的內容是否和傳入的值相等使用simulate來觸發click事件,因為deleteTodo被mock了,所以可以用deleteTodo方法時候被呼叫來判斷click事件是否被觸發。

測試元件生命週期

//使用spy替身的時候,在測試用例結束後,要對spy進行restore,不然這個spy會一直存在,並且無法對相同的方法再次進行spy。
it('calls componentDidMount', () => {
  const componentDidMountSpy = jest.spyOn(TodoList.prototype, 'componentDidMount');
  const { wrapper } = setup();
  expect(componentDidMountSpy).toHaveBeenCalled();
  componentDidMountSpy.mockRestore();
});
複製程式碼

使用spyOn來mock 元件的componentDidMount,替身函式要在元件渲染之前,所有替身函式要定義在setup執行之前,並且在判斷以後要對替身函式restore,不然這個替身函式會一直存在,且被mock的那個函式無法被再次mock。

測試元件的內部函式

it('calls component handleTest', () => { // class中使用箭頭函式來定義方法
  const { wrapper } = setup();
  const spyFunction = jest.spyOn(wrapper.instance(), 'handleTest');
  wrapper.instance().handleTest();
  expect(spyFunction).toHaveBeenCalled();
  spyFunction.mockRestore();
});

it('calls component handleTest2', () => { //在constructor使用bind來定義方法
  const spyFunction = jest.spyOn(TodoList.prototype, 'handleTest2');
  const { wrapper } = setup();
  wrapper.instance().handleTest2();
  expect(spyFunction).toHaveBeenCalled();
  spyFunction.mockRestore();
});
複製程式碼

使用instance函式來取得元件的例項,並用spyOn方法來mock例項上的內部方法,然後用這個例項去呼叫那個內部方法,就可以用替身來判斷這個內部函式是否被呼叫。如果內部方法是用箭頭函式來定義的時候,需要對例項進行mock;如果內部方法是通過正常的方式或者bind的方式定義的,那麼需要對元件的prototype進行mock。其實對生命週期或者內部函式的測試,可以通過一些state的改變進行判斷,因為這些函式的呼叫一般都會對元件的state進行一些操作。

Manual Mocks

  1. 對全域性的模組(moduleName)進行手動模擬,需要在node_modules平級的位置新建一個__mocks__資料夾,並在資料夾中新建一個moduleName的檔案
  2. 對某個檔案(fileName)進行手動模擬,需要在被模擬的檔案平級的位置新建一個__mocks__資料夾,然後在資料夾中新建一個fileName的檔案
add/index.js

import { add } from 'lodash';
import { multip } from '../../utils/index';

export default function sum(a, b) {
  return add(a, b);
}

export function m(a, b) {
  return multip(a, b);
}
複製程式碼
add/__test__/index.test.js

import sum, { m } from '../index';

jest.mock('lodash');
jest.mock('../../../utils/index');

describe('test mocks', () => {
  it('test sum', () => {
    expect(sum(2, 3)).toEqual(5);
  });
  it('test mutilp', () => {
    expect(m(2, 3)).toEqual(7);
  });
});
複製程式碼

_mocks_:

使用Jest進行React單元測試

在測試檔案中使用mock()方法對要進行mock的檔案進行引用,Jest就會自動去尋找對應的__mocks__中的檔案並進行替換,lodash中的add和utils中的multip方法就會被mock成對應的方法。可以使用自動代理的方式對專案的非同步元件庫(fetch、axios)進行mock,或者使用fetch-mock、jest-fetch-mock來模擬非同步請求。

對非同步方法進行測試

async/index.js

import request from './request';

export function getUserName(userID) {
  return request(`/users/${userID}`).then(user => user.name);
}



async/request.js

const http = require('http');
export default function request(url) {
  return new Promise((resolve) => {
    // This is an example of an http request, for example to fetch
    // user data from an API.
    // This module is being mocked in __mocks__/request.js
    http.get({ path: url }, (response) => {
      let data = '';
      response.on('data', _data => (data += _data));
      response.on('end', () => resolve(data));
    });
  });
}

複製程式碼

mock request:

const users = {
  4: {
    name: 'hehe',
  },
  5: {
    name: 'haha',
  },
};

export default function request(url) {
  return new Promise((resolve, reject) => {
    const userID = parseInt(url.substr('/users/'.length), 10);
    process.nextTick(() => {
      users[userID] ?
        resolve(users[userID]) :
        reject({
          error: `User with ${userID} not found.`,
        });
    });
  });
}

複製程式碼

request.js可以看成是一個用於請求資料的模組,手動mock這個模組,使它返回一個Promise物件,用於對非同步的處理。

測試Promise

// 使用'.resolves'來測試promise成功時返回的值
it('works with resolves', () => {
   // expect.assertions(1);
   expect(user.getUserName(5)).resolves.toEqual('haha')
});

// 使用'.rejects'來測試promise失敗時返回的值
it('works with rejects', () => {
  expect.assertions(1);
  return expect(user.getUserName(3)).rejects.toEqual({
    error: 'User with 3 not found.',
  });
});

// 使用promise的返回值來進行測試
it('test resolve with promise', () => {
  expect.assertions(1);
  return user.getUserName(4).then((data) => {
    expect(data).toEqual('hehe');
  });
});
it('test error with promise', () => {
  expect.assertions(1);
  return user.getUserName(2).catch((e) => {
    expect(e).toEqual({
      error: 'User with 2 not found.',
    });
  });
});
複製程式碼

當對Promise進行測試時,一定要在斷言之前加一個return,不然沒有等到Promise的返回,測試函式就會結束。可以使用.promises/.rejects對返回的值進行獲取,或者使用then/catch方法進行判斷。

測試Async/Await

// 使用async/await來測試resolve
it('works resolve with async/await', async () => {
  expect.assertions(1);
  const data = await user.getUserName(4);
  expect(data).toEqual('hehe');
});

// 使用async/await來測試reject
it('works reject with async/await', async () => {
  expect.assertions(1);
  try {
    await user.getUserName(1);
  } catch (e) {
    expect(e).toEqual({
      error: 'User with 1 not found.',
    });
  }
});
複製程式碼

使用async不用進行return返回,並且要使用try/catch來對異常進行捕獲。

程式碼覆蓋率

程式碼覆蓋率是一個測試指標,用來描述測試用例的程式碼是否都被執行。統計程式碼覆蓋率一般要藉助程式碼覆蓋工具,Jest整合了Istanbul這個程式碼覆蓋工具。

四個測量維度

  1. 行覆蓋率(line coverage):是否測試用例的每一行都執行了
  2. 函式覆蓋率(function coverage):師傅測試用例的每一個函式都呼叫了
  3. 分支覆蓋率(branch coverage):是否測試用例的每個if程式碼塊都執行了
  4. 語句覆蓋率(statement coverage):是否測試用例的每個語句都執行了

在四個維度中,如果程式碼書寫的很規範,行覆蓋率和語句覆蓋率應該是一樣的。會觸發分支覆蓋率的情況有很多種,主要有以下幾種:

  • ||,&&,?,!
  • if語句
  • switch語句

例子

function test(a, b) {
  a = a || 0;
  b = b || 0;
  if (a && b) {
    return a + b;
  } else {
    return 0;
  }
}

test(1, 2);
// test();
複製程式碼

當執行test(1,2)的時候,程式碼覆蓋率為

使用Jest進行React單元測試

當執行test()的時候,程式碼覆蓋率為

使用Jest進行React單元測試

設定閾值

stanbul可以在命令列中設定各個覆蓋率的門檻,然後再檢查測試用例是否達標,各個維度是與的關係,只要有一個不達標,就會報錯。

當statement和branch設定為90的時候,覆蓋率檢測會報

使用Jest進行React單元測試
當statemen設定為80t、branch設定為50的時候,覆蓋率檢測會通過

使用Jest進行React單元測試
在Jest中,可以通過coverageThreshold這個配置項來設定不同測試維度的覆蓋率閾值。global是全域性配置,預設所有的測試用例都要滿足這個配置才能通過測試。還支援萬用字元模式或者路徑配置,如果存在這些配置,那麼匹配到的檔案的覆蓋率將從全域性覆蓋率的計算中去除,獨立使用各自設定的閾值。

{
  ...
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 50,
        "functions": 50,
        "lines": 50,
        "statements": 50
      },
      "./src/components/": {
        "branches": 40,
        "statements": 40
      },
      "./src/reducers/**/*.js": {
        "statements": 90,
      },
      "./src/api/very-important-module.js": {
        "branches": 100,
        "functions": 100,
        "lines": 100,
        "statements": 100
      }
    }
  }
}
複製程式碼

整合到腳手架

在專案中引用單元測試後,希望每次修改需要測試的檔案時,能在提交程式碼前自動跑一邊測試用例,保證程式碼的正確性和健壯性。

在專案中可以使用husky和lint-staged,用來觸發git的hooks,做一些程式碼提交前的校驗。

  • husky:在專案中安裝husky以後,會在 .git/hooks 中寫入 pre-commit 等指令碼啟用鉤子,在 Git 進行相關操作時觸發
  • lint-staged:名字中的staged表示的就是Git中的暫存區,它只會對將要加入暫存區中的內容進行lint

在package.json中,precommit執行lint-staged,對lint-staged進行配置,對所有的js檔案進行eslint檢查,對src/components中的js檔案進行測試。

{
  "scripts": {
    "precommit": "lint-staged",
  },
   "lint-staged": {
    "ignore": [
      "build/*",
      "node_modules"
    ],
    "linters": {
      "src/*.js": [
        "eslint --fix",
        "git add"
      ],
      "src/components/**/*.js": [
        "jest --findRelatedTests --config .jest.js",
        "git add"
      ]
    }
  },
}
複製程式碼

對containers中的檔案進行修改,然後推進暫存區的時候,會進行eslint的檢查,但是不會進行測試

使用Jest進行React單元測試

對components中的todo-list進行修改,eslint會進行檢查,並且會執行todo-list這個元件的測試用例,因為改變了元件的結構,所以快照進行UI對比就會失敗

使用Jest進行React單元測試

相關文章