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

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

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 的些些区别:
    使用Jest对React全家桶(react-saga, redux-actions, reselect)的单元测试
    • 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 特性而备受关注,它将逐行比对你上一次建的快照,这可以很好的 防止无意间修改组件 的操作。
    使用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 可以称之为记忆函数,只有当其依赖值发生改变时才会触发更新,当然也可能会发生 意外 ,而 inSearchSelector 与 channelsSelector 仅仅是两个普通的非记忆 selector 函数,并没有变换他们 select 的数据;
  • 如果我们的 selector 中聚合了比较多其他的 selector , resultFunc 可以帮助我们mock数据,不需要再从 state 中解藕出对应数据;
  • recomputations 帮助我们校验记忆函数是否真的能记忆;

收工

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

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

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


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

查看所有标签

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

算法谜题

算法谜题

Anany Levitin、Maria Levitin / 赵勇、徐章宁、高博 / 人民邮电出版社 / 2014-1-1

算法是计算机科学领域最重要的基石之一。算法谜题,就是能够直接或间接地采用算法来加以解决的谜题。求解算法谜题是培养和锻炼算法思维能力一种最有效和最有乐趣的途径。 本书是一本经典算法谜题的合集。本书包括了一些古已有之的谜题,数学和计算机科学有一部分知识就发源于此。本书中还有一些较新的谜题,其中有一部分谜题被用作知名IT企业的面试题。全书可分为4个部分,分别是概览、谜题、提示和答案。概览介绍了算法......一起来看看 《算法谜题》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

在线进制转换器
在线进制转换器

各进制数互转换器

MD5 加密
MD5 加密

MD5 加密工具