填坑之路:Jest对React全家桶的单元测试

本文主要对react全家桶应用的单元测试提供一点思路。

开工须知

Jest

Jest是 Facebook 发布的一个开源的、基于 Jasmine 框架的 JavaScript单元测试工具。提供了包括内置的测试环境DOM API支持、断言库、Mock库等,还包含了Spapshot TestingInstant Feedback等特性。

Enzyme

Airbnb开源的React测试类库Enzyme提供了一套简洁强大的API,并通过jQuery风格的方式进行DOM处理,开发体验十分友好。不仅在开源社区有超高人气,同时也获得了React官方的推荐。

redux-saga-test-plan

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的些些区别:
    执行脚本
    • shallow只渲染当前组件,只能对当前组件做断言,所以expect(sWrapper.find('.active').exists())正常而expect(mWrapper.find('.commentItem .text').length).toBe(1)异常;
    • mount会渲染当前组件以及所有子组件,故而可以扩展到对其自组件做断言;
    • enzyme还提供另外一种渲染方式render,与shallowmount渲染出react树不同,它的渲染结果是htmldom树,也因此它的耗时也较长;
  • jestSnapshot Testing特性而备受关注,它将逐行比对你上一次建的快照,这可以很好的防止无意间修改组件的操作。
    Snapshot Testing

当然,你还可以在enzymeAPI 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帮助我们校验记忆函数是否真的能记忆;

收工

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

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容