第一次尝试将学习过的知识经过文章的方式记录下来。在写文章的过程当中,发现本身更多的不足以及测试的重要性。本篇文章主要是记录使用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',
},
]);
});
});
复制代码