使用Jest進行React單元測試

React單元測試方案

前置知識

爲何要進行測試

  1. 測試能夠確保獲得預期的結果
  2. 做爲現有代碼行爲的描述
  3. 促使開發者寫可測試的代碼,通常可測試的代碼可讀性也會高一點
  4. 若是依賴的組件有修改,受影響的組件能在測試中發現錯誤

測試類型

  • 單元測試:指的是以原件的單元爲單位,對軟件進行測試。單元能夠是一個函數,也能夠是一個模塊或一個組件,基本特徵就是隻要輸入不變,一定返回一樣的輸出。一個軟件越容易些單元測試,就代表它的模塊化結構越好,給模塊之間的耦合越弱。React的組件化和函數式編程,天生適合進行單元測試
  • 功能測試:至關因而黑盒測試,測試者不瞭解程序的內部狀況,不須要具有編程語言的專門知識,只知道程序的輸入、輸出和功能,從用戶的角度針對軟件界面、功能和外部結構進行測試,不考慮內部的邏輯
  • 集成測試:在單元測試的基礎上,將全部模塊按照設計要求組裝成子系統或者系統,進行測試
  • 冒煙測試:在正式全面的測試以前,對主要功能進行的與測試,確認主要功能是否知足須要,軟件是否能正常運行

開發模式

  • TDD: 測試驅動開發,英文爲Testing Driven Development,強調的是一種開發方式,以測試來驅動整個項目,即先根據接口完成測試編寫,而後在完成功能是要不斷經過測試,最終目的是經過全部測試
  • BDD: 行爲驅動測試,英文爲Behavior Driven Development,強調的是寫測試的風格,即測試要寫的像天然語言,讓項目的各個成員甚至產品都能看懂測試,甚至編寫測試

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

技術選型:Jest + Enzyme

Jest

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

  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,html

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

測試環境搭建

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

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

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

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

.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函數react

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);
  });
  
});
複製代碼

config

Jest擁有豐富的配置項,能夠寫在package.json裏增長增長jest字段來進行配置,或者經過命令行--config來指定配置文件。ios

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-apigit

快照

快照會生成一個組件的UI結構,並用字符串的形式存放在__snapshots__文件裏,經過比較兩個字符串來判斷UI是否改變,由於是字符串比較,因此性能很高。github

要使用快照功能,須要引入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_:

在測試文件中使用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)的時候,代碼覆蓋率爲

當執行test()的時候,代碼覆蓋率爲

設置閾值

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

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

當statemen設置爲80t、branch設置爲50的時候,覆蓋率檢測會經過

在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的檢查,可是不會進行測試

對components中的todo-list進行修改,eslint會進行檢查,而且會執行todo-list這個組件的測試用例,由於改變了組件的結構,因此快照進行UI對比就會失敗

相關文章
相關標籤/搜索