第一次嘗試將學習過的知識經過文章的方式記錄下來。在寫文章的過程當中,發現本身更多的不足以及測試的重要性。本篇文章主要是記錄使用Jest + Enzyme進行React技術棧單元測試所須要掌握的基本知識以及環境搭建。css
黑盒
:徹底不考慮程序的內部結構以及工做過程,僅關注程序的功能是否都正常。白盒
:已知程序內部邏輯結構,經過測試來證實程序內部能按照預約要求正確工做。灰盒
:介於黑盒與白盒之間的一種測試,關注輸出對於輸入的正確性,同時也關注內部表現,但這種關注不象白盒那樣詳細、完整。功能
:測試程序是否知足用戶提出的表面需求。性能
:測試程序的工做效率。安全
:測試程序是否能保護用戶的信息、確保信息不被輕易盜取。兼容性
:測試程序在不一樣平臺下的表現。易用性
:測試程序是否友好,知足用戶的使用習慣。UI元素
:頁面佈局是否一致、美觀。測試框架、斷言庫(chai、expect.js、should.js、Sinon.JS等)、工具備不少,如下僅列出一些比較常見的或是本人正在使用的測試框架/工具。html
一、單元測試(unit tests)前端
二、快照測試(snapshot tests)node
三、端對端測試(e2e tests)react
技術選型jquery
環境搭建github
安裝 Jest
npm
npm install --save-dev jest
複製代碼
安裝 Enzyme
npm install --save-dev enzyme jest-enzyme
// react適配器須要與react版本想對應 參考: https://airbnb.io/enzyme/
npm install --save-dev enzyme-adapter-react-16
// 若是使用的是16.4及以上版本的react,還能夠經過安裝jest-environment-enzyme來設置jest的環境
npm install --save-dev jest-environment-enzyme
複製代碼
安裝 Babel
npm install --save-dev babel-jest babel-core
npm install --save-dev babel-preset-env
npm install --save-dev babel-preset-react
// 無所不能stage-0
npm install --save-dev babel-preset-stage-0
// 按需加載插件
npm install --save-dev babel-plugin-transform-runtime
複製代碼
修改 package.json
// package.json
{
"scripts": {
"test": "jest"
}
}
複製代碼
安裝其餘須要用到的庫
// 安裝jquery來操做dom
npm install --save jquery
複製代碼
Jest
配置
更多關於Jest
的配置請查閱jestjs.io/docs/zh-Han…
// jest.config.js
module.exports = {
setupFiles: ['./jest/setup.js'], // 配置測試環境,這些腳本將在執行測試代碼自己以前當即在測試環境中執行。
setupTestFrameworkScriptFile: 'jest-enzyme', // 配置測試框架
testEnvironment: 'enzyme', // 使用jest-environment-enzyme時所需的配置
testEnvironmentOptions: {
enzymeAdapter: 'react16', // react適配器的版本
},
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/src/'], // 忽略的目錄
transform: { // 編譯配置
'^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.(css|scss)$': '<rootDir>/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '<rootDir>/jest/fileTransform.js',
},
};
複製代碼
使用Jest測試一個Function
// add.js
const add = (a, b) => a + b;
export default add;
複製代碼
// __tests__/add-test.js
import add from '../add';
describe('add() test:', () => {
it('1+2=3', () => {
expect(add(1, 2)).toBe(3); // 斷言是經過的,可是若是咱們傳入的是string類型呢?
});
});
複製代碼
// 執行Jest
npm test
// 或
jest add-test.js --verbose
複製代碼
快照測試
若是想確保UI不會意外更改,快照測試就是一個很是有用的工具。
// 安裝react、react-dom以及react-test-renderer
npm install --save react react-dom react-test-renderer
複製代碼
// components/Banner.js
import React from 'react';
const Banner = ({ src }) => (
<div>
<img src={src} alt="banner" />
</div>
);
export default Banner;
複製代碼
// __tests__/components/Banner-test.js
import React from 'react';
import renderer from 'react-test-renderer';
import Banner from '../../components/Banner';
describe('<Banner />', () => {
it('renders correctly', () => {
const tree = renderer.create(<Banner />).toJSON();
expect(tree).toMatchSnapshot();
});
});
複製代碼
JSDOM(JS實現的無頭瀏覽器)
jsdom最強大的能力是它能夠在jsdom中執行腳本。這些腳本能夠修改頁面內容並訪問jsdom實現的全部Web平臺API。
// handleBtn.js
const $ = require('jquery');
$('#btn').click(() => $('#text').text('click on the button'));
複製代碼
// handleBtn-test.js
describe('JSDOM test', () => {
it('click on the button', () => {
// initialization document
document.body.innerHTML = '<div id="btn"><span id="text"></span></div>';
const $ = require('jquery');
require('../handleBtn');
// simulation button click
$('#btn').click();
// the text is updated as expected
expect($('#text').text()).toEqual('click on the button');
});
});
複製代碼
Mock模塊
在須要Mock的模塊目錄下新建一個__mocks__
目錄,而後新建同樣的文件名,最後在測試代碼中添加上jest.mock('../moduleName')
,便可實現模塊的Mock。
// request.js
const http = require('http');
export default function request(url) {
return new Promise(resolve => {
// 這是一個HTTP請求的例子, 用來從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));
});
});
}
複製代碼
// __mocks__/request.js
const users = {
4: { name: 'Mark' },
5: { name: 'Paul' },
};
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(new Error(`User with ${userID} not found.`))));
});
}
複製代碼
// __tests__/request.js
jest.mock('../request.js');
import request from '../request';
describe('mock request.js', () => {
it('works with async/await', async () => {
expect.assertions(2); // 調用2個斷言
// 正確返回的斷言
const res = await request('/users/4');
expect(res).toEqual({ name: 'Mark' });
// 錯誤返回的斷言
await expect(request('/users/41')).rejects.toThrow('User with 41 not found.');
});
});
複製代碼
測試組件節點
shallow
:淺渲染,將組件做爲一個單元進行測試,並確保您的測試不會間接斷言子組件的行爲。支持交互模擬以及組件內部函數測試render
:靜態渲染,將React組件渲染成靜態的HTML字符串,而後使用Cheerio這個庫解析這段字符串,並返回一個Cheerio的實例對象,能夠用來分析組件的html結構。可用於子組件的判斷。mount
:徹底渲染,完整DOM渲染很是適用於您擁有可能與DOM API交互或須要測試包含在更高階組件中的組件的用例。依賴jsdom
庫,本質上是一個徹底用JS實現的無頭瀏覽器。支持交互模擬以及組件內部函數測試// components/List.js
import React, { Component } from 'react';
export default class List extends Component {
constructor(props) {
super(props);
this.state = {
list: [1],
};
}
render() {
const { list } = this.state;
return (
<div>
{list.map(item => (
<p key={item}>{item}</p>
))}
</div>
);
}
}
複製代碼
// __tests__/components/List-test.js
import React from 'react';
import { shallow, render, mount } from 'enzyme';
import List from '../../components/List';
describe('<List />', () => {
it('shallow:render <List /> component', () => {
const wrapper = shallow(<List />);
expect(wrapper.find('div').length).toBe(1);
});
it('render:render <List /> component', () => {
const wrapper = render(<List />);
expect(wrapper.html()).toBe('<p>1</p>');
});
it('mount:allows us to setState', () => {
const wrapper = mount(<List />);
wrapper.setState({
list: [1, 2, 3],
});
expect(wrapper.find('p').length).toBe(3);
});
});
複製代碼
測試組件內部函數
// components/TodoList.js
import React, { Component } from 'react';
export default class TodoList extends Component {
constructor(props) {
super(props);
this.state = {
list: [],
};
}
handleBtn = () => {
const { list } = this.state;
this.setState({
list: list.length ? [...list, list.length] : [0],
});
};
render() {
const { list } = this.state;
return (
<div>
{list.map(item => (
<p key={item}>{item}</p>
))}
<button type="button" onClick={() => this.handleBtn}>
add item
</button>
</div>
);
}
}
複製代碼
// __tests__/components/TodoList-test.js
import React from 'react';
import { shallow } from 'enzyme';
import TodoList from '../../components/TodoList';
describe('<TodoList />', () => {
it('calls component handleBtn', () => {
const wrapper = shallow(<TodoList />);
// 建立模擬函數
const spyHandleBtn = jest.spyOn(wrapper.instance(), 'handleBtn');
// list的默認長度是0
expect(wrapper.state('list').length).toBe(0);
// 首次handelBtn
wrapper.instance().handleBtn();
expect(wrapper.state('list').length).toBe(1);
// 模擬按鈕點擊
wrapper.find('button').simulate('click');
expect(wrapper.state('list').length).toBe(2);
// 總共執行handleBtn函數兩次
expect(spyHandleBtn).toHaveBeenCalledTimes(2);
// 恢復mockFn
spyHandleBtn.mockRestore();
});
});
複製代碼
測試代碼覆蓋率
// jest.config.js
module.exports = {
collectCoverage: true, // 收集覆蓋率信息
coverageThreshold: { // 設置覆蓋率最低閾值
global: {
branches: 50,
functions: 50,
lines: 50,
statements: 50,
},
'./firstTest/components': {
branches: 100,
},
},
};
複製代碼
安裝
npm install --save redux-thunk
npm install --save redux-saga
npm install --save-dev fetch-mock redux-mock-store redux-actions-assertions
npm install -g node-fetch
複製代碼
測試同步action
// actions/todoActions.js
export const addTodo = text => ({ type: 'ADD_TODO', text });
export const delTodo = text => ({ type: 'DEL_TODO', text });
複製代碼
// __tests__/actions/todoActions-test.js
import * as actions from '../../actions/todoActions';
describe('actions', () => {
it('addTodo', () => {
const text = 'hello redux';
const expectedAction = {
type: 'ADD_TODO',
text,
};
expect(actions.addTodo(text)).toEqual(expectedAction);
});
it('delTodo', () => {
const text = 'hello jest';
const expectedAction = {
type: 'DEL_TODO',
text,
};
expect(actions.delTodo(text)).toEqual(expectedAction);
});
});
複製代碼
測試基於redux-thunk的異步action
// actions/fetchActions.js
export const fetchTodosRequest = () => ({ type: 'FETCH_TODOS_REQUEST' });
export const fetchTodosSuccess = data => ({
type: 'FETCH_TODOS_SUCCESS',
data,
});
export const fetchTodosFailure = data => ({
type: 'FETCH_TODOS_FAILURE',
data,
});
export function fetchTodos() {
return dispatch => {
dispatch(fetchTodosRequest());
return fetch('http://example.com/todos')
.then(res => res.json())
.then(body => dispatch(fetchTodosSuccess(body)))
.catch(ex => dispatch(fetchTodosFailure(ex)));
};
}
複製代碼
// __tests__/actions/fetchActions-test.js
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../actions/fetchActions';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('fetchActions', () => {
afterEach(() => {
fetchMock.restore();
});
it('在獲取todos以後建立FETCH_TODOS_SUCCESS', async () => {
fetchMock.getOnce('/todos', {
body: { todos: ['do something'] },
headers: { 'content-type': 'application/json' },
});
// 所期盼的action執行記錄:FETCH_TODOS_REQUEST -> FETCH_TODOS_SUCCESS
const expectedActions = [
{ type: 'FETCH_TODOS_REQUEST' },
{ type: 'FETCH_TODOS_SUCCESS', data: { todos: ['do something'] } },
];
const store = mockStore({ todos: [] });
// 經過async/await來優化異步操做的流程
await store.dispatch(actions.fetchTodos());
// 斷言actios是否正確執行
expect(store.getActions()).toEqual(expectedActions);
});
it('在獲取todos以後建立FETCH_TODOS_FAILURE', async () => {
fetchMock.getOnce('/todos', {
throws: new TypeError('Failed to fetch'),
});
const expectedActions = [
{ type: 'FETCH_TODOS_REQUEST' },
{ type: 'FETCH_TODOS_FAILURE', data: new TypeError('Failed to fetch') },
];
const store = mockStore({ todos: [] });
await store.dispatch(actions.fetchTodos());
expect(store.getActions()).toEqual(expectedActions);
});
});
複製代碼
測試 Sagas
有兩個主要的測試 Sagas 的方式:一步一步測試 saga generator function,或者執行整個 saga 並斷言 side effects。
// sagas/uiSagas.js
import { put, take } from 'redux-saga/effects';
export const CHOOSE_COLOR = 'CHOOSE_COLOR';
export const CHANGE_UI = 'CHANGE_UI';
export const chooseColor = color => ({
type: CHOOSE_COLOR,
payload: {
color,
},
});
export const changeUI = color => ({
type: CHANGE_UI,
payload: {
color,
},
});
export function* changeColorSaga() {
const action = yield take(CHOOSE_COLOR);
yield put(changeUI(action.payload.color));
}
複製代碼
// __tests__/sagas/uiSagas-test.js
import { put, take } from 'redux-saga/effects';
import {
changeColorSaga, CHOOSE_COLOR, chooseColor, changeUI,
} from '../../sagas/uiSagas';
describe('uiSagas', () => {
it('changeColorSaga', () => {
const gen = changeColorSaga();
expect(gen.next().value).toEqual(take(CHOOSE_COLOR));
const color = 'red';
expect(gen.next(chooseColor(color)).value).toEqual(put(changeUI(color)));
});
});
複製代碼
// sagas/fetchSagas.js
import { put, call } from 'redux-saga/effects';
export const fetchDatasSuccess = data => ({
type: 'FETCH_DATAS_SUCCESS',
data,
});
export const fetchDatasFailure = data => ({
type: 'FETCH_DATAS_FAILURE',
data,
});
export const myFetch = (...parmas) => fetch(...parmas).then(res => res.json());
export function* fetchDatas() {
try {
const result = yield call(myFetch, '/datas');
yield put(fetchDatasSuccess(result));
} catch (error) {
yield put(fetchDatasFailure(error));
}
}
複製代碼
// __tests__/sagas/fetchSagas-test.js
import { runSaga } from 'redux-saga';
import { put, call } from 'redux-saga/effects';
import fetchMock from 'fetch-mock';
import {
fetchDatas, fetchDatasSuccess, fetchDatasFailure, myFetch,
} from '../../sagas/fetchSagas';
describe('fetchSagas', () => {
afterEach(() => {
fetchMock.restore();
});
// 一步步generator function 並斷言 side effects
it('fetchDatas success', async () => {
const body = { text: 'success' };
fetchMock.get('/datas', {
body,
headers: { 'content-type': 'application/json' },
});
const gen = fetchDatas();
// 調用next().value來獲取被yield的effect,並拿它和指望返回的effect進行比對
expect(gen.next().value).toEqual(call(myFetch, '/datas'));
const result = await fetch('/datas').then(res => res.json());
expect(result).toEqual(body);
// 請求成功
expect(gen.next(result).value).toEqual(put(fetchDatasSuccess(body)));
});
it('fetchDatas fail', () => {
const gen = fetchDatas();
expect(gen.next().value).toEqual(call(myFetch, '/datas'));
// 模擬異常時的處理是否預期
const throws = new TypeError('Failed to fetch');
expect(gen.throw(throws).value).toEqual(put(fetchDatasFailure(throws)));
});
// 執行整個 saga 並斷言 side effects。(推薦方案)
it('runSage success', async () => {
const body = { text: 'success' };
fetchMock.get('/datas', {
body,
headers: { 'content-type': 'application/json' },
});
const dispatched = [];
await runSaga({
dispatch: action => dispatched.push(action),
}, fetchDatas).done;
expect(dispatched).toEqual([fetchDatasSuccess(body)]);
});
it('runSage fail', async () => {
const throws = new TypeError('Failed to fetch');
fetchMock.get('/datas', {
throws,
});
const dispatched = [];
await runSaga({
dispatch: action => dispatched.push(action),
}, fetchDatas).done;
expect(dispatched).toEqual([fetchDatasFailure(throws)]);
});
});
複製代碼
測試 reducers
// reducers/todos.js
export default function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [
{
text: action.text,
},
...state,
];
default:
return state;
}
}
複製代碼
// __tests__/reducers/todos-test.js
import todos from '../../reducers/todos';
describe('reducers', () => {
it('should return the initial state', () => {
expect(todos(undefined, {})).toEqual([]);
});
it('todos initial', () => {
expect(todos([{ text: '1' }], {})).toEqual([{ text: '1' }]);
});
it('should handle ADD_TODO', () => {
expect(todos([], { type: 'ADD_TODO', text: 'text' })).toEqual([
{
text: 'text',
},
]);
});
});
複製代碼