• 中文
  • ENGLISH
React應用下的單元測試
2017/03/09

本文作者:昔夜

前言

目前,越來越多的Web應用使用 react 來進行界面UI開發,而與之配套的官方測試工具 react-addons-test-utils 用起來則比較繁瑣,寫出來的測試代碼也不易維護。

相比之下,Airbnb開源的react測試類庫 Enzyme 提供了一套簡潔強大的API,并通過jquery風格的方式進行dom處理,開發體驗十分友好。不僅在開源社區有超高人氣,同時也獲得了react官方的推薦。

要編寫測試用例的話,光有測試類庫還不夠,還需要測試運行環境(test runner)、斷言庫(assertion library)、mock庫(mock library)等等工具輔以支持。如果不想使用很多第三方包去完成這些的話,那么facebook出品的測試框架 jest 會是一個比較好的選擇。

jest除了支持上述功能外,還包含Snapshot Testing、Instant Feedback等超棒特性。

本文將以一個簡版todo應用為例,來講解如何使用 jest+enzyme 來測試react組件,項目代碼 可在github上查看。

準備工作

假如你需要對已有應用做測試的話,那么第一步將是:環境配置安裝。這里假設應用是以webpack來打包加載資源,那么集成工作將會變得非常簡單。

除了下載npm依賴包(enzyme、jest)之外,只需對package.json新增屬性:

{
  "jest": {
    "moduleFileExtensions": [
      "js",
      "jsx"
    ],
    "moduleNameMapper": {
      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
      "\\.(css|less|scss)$": "<rootDir>/__mocks__/styleMock.js"
    },
    "transform": {
      "^.+\\.js$": "babel-jest"
    }
  }
}

以上代碼片段中:

  • moduleFileExtensions 代表支持加載的文件名,與webpack中resolve.extensions類似。
  • moduleNameMapper 代表需要mock處理掉的資源,比如樣式文件等,這些東西不會影響到代碼邏輯,如果不mock掉jest會無法加載資源而報錯。
  • 如果應用還用到babel編譯es6/7語法的話,那么我們還需加上 transform 處理(需要安裝babel-jest)。

新增字段后,我們還需要把script中的 test 腳本改為 'jest' 就大功告成了。

應用講解

功能講解

我們待實現的todo應用只包含兩個功能, 新增todo以及刪除todo。最終效果如下(請忽略樣式~):

gif

UI測試

我們先來看看應用的主體結構是怎樣的:

render() {
  const { todos } = this.props;

  return (
    <div>
      <TodoCreator addTodo={this.addTodo} />
      { todos.map((todo, i) => (
        <TodoItem key={i} todo={todo} deleteTodo={this.deleteTodo} />
      ))}
    </div>
  )
}

不難發現,我們的應用其實就是兩個組件構成的:TodoCreatorTodoItem
其中 TodoCreator 是個簡單的 input ,它的功能是監聽用戶按下Enter鍵,然后創建一個todo。

組件實現代碼非常簡單:

onKeydownHandle(ev) {
  const { addTodo } = this.props;
  const value = ev.target.value;

  if (ev.key === 'Enter' && value) {
    addTodo(ev.target.value)
    ev.target.value = '';
  }
}
render() {
  return (
    <div>
      <input type="text" onKeyDown={this.onKeydownHandle} />
    </div>
  )
}

在了解該組件的功能后,我們首先要明確需要測試的點有哪些:

  1. 當用戶按下 Enter 鍵的時候要能調用props中的 addTodo 方法(如果有輸入的話)
  2. 如果用戶沒有輸入值的話不允許創建
  3. 創建完成后清除輸入框

帶著以上目的,我們開始寫測試代碼。首先我們在組件同級目錄創建一個以.spec.js作為suffix的文件(*.spec.js*.test.js作為suffix的文件可以被jest識別,當然你也可以指定測試目錄 __tests__ )。

Step1: 第一步引入相關包

import React from 'react';
import TodoCreator from './index.js';
import { shallow } from 'enzyme';

你可能已經注意到了我們引入了 shallow 方法,這個方法其實底層還是來源于react官方測試包 react-addons-test-utils,它可以實現 淺渲染

淺渲染 作用就是:它僅僅會渲染至虛擬dom,不會返回真實的dom節點,這個對測試性能有極大的提升。

除了shallow之外,enzyme還有另外的兩個渲染方法:mountrender

mount 可以實現 Full Rendering。比如說當我們需要對DOM API交互或者你需要測試組件的整個生命周期(如: componentDidMount)的時候,可以使用這個方法。需要注意的是,由于需要渲染成真實的dom節點,那么就需要測試環境對DOM API有支持。jest在內部使用了 jsdom 去模擬了DOM環境,所以我們就可以不用寫一個setup.js文件去mock那些全局變量了。

render 方法又是干什么用的呢?我們可以利用它來渲染出最終的html,然后利用這個html結構來進行分析處理。

多數情況下,shallow 方法就能滿足我們的需求了。

Step2: 開始寫測試case

我們的測試case是就是上文中講到的三個測試點。在正式分解功能之前,我們要寫一個 setup 方法用來渲染組件,因為每一個測試case都會用到它:

const setup = () => {
  // 組件的props
  const props = {
    addTodo: jest.fn() // mock
  };

  const wrapper = shallow(<TodoCreator {...props} />);

  return {
    props,
    wrapper
  }
}

setup 方法中,我們模擬了 TodoCreator 組件所需要的 props ,并對 TodoCreator 進行淺渲染,函數最后返回了props,和渲染后的虛擬dom結構。

接下來就是正式的case測試了。

CASE1: 當用戶按下 **Enter 鍵的時候要能調用props中的 addTodo 方法**

我們先來看下測試代碼:

it('press enter key should call addTodo if text length greater than 0', () => {
  const { wrapper, props } = setup();
  // mock event object
  const mockEventObj = {
    key: 'Enter',
    target: {
      value: 'TEST'
    }
  };

  wrapper.find('input').simulate('keydown', mockEventObj);
  expect(props.addTodo).toBeCalled();
})

上述代碼中,我們做了以下事情:

  • 通過 enzyme 提供的jquery式的API find ,我們找到了組件中的 input 節點。
  • 通過 simulate 方法我們模擬了 onkeydown 事件,這里的keydown會映射到組件上的 onKeyDown
  • 通過 jest 自帶的斷言API expecttoBeCalled ,我們去判別通過props傳遞過來的 addTodo 函數是否被調用。

接下來我們執行 npm test 會看到命令行會輸出測試成功消息 PASS (本文只展示了部分代碼)。

pass

但我們總覺得好像少了點什么,至少我是這樣認為的。因為我有點懷疑我們測試case的可靠性。 所以為了驗證它,我們把用戶的輸入改為空,也即 target.value = '' ,然后再執行測試命令:

error

這時候控制臺開始報錯了。錯誤顯示,按下 Enter 鍵后, addTodo 方法并未被調用。而我們的代碼中確實要求input value不為空才能新增todoItem。盡管報錯,但這卻是值得的,因為這就證明我們之前的測試代碼是對的(這種模式在寫測試的時候很常見)。

CASE2: 如果用戶沒有輸入值的話不允許創建

這一步其實我們在上述錯誤示例中已經提到,我們把 mockEventObj.target.value 的值置空,同時期望 addTodo 方法不被調用,所以它的斷言語句應該是 expect(props.addTodo).not.toBeCalled()

CASE3: 創建完成后清除輸入框

同樣是執行上述操作后(這個時候應該保證輸入框有值),此時斷言語句應該變為 expect(wrapper.find('input').text()).toBe('') ,用著這些熟悉的 find().text() ,有點回到了以前jquery開發的感覺:)。

這樣的話,我們就算把 TodoCreator 的測試代碼完成,來看看整體執行結果:

完美!看到測試case全部通過的感覺很棒:)。
至此,我們的UI測試已經結束。雖然我們的 TodoItem 組件還沒測試,但它的測試方式跟上述的 TodoCreator 組件毫無二致。我們只需要明白哪些點需要測試,然后可以依葫蘆畫瓢的完成。比如,對于 TodoItem 組件,checkbox onChange的時候應該刪除todo。

應用state測試

react組件只是應用的一部分(UI),所以我們還需對整個應用的狀態(state)進行測試。在這里我們使用 redux 來管理應用的state,使用它之后狀態管理代碼非常便于寫測試。如何你還對redux不了解話,可以參考官博的另一篇文章 Redux深入原理。來看看我們todo應用的reducer是怎么寫的:

import { ADD_TODO, DELETE_TODO } from '../constants'

const initialState = [
  {
    text: 'Todo',
    completed: false,
    id: 0
  }
]

export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          completed: false,
          text: action.text,
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
        },
        ...state
      ]

    case DELETE_TODO:
      return state.filter(todo =>
        todo.id !== action.id
      )

    default:
      return state
  }
}

我們看到上述 switch 中有三個分支:

  • 默認返回的state是 initialState 對象
  • 如果接受到type為 ADD_TODO 的action的話,state會 unshift 一個新todo對象(返回新的state)
  • 如果接受到type為 DELETE_TODO 的action的話,state會刪除對應id的todo對象(返回新的state)

針對上述的點,我們的測試case也可以分解為三塊(具體代碼請參考github項目)。

CASE1: 初始化State

這里我們測試的是應用初始化state,我們希望返回的state是上文的 initialState

// 引入todo reducer
import todos from './todos'
import * as types from '../constants'

describe('todos reducer', () => {
  it('should handle initial state', () => {
    // initial state test
    expect(
      todos(undefined, {})
    ).toEqual([
      {
        text: 'Todo',
        completed: false,
        id: 0
      }
    ])
  })
})

請注意,toEqual 方法會遞歸的比較兩個對象的key value值,而不是比較對象的引用,所以我們可以放心的去做對比。

再次,我們執行npm test會“無感”的看到case通過。為了再次驗證結論,我們把初始化todo的 text 改為’Todo1’,我們再試一遍:

習慣的看到我們的測試case又掛了:)。而且控制臺還把很友好的把期望結果和實際的偏差都清楚的指出來了,這將極大的方便我們發現問題。

CASE2: ADD_TODO

與測試初始化狀態一樣,不過這時我們調用todo reducer的時候我們需要傳遞一個 typeADD_TODO 的action對象,返回結果中新增一個todo item。

測試case代碼如下:

it('should handle ADD_TODO', () => {
  // 本次初始state為空數組[]
  expect(
    todos([], {
      type: types.ADD_TODO,
      text: 'Hello World'
    })
  ).toEqual([
    {
      text: 'Hello World',
      completed: false,
      id: 0
    }
  ])
})

刪除todo也是如此,這里不做展開描述,依葫蘆畫瓢即可。
在這里,我們todo應用的UI+state測試就已經完成了。

總結

我們利用了 jest 完美的測試環境和 enzyme 極簡API完成了上述工作。

需要特別強調的是,jest還有一個超棒的特性Snapshot Testing,它通過兩次測試的快照(JSON)來簡化UI測試并且可以diff出兩次快照的變化。目前jest的功能越來越強大,我們甚至可以用它單獨完成react應用的測試工作。

另外,enzyme只實現了基本的選擇器功能,像CSS3選擇器是不暫不支持的。

總而言之,jestenzyme 將會是測試react應用的不二選擇。

參考資料

訂閱我們
体彩20选5开奖结果查询