TDD和BDD有各自的使用場景,BDD通常偏向於系統功能和業務邏輯的自動化測試設計;而TDD在快速開發並測試功能模塊的過程當中則更加高效,以快速完成開發爲目的。javascript
Jest是Facebook開源的一個前端測試框架,主要用於React和React Native的單元測試,已被集成在create-react-app中。Jest特色:css
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"
},
};
複製代碼
全局和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);
});
});
複製代碼
Jest擁有豐富的配置項,能夠寫在package.json裏增長增長jest字段來進行配置,或者經過命令行--config來指定配置文件。ios
使用mock函數能夠輕鬆的模擬代碼之間的依賴,能夠經過fn或spyOn來mock某個具體的函數;經過mock來模擬某個模塊。具體的API能夠看mock-function-api。git
快照會生成一個組件的UI結構,並用字符串的形式存放在__snapshots__文件裏,經過比較兩個字符串來判斷UI是否改變,由於是字符串比較,因此性能很高。github
要使用快照功能,須要引入react-test-renderer庫,使用其中的renderer方法,jest在執行的時候若是發現toMatchSnapshot方法,會在同級目錄下生成一個__snapshots文件夾用來存放快照文件,之後每次測試的時候都會和第一次生成的快照進行比較。可使用jest --updateSnapshot來更新快照文件。
Jest支持對異步的測試,支持Promise和Async/Await兩種方式的異步測試。
三種方法中,shallow和mount由於返回的是DOM對象,能夠用simulate進行交互模擬,而render方法不能夠。通常shallow方法就能夠知足需求,若是須要對子組件進行判斷,須要使用render,若是須要測試組件的生命週期,須要使用mount方法。
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>
);
}
}
複製代碼
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,
};
};
複製代碼
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進行一些操做。
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對象,用於對異步的處理。
// 使用'.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來測試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這個代碼覆蓋工具。
在四個維度中,若是代碼書寫的很規範,行覆蓋率和語句覆蓋率應該是同樣的。會觸發分支覆蓋率的狀況有不少種,主要有如下幾種:
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,作一些代碼提交前的校驗。
在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對比就會失敗