React單元測試方案
前置知識
為什麼要進行測試
- 測試可以確保得到預期的結果
- 作為現有程式碼行為的描述
- 促使開發者寫可測試的程式碼,一般可測試的程式碼可讀性也會高一點
- 如果依賴的元件有修改,受影響的元件能在測試中發現錯誤
測試型別
- 單元測試:指的是以原件的單元為單位,對軟體進行測試。單元可以是一個函式,也可以是一個模組或一個元件,基本特徵就是隻要輸入不變,必定返回同樣的輸出。一個軟體越容易些單元測試,就表明它的模組化結構越好,給模組之間的耦合越弱。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特點:
- 易用性:基於Jasmine,提供斷言庫,支援多種測試風格
- 適應性:Jest是模組化、可擴充套件和可配置的
- 沙箱和快照:Jest內建了JSDOM,能夠模擬瀏覽器環境,並且並行執行
- 快照測試:Jest能夠對React元件樹進行序列化,生成對應的字串快照,通過比較字串提供高效能的UI檢測
- Mock系統:Jest實現了一個強大的Mock系統,支援自動和手動mock
- 支援非同步程式碼測試:支援Promise和async/await
- 自動生成靜態分析結果:內建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);
});
});
複製程式碼
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兩種方式的非同步測試。
常見斷言
- expect(value):要測試一個值進行斷言的時候,要使用expect對值進行包裹
- toBe(value):使用Object.is來進行比較,如果進行浮點數的比較,要使用toBeCloseTo
- not:用來取反
- toEqual(value):用於物件的深比較
- toMatch(regexpOrString):用來檢查字串是否匹配,可以是正規表示式或者字串
- toContain(item):用來判斷item是否在一個陣列中,也可以用於字串的判斷
- toBeNull(value):只匹配null
- toBeUndefined(value):只匹配undefined
- toBeDefined(value):與toBeUndefined相反
- toBeTruthy(value):匹配任何使if語句為真的值
- toBeFalsy(value):匹配任何使if語句為假的值
- toBeGreaterThan(number): 大於
- toBeGreaterThanOrEqual(number):大於等於
- toBeLessThan(number):小於
- toBeLessThanOrEqual(number):小於等於
- toBeInstanceOf(class):判斷是不是class的例項
- anything(value):匹配除了null和undefined以外的所有值
- resolves:用來取出promise為fulfilled時包裹的值,支援鏈式呼叫
- rejects:用來取出promise為rejected時包裹的值,支援鏈式呼叫
- toHaveBeenCalled():用來判斷mock function是否被呼叫過
- toHaveBeenCalledTimes(number):用來判斷mock function被呼叫的次數
- assertions(number):驗證在一個測試用例中有number個斷言被呼叫
- extend(matchers):自定義一些斷言
Enzyme
三種渲染方法
- shallow:淺渲染,是對官方的Shallow Renderer的封裝。將元件渲染成虛擬DOM物件,只會渲染第一層,子元件將不會被渲染出來,使得效率非常高。不需要DOM環境, 並可以使用jQuery的方式訪問元件的資訊
- render:靜態渲染,它將React元件渲染成靜態的HTML字串,然後使用Cheerio這個庫解析這段字串,並返回一個Cheerio的例項物件,可以用來分析元件的html結構
- mount:完全渲染,它將元件渲染載入成一個真實的DOM節點,用來測試DOM API的互動和元件的生命週期。用到了jsdom來模擬瀏覽器環境
三種方法中,shallow和mount因為返回的是DOM物件,可以用simulate進行互動模擬,而render方法不可以。一般shallow方法就可以滿足需求,如果需要對子元件進行判斷,需要使用render,如果需要測試元件的生命週期,需要使用mount方法。
常用方法
- simulate(event, mock):模擬事件,用來觸發事件,event為事件名稱,mock為一個event object
- instance():返回元件的例項
- find(selector):根據選擇器查詢節點,selector可以是CSS中的選擇器,或者是元件的建構函式,元件的display name等
- at(index):返回一個渲染過的物件
- get(index):返回一個react node,要測試它,需要重新渲染
- contains(nodeOrNodes):當前物件是否包含引數重點 node,引數型別為react物件或物件陣列
- text():返回當前元件的文字內容
- html(): 返回當前元件的HTML程式碼形式
- props():返回根元件的所有屬性
- prop(key):返回根元件的指定屬性
- state():返回根元件的狀態
- setState(nextState):設定根元件的狀態
- 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
- 對全域性的模組(moduleName)進行手動模擬,需要在node_modules平級的位置新建一個__mocks__資料夾,並在資料夾中新建一個moduleName的檔案
- 對某個檔案(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這個程式碼覆蓋工具。
四個測量維度
- 行覆蓋率(line coverage):是否測試用例的每一行都執行了
- 函式覆蓋率(function coverage):師傅測試用例的每一個函式都呼叫了
- 分支覆蓋率(branch coverage):是否測試用例的每個if程式碼塊都執行了
- 語句覆蓋率(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對比就會失敗