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

收工

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

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

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

相關文章