美团点评前端开发(来自美团点评前端工程师的技术分享)
美团点评前端开发(来自美团点评前端工程师的技术分享)Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。Augular 的默认测试框架就是 Karma Jasmine,而 React 的默认测试框架是 Jest.前端测试的框架可谓是百花齐放。因为我们的项目使用的是 React 技术栈,这里主要介绍 React 项目的技术选型和使用。1.单元测试
作者介绍:柯培霖,美团点评工程师。
前言2019 年前端测试依然是一个炙手可热的话题。笔者在今年 5 月份参加 Vueconf 的时候,Vue 单元测试的主题演讲者曾向现场的参与者发出提问,有多少团队引入了单元测试,意外的是只有寥寥数人举起了手。尽管,那个时候笔者的团队也还没有引入前端测试,但是考虑到测试的必要性,且团队正在着手一个新项目,所以回去之后在这个新项目全量地接入了前端测试。
现如今大部分互联网团队都是走 敏捷开发 的节奏。实际上,自动化测试才是实现“敏捷”的基本保障。业务端的快速上线和快速验证对技术侧的响应力提出了更高的要求:更快上线,持续上线。再考虑到人员流动和应用逐步变大的事实,日后迭代的成本只会变得越来越高。当然这个项目迭代的成本也跟项目的复杂度有关,比如笔者所在的点餐业务,项目有足够的复杂性,有些细微的改动点其实会牵扯到很多内容,而对刚加入团队的新人就会显得不太友好。因此,项目拥有前端测试是必不可少的,它能够有效保障业务迭代的质量和稳定性。
一、什么是前端测试?我们经常说的单元测试其实只是前端测试的一种。前端测试分为单元测试,UI 测试,集成测试和端到端测试。
前端测试的框架可谓是百花齐放。
- 单元测试有 Mocha Ava Karma Jest Jasmine 等。
- UI 测试有 ReactTestUtils Test Render Enzyme React-Testing-Library Vue-Test-Utils 等。
- e2e 测试有 Nightwatch Cypress Phantomjs Puppeteer 等。
因为我们的项目使用的是 React 技术栈,这里主要介绍 React 项目的技术选型和使用。
1.单元测试
每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。Augular 的默认测试框架就是 Karma Jasmine,而 React 的默认测试框架是 Jest.
Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。
2.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.
3.e2e 测试
Puppeteer 是 Google Chrome 团队推出的库,尽管它相对其他 e2e 框架更新,但它同样也有一个庞大的社区。它拥有更简洁易用的 API,更快的运行速度,已逐渐成为业内自动化测试的标杆,俘获大量 Selenium 用户的心。可以看下近年来 e2e 测试框架的 npm trends.
4.结论
经过分析,最后我们项目的技术选型为 Jest React Testing Library Puppeteer
而对于 Vue 的项目,为了保持技术栈的统一,我们选用了 Jest Vue-Test-Utils Puppeteer
三、编写原则未来的项目都是基于 Talos 生成,其实也就是使用了 Create-React-App 生成 React 项目,使用了 Vue-CLI@3 生成了 Vue 项目。本身默认都有 Jest 的配置,不需做大的改动。
其实,Jest 的语法蛮简单的,只需要熟悉几个 API 就可以快速上手测试了。
1.工具类函数的单元测试
// 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]); }); });
只需要给定函数的输入,之后调用函数,验证它的输出与期望的是否一样。
2.Redux 的单元测试
2.1 测试 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); }); });
2.2 测试普通 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); });
2.3 测试带中间件的复合 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); });
2.4 测试异步 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); }); });
3.UI 测试
对于单元测试来说,是成本低且收益高,而对 UI 测试来说,可能更像是成本高但收益低。 但是对于一些公共组件的测试还是很有必要的,就像笔者前文说到过的一样,当项目的代码足够复杂时,一个通用组件的改动迎接你的可能就是一个线上 Case。
3.1 函数组件
下面简单的看一个加减菜组件的测试(精简了一部分逻辑)。
import React from 'react'; import Immutable from 'immutable'; import './NumberCount.less'; const NumberCount = (props) => { const { dish count addToCart minusDish minusPoint } = props; return ( { count > 0 ? ( <>
minusDishToCart(dish)} /> {count} ) : null } addToCart(dish)} />
); }; 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(); 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(); 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); });
3.2 使用 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 ( this.togglePanel(e spuDish)}> 选择 { count > 0 && {count} } ); } } 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(); const selectButton = container.querySelector('.select-dish'); fireEvent.click(selectButton); expect(props.selectSkuDish).toBeCalledWith(props.spuDish); expect(props.toggleMultiPanelAction).toBeCalledWith(true); });
4.编写测试小技巧
在写某些模块的单测或是 UI 测试时,大家可能会发现一些难以测试的点,比如 Localstorage 或一些延时函数的触发。下面一起看一下如何处理这些情况。
4.1 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: [ '/config/jest/browserMocks.js' ]
4.2 延时函数
利用 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 ( { hasMask &&
}
{content}
); } } 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(); 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(); });
5.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 测试则只做公共组件的覆盖。
前端测试确实会给项目带来相当多的好处,它能为 长期迭代 的项目带来显著的质量提升。
这篇文章主要总结了笔者在 React 项目中书写测试的经验与沉淀,而对于 Vue 的项目,暂时还没有深入研究。但是 Vue 有个特点,基本上重要的库比如 Vue-Router Vuex 都是官方维护,同样的 Vue Test Utils 也是 Vue.js 官方的单元测试工具库。文档 写的相当详细,对 Vue 项目编写测试时可以参考。
想了解更多前端知识欢迎评论区留言或私信我!
欢迎关注公众号:fkdcxy 疯狂的程序员丶 获取更多前端教程!