万字总结2019年前端测试体系建设与最佳实践
The following article is from 奶爸码农 Author 柯培霖
作者:柯培霖,美团点评前端工程师,获得作者授权转载
2019 年前端测试依然是一个炙手可热的话题。笔者在今年 5 月份参加 Vueconf 的时候,Vue 单元测试的主题演讲者曾向现场的参与者发出提问,有多少团队引入了单元测试,意外的是只有寥寥数人举起了手。尽管,那个时候笔者的团队也还没有引入前端测试,但是考虑到测试的必要性,且团队正在着手一个新项目,所以回去之后在这个新项目全量地接入了前端测试。
现如今大部分互联网团队都是走 敏捷开发 的节奏。实际上,自动化测试才是实现“敏捷”的基本保障。业务端的快速上线和快速验证对技术侧的响应力提出了更高的要求:更快上线,持续上线。再考虑到人员流动和应用逐步变大的事实,日后迭代的成本只会变得越来越高。当然这个项目迭代的成本也跟项目的复杂度有关,比如笔者所在的点餐业务,项目有足够的复杂性,有些细微的改动点其实会牵扯到很多内容,而对刚加入团队的新人就会显得不太友好。因此,项目拥有前端测试是必不可少的,它能够有效保障业务迭代的质量和稳定性。
什么是前端测试?
我们经常说的单元测试其实只是前端测试的一种。前端测试分为单元测试,UI 测试,集成测试和端到端测试。
单元测试:是指对软件中的最小可测试单元进行检查和验证,通常指的是独立测试单个函数。
UI 测试:是对图形交互界面的测试。
集成测试:就是测试应用中不同模块如何集成,如何一起工作,这和它的名字一致。
端到端测试(e2e):是站在用户角度的测试,把我们的程序看成是一个黑盒子,我不懂你内部是怎么实现的,我只负责打开浏览器,把测试内容在页面上输入一遍,看是不是我想要得到的结果。
技术选型
前端测试的框架可谓是百花齐放。
单元测试有 Mocha, Ava, Karma, Jest, Jasmine 等。
UI 测试有 ReactTestUtils, Test Render, Enzyme, React-Testing-Library, Vue-Test-Utils 等。
e2e 测试有 Nightwatch, Cypress, Phantomjs, Puppeteer 等。
因为我们的项目使用的是 React 技术栈,这里主要介绍 React 项目的技术选型和使用。
单元测试
Mocha 是生态最好,使用最广泛的单测框架,但是他需要较多的配置来实现它的高扩展性。
Ava 是更轻量高效简单的单测框架,但是自身不够稳定,并发运行文件多的时候会撑爆 CPU.
Jasmine 是单测框架的“元老”,开箱即用,但是异步测试支持较弱。
Jest 基于 Jasmine, 做了大量修改并添加了很多特性,同样开箱即用,但异步测试支持良好。
Karma 能在真实的浏览器中测试,强大适配器,可配置其他单测框架,一般会配合 Mocha 或 Jasmine 等一起使用。
每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。Augular 的默认测试框架就是 Karma + Jasmine,而 React 的默认测试框架是 Jest.
Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。
UI 测试
UI 测试尽管有官方的测试框架 ReactTestUtils 和 Test Render,但是它们的 API 比较复杂,官方文档也是推荐使用 react-testing-library 或 Enzyme 这两个库。
Note: We recommend using React Testing Library which is designed to enable and encourage writing tests that use your components as the end users do. Alternatively, Airbnb has released a testing utility called Enzyme, which makes it easy to assert, manipulate, and traverse your React Components’ output.
React Testing Library 和 Enzyme 都是基于 ReactTestUtils 和 Test Render,封装了更简洁易用的 API。
Enzyme 出来的更早,但是它常常会滞后于 React 功能的实现(大约半年左右,比如不支持 hooks,我不确定现在是否支持了)。React Testing Library 出的比较晚,但倾向于支持 React 的新功能,这对我来说在测试 Hooks 时是一个巨大的好处。
Enzyme 是从代码实现的角度出发进行测试,基于 state 和 props,而 React Testing Library 是从用户体验的角度出发,所以是基于 dom 进行测试。它也可能有更好的开发体验,以及更稳定的测试。这种方法使重构变得轻而易举,同时也可以实现可访问性的最佳实践。
当然因为 Enzyme 出的比较早,它的周围生态更好,很多大厂都用了它,不过也有一些正在做 迁移。我希望能够尝试更新更好的框架,所以最后选择了 React Testing Library.
e2e 测试
Puppeteer 是 Google Chrome 团队推出的库,尽管它相对其他 e2e 框架更新,但它同样也有一个庞大的社区。它拥有更简洁易用的 API,更快的运行速度,已逐渐成为业内自动化测试的标杆,俘获大量 Selenium 用户的心。可以看下近年来 e2e 测试框架的 npm trends.
结论
经过分析,最后我们项目的技术选型为 Jest + React Testing Library + Puppeteer
而对于 Vue 的项目,为了保持技术栈的统一,我们选用了 Jest + Vue-Test-Utils + Puppeteer
编写原则
测试代码时,只考虑测试,不考虑内部实现
数据尽量模拟现实,越靠近现实越好
充分考虑数据的边界条件
对重点、复杂、核心代码,重点测试
利用 AOP(beforeEach、afterEach),减少测试代码数量,避免无用功能
测试、功能开发相结合,有利于设计和代码重构
编写说明
未来的项目都是基于 Talos 生成,其实也就是使用了 Create-React-App 生成 React 项目,使用了 Vue-CLI@3 生成了 Vue 项目。本身默认都有 Jest 的配置,不需做大的改动。
单元测试和 UI 测试的文件夹统一命名为 tests,测试文件以 .test.js 为后缀
将 tests 文件夹与它们正在测试的代码放在同级目录下,以便相对路径导入时路径更短
e2e 测试的文件夹命名为 e2e,并与 src 同放在根目录下
VScode 和 WebStorm 都有对应的 Jest 插件,安装后书写代码时有代码补全,debug 和自动运行等功能
如何编写测试
其实,Jest 的语法蛮简单的,只需要熟悉几个 API 就可以快速上手测试了。
工具类函数的单元测试
// lib/utils.js
export function hexToRGB(hexColor) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor);
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [];
}
// lib/__tests__/utils.test.js
import { hexToRGB } from '../util';
describe('将16进制颜色转为 rgb', () => {
it('小写', () => {
expect(hexToRGB('#ffc150')).toEqual([255, 193, 80]);
});
it('大写', () => {
expect(hexToRGB('#FFC150')).toEqual([255, 193, 80]);
});
});
只需要给定函数的输入,之后调用函数,验证它的输出与期望的是否一样。
Redux 的单元测试
测试 Reducer
Reducer 把 action 合并到之前的 state,并返回新的 state。因为项目里使用了 Immutable.js,所以需要使用 merge 操作。
// store/reducers/cart.js
import Immutable from 'immutable';
import { UPDATE_CART_DISH_LIST, UPDATE_CART_DISH_SORT_MAP_LIST } from '../actions/cart';
export const initialState = Immutable.Map({
cartDishList: Immutable.Map({}),
cartDishSortMapList: Immutable.List([]),
});
export default (state = initialState, action) => {
switch (action.type) {
case UPDATE_CART_DISH_LIST:
return state.merge({
cartDishList: action.cartDishList,
});
case UPDATE_CART_DISH_SORT_MAP_LIST:
return state.merge({
cartDishSortMapList: action.cartDishSortMapList,
});
default:
return state;
}
};
// store/reducers/__tests__/cart.js
import Immutable from 'immutable';
import cartReducer, { initialState } from '../cart';
import { UPDATE_CART_DISH_LIST, UPDATE_CART_DISH_SORT_MAP_LIST } from '../../actions/cart';
describe('cart reducer', () => {
it('返回初始化的 state', () => {
expect(cartReducer(undefined, {})).toEqual(initialState);
});
it('更新购物车', () => {
const state = Immutable.Map({ cartDishList: null });
const action = { type: UPDATE_CART_DISH_LIST, cartDishList: Immutable.Map({}) };
const newState = Immutable.Map({ cartDishList: Immutable.Map({}) });
expect(cartReducer(state, action)).toEqual(newState);
});
it('更新购物车菜品顺序', () => {
const state = Immutable.Map({ cartDishSortMapList: null });
const action = { type: UPDATE_CART_DISH_SORT_MAP_LIST, cartDishSortMapList: Immutable.List([]) };
const newState = Immutable.Map({ cartDishSortMapList: Immutable.List([]) });
expect(cartReducer(state, action)).toEqual(newState);
});
});
测试普通 Action
普通 Action 就是一个返回了普通对象的函数,测试起来相当简单。
// store/actions/cart.js
export const UPDATE_SHOPID = 'UPDATE_SHOPID';
export function updateShopIdAction(shopId) {
return {
type: UPDATE_SHOPID,
shopId,
};
}
// store/actions/__tests__/cart.test.js
import { updateShopIdAction, UPDATE_SHOPID } from '../cart';
test('更新 shopId', () => {
const expectedAction = { type: UPDATE_SHOPID, shopId: '111' };
expect(updateShopIdAction('111')).toEqual(expectedAction);
});
测试带中间件的复合 Action
项目里使用了 redux-thunk
这个中间件,我们需要使用 redux-mock-store
来把中间件应用于模拟的 store
.
// store/actions/cart.js
export function updateShopIdAction(shopId) {
return {
type: UPDATE_SHOPID,
shopId,
};
}
export function updateTableNumAction(tableNum) {
return {
type: UPDATE_TABLE_NUM,
tableNum,
};
}
export function updateBaseInfo(shopId, tableNum) {
return (dispatch) => {
dispatch(updateShopIdAction(shopId));
dispatch(updateTableNumAction(tableNum));
};
}
// store/actions/__tests__/cart.test.js
import configureStore from 'redux-mock-store';
import Immutable from 'immutable';
import thunk from 'redux-thunk';
import { updateBaseInfo, UPDATE_SHOPID, UPDATE_TABLE_NUM } from '../cart';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
test('updateBaseInfo', () => {
const store = mockStore(Immutable.Map({}));
store.dispatch(updateBaseInfo('111', '111'));
const actions = store.getActions();
const expectPayloads = [{ type: UPDATE_SHOPID, shopId: '111' }, { type: UPDATE_TABLE_NUM, tableNum: '111' }];
expect(actions).toEqual(expectPayloads);
});
测试异步 Action
我们需要借助 axios-mock-adapter
这个包来模拟请求。Create-React-App
默认会安装这个包。
// store/asyncActions/shop.js
import { loadShopInfoAction } from '../actions/main';
import shop from '../../api/shop';
import Loading from '../../components/common/Loading';
export const getShopInfo = (mtShopId, tableNum) => (dispatch) => {
Loading.show();
return shop.getShopInfo({ mtShopId, tableNum })
.then((res) => {
Loading.close();
const result = res.data;
dispatch(loadShopInfoAction(result));
})
.catch((e) => {
Loading.close();
console.error(e);
});
};
// store/asyncActions/__tests__/shop.test.js
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import MockAdapter from 'axios-mock-adapter';
import instance from '@lib/axios';
import { getShopInfo } from '../main';
import { LOAD_SHOP_INFO } from '../../actions/main';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const mockHttp = new MockAdapter(instance); // 继承了 axios.js 文件里的配置
test('getShopInfo', () => {
const store = mockStore({});
const expectPayloads = [{ type: LOAD_SHOP_INFO, shopInfo: { peopleCount: 0 } }];
mockHttp.onGet('/shopInfo')
.reply(200, {
data: { peopleCount: 0 },
});
store.dispatch(getShopInfo())
.then(() => {
const actions = store.getActions();
expect(actions).toEqual(expectPayloads);
});
});
UI 测试
对于单元测试来说,是成本低且收益高,而对 UI 测试来说,可能更像是成本高但收益低。但是对于一些公共组件的测试还是很有必要的,就像笔者前文说到过的一样,当项目的代码足够复杂时,一个通用组件的改动迎接你的可能就是一个线上 Case。
函数组件
下面简单的看一个加减菜组件的测试(精简了一部分逻辑)。
import React from 'react';
import Immutable from 'immutable';
import './NumberCount.less';
const NumberCount = (props) => {
const { dish, count, addToCart, minusDish, minusPoint,} = props;
return (
<div className="number-count">
{
count > 0
? (
<>
<div className="minus">
<span className="minus-icon" />
<span className="minus-trigger" onClick={() => minusDishToCart(dish)} />
</div>
<div className="num">{count}</div>
</>
)
: null
}
<div className="plus">
<span className="plus-icon" />
<span className="plus-trigger" onClick={() => addToCart(dish)} />
</div>
</div>
);
};
export default NumberCount;
对于这个组件来说,逻辑还是比较简单的。我们的测试点在加菜和减菜按钮的事件是否被正确触发,当数量为 0 时,减号按钮和数量是否展示,数量不为 0 时,展示是否正确。
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import Immutable from 'immutable';
import NumberCount from '../NumberCount';
afterEach(cleanup);
test('点击加菜按钮', () => {
const props = {
dish: Immutable.fromJS({ spuId: 111 }),
count: 0,
addToCart: jest.fn(),
};
const { container } = render(<NumberCount {...props} />);
const addButton = container.querySelector('.plus-trigger');
const minusButton = container.querySelector('.minus-trigger');
const numDiv = container.querySelector('.num');
expect(minusButton).toBeNull();
expect(numDiv).toBeNull();
fireEvent.click(addButton);
expect(props.addToCart).toBeCalledWith(props.dish);
});
test('点击减菜按钮', () => {
const props = {
dish: Immutable.fromJS({ spuId: 111 }),
count: 1,
addToCart: jest.fn(),
minusDish: jest.fn(),
};
const { container } = render(<NumberCount {...props} />);
const minusButton = container.querySelector('.minus-trigger');
const numDiv = container.querySelector('.num');
expect(numDiv.innerHTML).toBe('1');
fireEvent.click(minusButton);
expect(props.minusDish).toBeCalledWith(props.dish);
});
使用 connect 包裹后的高阶组件
尽管理论上 components 里面的公共组件都应该是无状态组件,但是有时候有些公用的组件写成有状态组件可能更容易被使用,开发成本更低。Redux 官方推荐直接测试 connect 包裹前的组件。
In order to be able to test the App component itself without having to deal with the decorator, we recommend you to also export the undecorated component.
这边给出一个样例。
import React from 'react';
import { connect } from 'react-redux';
import Immutable from 'immutable';
import ImmutableBaseComponent from './ImmutableBaseComponent';
import { selectSkuDish, toggleMultiPanelAction } from '../../store/actions/cart';
import { computeCount } from '@modules/cartHelper';
import './SelectDish.less';
// 需要将这个未被 connect 的组件 export, 用于测试
export class SelectDish extends ImmutableBaseComponent {
togglePanel = (e, spuDish) => {
e.stopPropagation();
this.props.selectSkuDish(spuDish);
this.props.toggleMultiPanelAction(true);
}
render() {
const { spuDish, cartDishList } = this.props;
const count = computeCount(spuDish, cartDishList);
return (
<div className="select-dish" onClick={e => this.togglePanel(e, spuDish)}>
选择
{ count > 0 && <span>{count}</span> }
</div>
);
}
}
const mapStateToProps = state => ({
cartDishList: state.getIn(['cart', 'cartDishList']),
});
const mapDispatchToProps = dispatch => ({
selectSkuDish: spuDish => dispatch(selectSkuDish(spuDish)),
toggleMultiPanelAction: show => dispatch(toggleMultiPanelAction(show)),
});
export default connect(mapStateToProps, mapDispatchToProps)(SelectDish);
可以看到代码中 export 了包裹前的 SelectDish, 这样就能进行如下测试:
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import Immutable from 'immutable';
import { SelectDish } from '../SelectDish';
afterEach(cleanup);
test('选择多规格菜品', () => {
const props = {
spuDish: Immutable.fromJS({ spuId: '111' }),
cartDishList: Immutable.fromJS({}),
selectSkuDish: jest.fn(),
toggleMultiPanelAction: jest.fn(),
};
const { container } = render(<SelectDish {...props} />);
const selectButton = container.querySelector('.select-dish');
fireEvent.click(selectButton);
expect(props.selectSkuDish).toBeCalledWith(props.spuDish);
expect(props.toggleMultiPanelAction).toBeCalledWith(true);
});
编写测试小技巧
在写某些模块的单测或是 UI 测试时,大家可能会发现一些难以测试的点,比如 Localstorage, 或一些延时函数的触发。下面一起看一下如何处理这些情况。
LocalStorage
因为 Jest 的环境是基于 jsdom, 所以我们需要去模拟 localstorage 的行为。借鉴 Vue2.0 里数据侦测的方法。新建一个文件,加入如下代码
// config/jest/browserMocks.js
const localStorageMock = (function () {
let store = {};
return {
getItem(key) {
return store[key] || null;
},
setItem(key, value) {
store[key] = value.toString();
},
removeItem(key) {
delete store[key];
},
clear() {
store = {};
},
};
}());
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
之后需要在 Jest 的配置中将该文件配置为启动文件
setupFiles: [
'<rootDir>/config/jest/browserMocks.js',
]
延时函数
利用 Jest 提供的 jest.useFakeTimers(),jest.runAllTimers(),jest.useRealTimers() 等 API 来完成测试。可以看以下 Toast 组件的 UI 测试。
import React from 'react';
import './Toast.less';
class Toast extends React.Component {
static close() {
Toast.beforeClose && Toast.beforeClose();
if (Toast.timer) {
clearTimeout(Toast.timer);
Toast.timer = null;
}
if (Toast.toastWrap) {
document.body.removeChild(Toast.toastWrap);
Toast.toastWrap = null;
}
}
componentDidMount() {
const { duration, beforeClose } = this.props;
Toast.beforeClose = beforeClose;
if (duration > 0) {
Toast.timer = setTimeout(() => {
Toast.close();
}, duration);
}
}
render() {
if (Toast.toastWrap) Toast.close();
const { content, hasMask } = this.props;
return (
<div className="toast-wrap">
{ hasMask && <div className="toast-mask" /> }
<div className="toast-box">
{content}
</div>
</div>
);
}
}
export default Toast;
我们这里就检查的写一点测试,测试 Toast 弹窗内的内容是否一致,beforeClose 事件是否是在弹窗关闭时才触发。
import React from 'react';
import { render, cleanup } from '@testing-library/react';
import Toast from '../Toast';
afterEach(cleanup);
test('正常弹窗', () => {
jest.useFakeTimers();
const props = {
duration: 2000,
content: 'hello world',
beforeClose: jest.fn(),
};
const { container } = render(<Toast {...props} />);
const toastDiv = container.querySelector('.toast-box');
expect(toastDiv.innerHTML).toBe('hello world');
expect(props.beforeClose).not.toBeCalled();
jest.runAllTimers();
expect(props.beforeClose).toBeCalled();
jest.useRealTimers();
});
e2e 测试
对于 e2e 测试来说,我们不需要写太多的代码,毕竟我们都有专业的 QA 同学。我认为只需要简单的覆盖主流程,比如我们的点餐业务,从最开始的选择人数页进入菜单页,进行加菜,减菜,再进入下单页下单等。e2e 还需要对 Jest 做一点配置。新建一个 jest-e2e.config.js 文件,不与单测的配置冲突。
module.exports = {
preset: 'jest-puppeteer',
testRegex: 'e2e/.*\\.test\\.js$',
};
在 package.json 加一行命令
"scripts": {
"test:e2e": "jest -c jest-e2e.config.js --detectOpenHandles"
}
这边简单贴一点 e2e 的代码。
// e2e/regression.test.js
const puppeteer = require('puppeteer');
const TARGET_URL = 'XXX';
const width = 375;
const height = 667;
test('主流程', async () => {
const browser = await puppeteer.launch({ headless: false });
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.goto(TARGET_URL, {
waitUntil: 'networkidle2', // 等到空闲
});
await page.setViewport({ width, height });
await page.waitForSelector('.people-count');
await page.screenshot({ path: 'e2e/screenshots/main.png' });
await page.click('.people-count:nth-child(1)');
await page.click('.start-btn');
await page.waitFor(3000);
await page.screenshot({ path: 'e2e/screenshots/menu.png' });
// 添加普通菜
await page.click('.meal-list-style > ul > li:nth-child(8) .plus-trigger');
// 展开购物车
await page.click('#cart-chef');
await page.screenshot({ path: 'e2e/screenshots/cart.png' });
await page.click('#cart-chef');
// 进入下单页
await page.click('.cart-bar > .btn.highlight');
await page.waitFor(3000);
await page.screenshot({ path: 'e2e/screenshots/order-confirm.png' });
await browser.close();
}, 20000);
测试覆盖率
为项目添加一行命令,就可以查看项目的测试覆盖率。
"scripts": {
"cov": "node scripts/test.js --coverage"
}
编写测试其实是为了保证项目的质量和开发体验,所以原则上不会做到全量的覆盖。
因为目前我们的项目大多属于敏捷开发,UI 样式的改动或者功能性需求较多,时间上也无法允许我们做到更好的测试覆盖。
因此,我们书写测试的目标是抽象出来的功能函数(集中放在 modules 文件夹),对数据流操作的 action,公共的组件(components 里 comon 文件夹下)。
只有单元测试和 UI 测试会计算到测试覆盖率,而 e2e 不会被计算进去。e2e 不需要写太多,因为大部分关键逻辑已经被单元测试覆盖,e2e 只需要简单的进行主流程的模拟。
通过 Jest 里的 collectCoverageFrom 配置改变测试统计的范围,最终项目的测试覆盖率要求为 Statement 60%, Branches 60%, Functions 60%, Lines 60%. 相关配置如下:
{
collectCoverageFrom: [
'src/components/common/**/*.{js,jsx,ts,tsx}',
'src/modules/**/*.{js,jsx,ts,tsx}',
'src/lib/**/*.{js,jsx,ts,tsx}',
'src/store/**/*.{js,jsx,ts,tsx}',
'src/constants/**/*.{js,jsx,ts,tsx}',
'src/api/**/*.{js,jsx,ts,tsx}',
],
coverageThreshold: {
global: {
statements: 60,
branches: 60,
functions: 60,
lines: 60,
},
},
}
这边展示下我们项目里 store/actions 文件夹下的测试覆盖情况
可以在最上面看到整个文件夹的总体的测试覆盖情况,和下面每个文件的具体覆盖情况。点击文件进去还能查看具体代码的覆盖情况。
总结
为项目添加测试是有一定成本的,尤其是 UI 测试方面。任何一件事情我们都需要平衡成本和收益,就像上文提到的,成本低的单元测试尽可能的全量覆盖,而高成本的 UI 测试则只做公共组件的覆盖。
前端测试确实会给项目带来相当多的好处,它能为 长期迭代 的项目带来显著的质量提升。
首先是能在测试环境降低 bug 数量,通过运行单测能检测出一些逻辑错误。
其次覆盖到不少 QA 同学没有覆盖到的边界情况(笔者在后期补写测试的时候,顺手修了几个问题😁),因为我们的测试编写原则就是要充分考虑数据的边界条件。
能够方便重构。
在原有逻辑增加新功能时,通过运行之前的测试,能够大大提高迭代的质量和稳定性。
这篇文章主要总结了笔者在 React 项目中书写测试的经验与沉淀,而对于 Vue 的项目,暂时还没有深入研究。但是 Vue 有个特点,基本上重要的库比如 Vue-Router, Vuex 都是官方维护,同样的 Vue Test Utils 也是 Vue.js 官方的单元测试工具库。文档 写的相当详细,对 Vue 项目编写测试时可以参考。
❤️爱心三连击
1.看到这里了就点个赞支持下吧,你的点赞是我创作的动力。
2.关注公众号前端食堂
,你的前端食堂,记得按时吃饭!
3.入冬了,多穿衣服不要着凉~!
往期精选