使用Jest對React全家桶(react-saga, redux-actions, reselect)的單元測試

哦啦吧啦丶發表於2019-03-03

本文主要對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>
        <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)
  })

  it("get child component's specific class", () => {
    // expect(sWrapper.find('.commentItem .text').length).toBe(1)
    // expect(sWrapper.find('.commentItem .text').exists()).toBeTruthy()
    // expect(sWrapper.find('.commentItem .text')).toHaveLength(1)
    expect(mWrapper.find('.commentItem .text').length).toBe(1)
    expect(sWrapper.find('.commentItem .text').length).toBe(1)
  })

  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(sWrapper.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幫助我們校驗記憶函式是否真的能記憶;

收工

以上,把自己的理解都簡單的描述了一遍,當然肯定會有缺漏或者偏頗,望指正。

沒有完整的寫過前端專案單元測試的經歷,剛好由於專案需要便認真去學習了一遍。

其中艱辛,希望眾位不要再經歷了。

相關文章