使用Jest对React全家桶(react-saga, redux-actions, reselect)的单元测试

栏目: IOS · Android · 发布时间: 6年前

Jest 是 Facebook 发布的一个开源的、基于 Jasmine 框架的 JavaScript 单元测试工具。提供了包括内置的测试环境 DOM API 支持、断言库、 Mock 库等,还包含了 Spapshot TestingInstant 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 的一个别名,可以根据个人习惯选用;
  • 执行脚本可以发现 shallowmount 的些些区别:
    使用Jest对React全家桶(react-saga, redux-actions, reselect)的单元测试
    • shallow 只渲染当前组件,只能对当前组件做断言,所以 expect(sWrapper.find('.active').exists()) 正常而 expect(mWrapper.find('.commentItem .text').length).toBe(1) 异常;
    • mount 会渲染当前组件以及所有子组件,故而可以扩展到对其自组件做断言;
    • enzyme 还提供另外一种渲染方式 render ,与 shallowmount 渲染出 react 树不同,它的渲染结果是 htmldom 树,也因此它的耗时也较长;
  • jestSnapshot Testing 特性而备受关注,它将逐行比对你上一次建的快照,这可以很好的 防止无意间修改组件 的操作。
    使用Jest对React全家桶(react-saga, redux-actions, reselect)的单元测试

当然,你还可以在 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 可以称之为记忆函数,只有当其依赖值发生改变时才会触发更新,当然也可能会发生 意外 ,而 inSearchSelectorchannelsSelector 仅仅是两个普通的非记忆 selector 函数,并没有变换他们 select 的数据;
  • 如果我们的 selector 中聚合了比较多其他的 selectorresultFunc 可以帮助我们mock数据,不需要再从 state 中解藕出对应数据;
  • recomputations 帮助我们校验记忆函数是否真的能记忆;

收工

以上,把自己的理解都简单的描述了一遍,当然肯定会有缺漏或者偏颇,望指正。

没有完整的写过前端项目单元测试的经历,刚好由于项目需要便认真去学习了一遍。

其中艰辛,希望众位不要再经历了。


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

创新者的处方

创新者的处方

[美]克莱顿·克里斯坦森、杰罗姆·格罗斯曼、黄捷升 / 朱恒鹏、张琦 / 中国人民大学出版社 / 2015-9 / 89.90元

[内容简介] ● 创新大师克里斯坦森采用了哈佛商学院在20年研究中总结而出的、在各行业实践中获得成功的管理创新经验,把颠覆式创新理念引入美国医疗行业研究。医疗机构需要量体裁衣,选择合适的商业模式展开创新之举。 ● 作者同时探讨了医疗保险公司、制药企业、医学院和政府机构在医疗改革中起到的作用,从社会性角度深入剖析了医疗保健行业未来之路。 ● 医疗界人士、政策制定者、对医疗界现......一起来看看 《创新者的处方》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换