Jest
是 Facebook 发布的一个开源的、基于 Jasmine
框架的 JavaScript
单元测试工具。提供了包括内置的测试环境 DOM API
支持、断言库、 Mock
库等,还包含了 Spapshot Testing
、 Instant Feedback
等特性。
Enzyme
Airbnb
开源的 React
测试类库 Enzyme
提供了一套简洁强大的 API
,并通过 jQuery
风格的方式进行 DOM
处理,开发体验十分友好。不仅在开源社区有超高人气,同时也获得了 React
官方的推荐。
redux-saga-test-plan
运行在 jest
环境下,模拟 generator
函数,使用 mock
数据进行测试,是对 redux-saga
比较友好的一种测试方案。
开工准备
添加依赖
yarn add jest enzyme enzyme-adapter-react-16 enzyme-to-json redux-saga-test-plan@beta --dev 复制代码
说明:
- 默认已经搭建好可用于
react
测试的环境 - 由于项目中使用
react
版本是在16以上,故需要安装enzyme
针对该版本的适配器enzyme-adapter-react-16
-
enzyme-to-json
用来序列化快照 - 请注意, 大坑 (尴尬的自问自答)。文档未提及对
redux-saga1.0.0-beta.0
的支持情况,所以如果按文档提示去安装则在测试时会有run
异常,我们在 issue 中发现解决方案。
配置
在 package.json
中新增脚本命令
"scripts": { ... "test": "jest" } 复制代码
然后再去对 jest
进行配置,以下是两种配置方案:
- 直接在
package.json
新增jest
属性进行配置
"jest": { "setupFiles": [ "./jestsetup.js" ], "moduleFileExtensions": [ "js", "jsx" ], "snapshotSerializers": [ "enzyme-to-json/serializer" ], "modulePaths": [ "<rootDir>/src" ], "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css|less|scss)$": "identity-obj-proxy" }, "testPathIgnorePatterns": [ '/node_modules/', "helpers/test.js" ], "collectCoverage": false } 复制代码
- 根目录下新建
xxx.js
文件,在脚本命令中添加--config xxx.js
,告知jest
去该文件读取配置信息。
module.exports = { ... // 同上 } 复制代码
说明:
-
setupFiles
:在每个测试文件运行前,Jest
会先运行这里的配置文件来初始化指定的测试环境 -
moduleFileExtensions
:支持的文件类型 -
snapshotSerializers
: 序列化快照 -
testPathIgnorePatterns
:正则匹配要忽略的测试文件 -
moduleNameMapper
:代表需要被Mock
的文件类型,否则在运行测试脚本的时候常会报错:.css
或.png
等不存在 (如上需要添加identity-obj-proxy
开发依赖) -
collectCoverage
:是否生成测试覆盖报告,也可以在脚本命令后添加--coverage
以上仅列举了部分常用配置,更多详见官方文档。
开工大吉
对 React
应用全家桶的测试主要可分为三大块。
组件测试
// Tab.js import React from 'react' import PropTypes from 'prop-types' import TabCell from './TabCell' import styles from './index.css' const Tab = ({ type, activeTab, likes_count: liked, goings_count: going, past_count: past, handleTabClick }) => { return (<div className={styles.tab}> {type === 'user' ? <div> ![](https://user-gold-cdn.xitu.io/2018/11/1/166cf21e3bffb7d7?w=1484&h=1442&f=png&s=287585) <TabCell type='liked' text={`${liked} Likes`} isActived={activeTab === 'liked'} handleTabClick={handleTabClick} /> <TabCell type='going' text={`${going} Going`} isActived={activeTab === 'going'} handleTabClick={handleTabClick} /> <TabCell type='past' text={`${past} Past`} isActived={activeTab === 'past'} handleTabClick={handleTabClick} /> </div> : <div> <TabCell type='details' text='Details' isActived={activeTab === 'details'} handleTabClick={handleTabClick} /> <TabCell type='participant' text='Participant' isActived={activeTab === 'participant'} handleTabClick={handleTabClick} /> <TabCell type='comment' text='Comment' isActived={activeTab === 'comment'} handleTabClick={handleTabClick} /> </div> } </div>) } Tab.propTypes = { type: PropTypes.string, activeTab: PropTypes.string, likes_count: PropTypes.number, goings_count: PropTypes.number, past_count: PropTypes.number, handleTabClick: PropTypes.func } export default Tab 复制代码
// Tab.test.js import React from 'react' import { shallow, mount } from 'enzyme' import renderer from 'react-test-renderer' import Tab from 'components/Common/Tab' import TabCell from 'components/Common/Tab/TabCell' const setup = () => { // 模拟props const props = { type: 'activity', activeTab: 'participant', handleTabClick: jest.fn() } const sWrapper = shallow(<Tab {...props} />) const mWrapper = mount(<Tab {...props} />) return { props, sWrapper, mWrapper } } describe('Tab components', () => { const { sWrapper, mWrapper, props } = setup() it("get child component TabCell's length", () => { expect(sWrapper.find(TabCell).length).toBe(3) expect(mWrapper.find(TabCell).length).toBe(3) }) test('get specific class', () => { expect(sWrapper.find('.active').exists()) expect(mWrapper.find('.active').exists()) }) it("get child component's specific class", () => { expect(mWrapper.find('.commentItem .text').length).toBe(1) expect(sWrapper.find('.commentItem .text').length).toBe(1) }) test('shallowWrapper function to be called', () => { sWrapper.find('.active .text').simulate('click') expect(props.handleTabClick).toBeCalled() }) test('mountWrapper function to be called', () => { mWrapper.find('.active .text').simulate('click') expect(props.handleTabClick).toBeCalled() }) it('set props', () => { expect(mWrapper.find('.participantItem.active')).toHaveLength(1) mWrapper.setProps({activeTab: 'details'}) expect(mWrapper.find('.detailsItem.active')).toHaveLength(1) }) // Snapshot it('Snapshot', () => { const tree = renderer.create(<Tab {...props} />).toJSON() expect(tree).toMatchSnapshot() }) }) 复制代码
说明:
-
test
方法是it
的一个别名,可以根据个人习惯选用; - 执行脚本可以发现
shallow
与mount
的些些区别:-
shallow
只渲染当前组件,只能对当前组件做断言,所以expect(sWrapper.find('.active').exists())
正常而expect(mWrapper.find('.commentItem .text').length).toBe(1)
异常; -
mount
会渲染当前组件以及所有子组件,故而可以扩展到对其自组件做断言; -
enzyme
还提供另外一种渲染方式render
,与shallow
及mount
渲染出react
树不同,它的渲染结果是html
的dom
树,也因此它的耗时也较长;
-
-
jest
因Snapshot Testing
特性而备受关注,它将逐行比对你上一次建的快照,这可以很好的 防止无意间修改组件 的操作。
当然,你还可以在 enzyme
的API Reference找到更多灵活的测试方案。
saga测试
// login.js部分代码 export function * login ({ payload: { params } }) { yield put(startSubmit('login')) let loginRes try { loginRes = yield call(fetch, { ssl: false, method: 'POST', version: 'v1', resource: 'auth/token', payload: JSON.stringify({ ...params }) }) const { token, user: currentUser } = loginRes yield call(setToken, token) yield put(stopSubmit('login')) yield put(reset('login')) yield put(loginSucceeded({ token, user: currentUser })) const previousUserId = yield call(getUser) if (previousUserId && previousUserId !== currentUser.id) { yield put(reduxReset()) } yield call(setUser, currentUser.id) if (history.location.pathname === '/login') { history.push('/home') } return currentUser } catch (e) { if (e.message === 'error') { yield put(stopSubmit('login', { username: [{ code: 'invalid' }] })) } else { if (e instanceof NotFound) { console.log('notFound') yield put(stopSubmit('login', { username: [{ code: 'invalid' }] })) } else if (e instanceof Forbidden) { yield put(stopSubmit('login', { password: [{ code: 'authorize' }] })) } else if (e instanceof InternalServerError) { yield put(stopSubmit('login', { password: [{ code: 'server' }] })) } else { if (e.handler) { yield call(e.handler) } console.log(e) yield put(stopSubmit('login')) } } } } 复制代码
// login.test.js import {expectSaga} from 'redux-saga-test-plan' import * as matchers from 'redux-saga-test-plan/matchers' import { throwError } from 'redux-saga-test-plan/providers' import {loginSucceeded, login} from '../login' import fetch from 'helpers/fetch' import { startSubmit, stopSubmit, reset } from 'redux-form' import { setToken, getUser, setUser } from 'services/authorize' const params = { username: 'yy', password: '123456' } it('login maybe works', () => { const fakeResult = { 'token': 'd19911bda14cb0f36b82c9c6f6835c8c', 'user': { 'id': 53, 'username': 'yy', 'email': 'yan.yang@shopee.com', 'avatar': 'https://coding.net/static/fruit_avatar/Fruit-19.png' } } return expectSaga(login, { payload: { params } }) .put(startSubmit('login')) .provide([ [matchers.call.fn(fetch), fakeResult], [matchers.call.fn(setToken), fakeResult.token], [matchers.call.fn(getUser), 53], [matchers.call.fn(setUser), 53] ]) .put(stopSubmit('login')) .put(reset('login')) .put(loginSucceeded({ token: fakeResult.token, user: fakeResult.user })) .returns({...fakeResult.user}) .run() }) it('catch an error', () => { const error = new Error('error') return expectSaga(login, { payload: { params } }) .put(startSubmit('login')) .provide([ [matchers.call.fn(fetch), throwError(error)] ]) .put(stopSubmit('login', { username: [{ code: 'invalid' }] })) .run() }) 复制代码
说明:
- 对照
saga
代码,梳理脚本逻辑(可以只编写对核心逻辑的断言); -
expectSaga
简化了测试,为我们提供了如redux-saga
风格般的API
。其中provide
极大的解放了我们mock
异步数据的烦恼;- 当然,在
provide
中除了使用matchers
,也可以直接使用redux-saga/effects
中的方法,不过注意如果直接使用effects
中的call
等方法将会执行该方法实体,而使用matchers
则不会。详见Static Providers;
- 当然,在
-
throwError
将模拟抛错,进入到catch
中;
selector测试
// activity.js import { createSelector } from 'reselect' export const inSearchSelector = state => state.activityReducer.inSearch export const channelsSelector = state => state.activityReducer.channels export const channelsMapSelector = createSelector( [channelsSelector], (channels) => { const channelMap = {} channels.forEach(channel => { channelMap[channel.id] = channel }) return channelMap } ) 复制代码
// activity.test.js import { inSearchSelector, channelsSelector, channelsMapSelector } from '../activity' describe('activity selectors', () => { let channels describe('test simple selectors', () => { let state beforeEach(() => { channels = [{ id: 1, name: '1' }, { id: 2, name: '2' }] state = { activityReducer: { inSearch: false, channels } } }) describe('test inSearchSelector', () => { it('it should return search state from the state', () => { expect(inSearchSelector(state)).toEqual(state.activityReducer.inSearch) }) }) describe('test channelsSelector', () => { it('it should return channels from the state', () => { expect(channelsSelector(state)).toEqual(state.activityReducer.channels) }) }) }) describe('test complex selectors', () => { let state const res = { 1: { id: 1, name: '1' }, 2: { id: 2, name: '2' } } const reducer = channels => { return { activityReducer: {channels} } } beforeEach(() => { state = reducer(channels) }) describe('test channelsMapSelector', () => { it('it should return like res', () => { expect(channelsMapSelector(state)).toEqual(res) expect(channelsMapSelector.resultFunc(channels)) }) it('recoputations count correctly', () => { channelsMapSelector(state) expect(channelsMapSelector.recomputations()).toBe(1) state = reducer([{ id: 3, name: '3' }]) channelsMapSelector(state) expect(channelsMapSelector.recomputations()).toBe(2) }) }) }) }) 复制代码
说明:
-
channelsMapSelector
可以称之为记忆函数,只有当其依赖值发生改变时才会触发更新,当然也可能会发生 意外 ,而inSearchSelector
与channelsSelector
仅仅是两个普通的非记忆selector
函数,并没有变换他们select
的数据; - 如果我们的
selector
中聚合了比较多其他的selector
,resultFunc
可以帮助我们mock数据,不需要再从state
中解藕出对应数据; -
recomputations
帮助我们校验记忆函数是否真的能记忆;
收工
以上,把自己的理解都简单的描述了一遍,当然肯定会有缺漏或者偏颇,望指正。
没有完整的写过前端项目单元测试的经历,刚好由于项目需要便认真去学习了一遍。
其中艰辛,希望众位不要再经历了。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- vue全家桶
- 升级vue全家桶过程记录
- SpringBootBucket 1.0.0 发布,SprintBoot全家桶
- 免费获取 JetBrains 全家桶正版 License 教程
- 使用React全家桶搭建一个后台管理系统
- Windows 10四月更新恢复预装“全家桶”遭吐槽
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。