接著上篇的內容, 這篇文章會詳細的介紹在 Glow 我們如何寫單元測試, 以及在 React Native 中各個模組單元測試的詳細實現方式。

單元測試工具 - Jest & Enzyme

Jest - Facebook

Jest 是 Facebook 開源的 Javascript 測試框架,提供了許多好用的 API,先介紹下主要的優點:

  • 自帶 snapshot 測試,讓UI測試簡單有效
  • 幾乎 0 配置,自帶各種功能。 相比其他單元測試:karma (test runner) + mocha (test framework) + chai (assertion) +
    sinon
    (test spy) + ...
  • 並行執行測試 case
  • 提供 watch mode,很方便的可以實行 TDD 的開發模式或者更新程式碼的同時自動執行單元測試。
  • 提供簡單實用的 spy, mock 方法。 用 jest.fn() 就可以實現 spy function。
  • 自帶清晰易懂的 code coverage 生成功能。 集成了 istanbul
  • 不僅適用於 React Native 測試, 也可以適用於 React.js, Vuejs 等其他 js lib 或者 framework。

Enzyme 是 AirBnb 開源的用於 React測試的 js utility。(在 vuejs 測試中可以用 vue-test-utils)

  • Enzyme 提供了可以直接操作 React component 中的 props 和s tate 的方法,使得建造測試 context 變的簡單。不需要呼叫c omponent 原本的方法來更新 state, prop 等。

  • Enzyme 提供了三種 render React component的方法, static, shallow 和 mount。

最常用的render方法是 Shallow Render。

這種方法的特點是隻 render 當前元件中一層深的元素, 不會去渲染當前元件中用到的子元件。 這就保證了測當前元件的時候, 不會受到子元件行為的影響。符合分層測試的需求;並且也比較快速。需要渲染更深層次的子元件時也可以用 enzyme 提供的dive方法來實現。

單元測試實踐

元件UI測試 (Snapshot)

傳統的 Snapshot 測試一般是渲染一個UI元件 -> 擷取螢幕快照 -> 和之前的螢幕快照對比。如果對比失敗了(兩個截圖不同),要麼是有 bug, 要麼需要升級螢幕快照(UI 意料之中的更新)。

Jest Snapshot Test的特點:

  • Jest 使用一個 test renderer 來生成出 React tree 的序列化結構樹。toMatchSnapshot 方法會幫你對比這次要生成的結構和上次的區別。

  • 當元素的 prop 或者 state 不同時,會生成不同情況的snapshot來覆蓋這些情況下的UI結構。

  • Jest 的 snapshot 測試不僅可以對比React tree結構的區別, 也可以對比其他可序列化的值的區別。 比如對比Redux某個狀態的state是否和之前相同。

Snapshot可以很大幅度的減少元件UI測試花費的精力, 工作流程如下:

  • 傳統的assertion: expect(result).toEqual(expectedResult) 現在可以寫成 expect(result).toMatchSnapshot(), 同時生成結果的snapshot被儲存在*.snap檔案中。

  • 當生成的 snapshot 結果發生變化時, jest 會清楚的告訴測試人員哪塊 component tree 發生了變化(看起來就像 git diff),這樣測試人員就可以知道這裡是 bug 還是正確的UI更新。

  • 當 snapshot 結果需要升級更新時, 只需要執行 jest -u 指令即可更新之前生成的 snapshot 結果。

為什麼 Snapshot 在 React 測試中是可靠的呢?

  • 在 React(以及 React Native ) 的開發理念中, 開發者把重點放在描述要顯示的元件在不同輸入時的靜態狀態,然後交給React去處理UI的更新。 可以想象成每次UI有變化時會重新生成這個元件並重新整理, React會幫開發者處理具體怎麼高效的變化。

  • 因此我們在測試元件的時候, 也只要把重點放在測試我們如何描述這個元件。當一個元件的 prop 和 state 確定時, 我們用 snapshot 保證在這個狀態下元件的序列化結構是符合預期的,而不需要考慮狀態轉變時發生的動態變化。

  • 對測試來說, 我們永遠應該把注意力放在自己team寫的程式碼上, 因此可以足夠安全的認為當生成的 snapshot 正確時,元件的UI渲染也是正確的。

實際應用時,我們用了 jest 的 shallow 方法來生成測試元件的wrapper; 用 enzyme-to-json/serializer 這個 lib 把生成的 shallowWrapper 轉化成 snapshot 結果。
用 shallow 的好處是保證每個元件測試的獨立性,比如在當前元件的 snapshot 結構樹中, 我只關心我用到的 childComponent 的名字和傳給他什麼 prop, 具體這個元件的內部UI結構應該交給這個元件本身的snapshot測試去保證。

舉例我們要測試一個 <Home /> 元件。

// 生成這個元件的shallowWrapper, props為測試時需要傳給元件的prop
const setup = props => {  
  return shallow(<Home {...props}/>>);
}
const wrapper = setup({title: 'example title', dateIdx: 100});

// 生成snapshot並和之前的結果對比

expect(wrapper).toMatchSnapshot();  
// 序列化結構樹會自動和*.snap中的結果比較

如果是第一次生成 snapshot, 應該去仔細看一下 Home.react-test.js.snap 中生成的結構樹,防止原始的 snapshot 就是錯誤的。

除了測試,元件本身的設計也會影響到測試的效率。
比如一個邏輯很複雜的元件, props可能是幾個很複雜的 Object, 那麼這個元件內部除了顯示邏輯還包含了很多從這些 Object 中計算出需要顯示的data的邏輯。 這樣在設計和維護單元測試指令碼時就很困難。

正確的做法應該是在設計 Component 的時候就設計成 Container - Presentational Component的模式。(參考 Smart and Dumb components - Dan Abramov)。

這樣的好處是比如本來UI上需要顯示一段 text, 這段 text 根據幾個複雜的 Object 計算出來,那原本的測試就需要mock這些複雜的 Object 並保證 snapshot 的正確性。 當把這個Component 重構成presentational的元件之後,它只需要一個這個 text 欄位的 prop 傳給他一個 string, 然後把這個 prop 顯示在UI上, 計算邏輯被抽象到了父元件或者 selector層(redux和component之間)。

這樣我們的測試指令碼的可維護性就變高了, 這個元件本身也變得更加單純了。

元件互動測試

用 Enzyme shallow 生成的 ReactWrapper 會提供一些用來進行元件互動測試的 API,比如 find(), parents(), children()等選擇器進行元素查詢; state(), props()進行資料查詢; setState(), setProps()進行資料操作; simulate()模擬時間觸發。

在互動測試中,我們主要利用 simulate() API模擬事件,來判斷這個元素的 prop 上的特定函式是否被呼叫, 傳參是否正確, 以及元件狀態是否發生意料之中的修改。在最近的 enzyme 版本更新後, shallowWrapper 的 component lifecycle 函式也會被正確的呼叫。因此對元件狀態的測試是比較容易的。

比如我們有一個元素中包含了下面這塊程式碼:

...
<PrimaryButton onPress={()=>{Logger.log('Button_Clicked')}}>  
...

我們的測試指令碼可以這麼寫:

// Mock Logger module中的方法, 用jest.fn來實現spy方法
Logger.log = jest.fn();

// setup shallowWrapper
const setup = props => shallow(<SomeComponent {...props}/>>);  
const wrapper = setup();

// 找到元素並且模擬press事件
wrapper.find('PrimaryButton').simulate('press');

// Assert正確的方法被呼叫, 並且傳參正確
expect(Logger.log).toBeCalledWith('Button_Clicked');  

如果 press 事件導致 state 變化或者UI變化, 也可以用 enzyme 提供的API或者用 snapshot 進行測試。

要注意的是在這個 case 中我們用了 shallow render,simulate 的點選事件只是執行了這個元件的 onPress 方法,而這個 PrimaryButton 的元件內部是不是把這個 onPress 真正的執行了這個 case 並不關心。因此需要另一個針對 PrimaryButton 元件的單元測試來保證 onPress 這個prop被正確的處理了。

這樣的好處是當 PrimaryButton 自身出現bug時, 之後這個元件本身的單元測試會 fail, 其他用到這個元件的 Component 並不會受影。 這樣測試之間就相互獨立了。

Reducer/Action handler/Selector/Utils 測試

這幾種 React Native 不同layer的測試都屬於功能函式測試,一個良好的 React Native 專案應該把業務邏輯儘量都實現在這幾個 layer 中, 而不是堆放在元件中。也就是把顯示(views)和邏輯分開。

這樣純函式和函式式變成的優勢就體現出來了,不僅code結構和層級變的清晰,編寫和維護單元測試也變得簡單了。

如果你的專案有難以測試的函式/元件, 應該先想著如何refactor,把龐大複雜的邏輯/元件拆分成功能單一的單元, 儘量讓一個函式只做一個task。

先看一下我們目前 React Native 的邏輯結構:

Structure

  • Redux 的 Store 中儲存著 global 的 App state

  • Selectors 把A pp state(有時候和 component 的 prop 一起)轉化成 Component(React Views)顯示時需要的簡單的Prop

  • Component 要改變 App state 的時候, dispatch 一個 action 到 Action handler 中(react-thunk),來執行非同步的 action(用了redux-thunk, 最終會dispatch純Object的action)或者純Object 的 action。

  • Reducer接收action和舊的app state生成新的app state並存到Store中。

  • Store改變後會通過Selectors更新Component的UI。

1. Reducer測試

Reducer 是純函式, 因此測試的時候只要引入函式, 傳入特定引數,判斷函式返回是否符合預期即可。 可以利用 jest 的 snapshot test 來判斷結果。

舉個例子, 有reducer如下(我們在redux中使用了Immutable.js):

// reducer
export function localUserReducer(state, action) {  
  switch (action.type) {
    case Actions.UPDATE_USER:
      state = state.merge(Immutable.fromJS(action.user));
      break;
    default:
    break
  }
  return state;
}

// action
export function updateUser(user: Object): Object {  
  return {
    type: Actions.UPDATE_USER,
    user: user,
  };
}

測試指令碼可以這麼寫

// ingore import of reducer and action
it('should merge user info when updateUser action is dispatched', ()=>{  
    const state = Immutable.fromJS({ name: 'old_name', other: 'old_other' });
    expect(localUserReducer(state, updateUser({ new: 'new fields', name: 'new name' }))).toMatchSnapshot();
})

生成的snapshot如下:

exports[`should merge user info when updateUser action is dispatched`] = `  
Immutable.Map {  
  "name": "new name",
  "other": "old_other",
  "new": "new fields",
}
`;

可以看到 snapshot 中得到了一個 Immutable.Map 型別的物件, 並且Map的值正確的被 merge 了。

2. Action Handler測試

純 Object的 action 測試比較簡單, 保證 action creator 函式返回的 Object 正確就可以了。

Async action的測試有兩種不錯的方案:

  • 藉助第三方庫configureMockStore,將 redux-thunk 這種非同步中介軟體傳入進去處理,獲得封裝後的 store.dispatch 來派發action

  • 利用 jest 的 spy 函式, mock const dispatch = jest.fn(), 然後把 dispatch 傳給非同步 action 的函式, 並驗證 dispatch spy 被傳了正確的 object action 引數。

比如有個非同步 action:

export function saveOnboardingUser(user) {  
  return async (dispatch) => {
    await someAsyncFunction();
    dispatch(updateUser(user))
    return user;
  }
}

測試程式碼:

...
const dispatch = jest.fn();  
await Actions.saveOnboardedUser({name: 'example'});  
expect(dispatch).toBeCalledWith({ "type": "update_local_user", "user":{ "name": "example"}});  
...
3. Selector 測試

Selector 這層我們用了 reselect 這個庫, selector 的作用是從 redux store 的 state 中選出我們需要的值。
因此 selector 也是純函式, 在測試的時候只需要 mock一個 redux 的 state, 然後保證 selector 的結果正確即可。

這裡只簡單的寫一下測試程式碼:

...
const state = Immutable.Map({ui: Immutable.Map({dateIdx: 10})});  
expect(homeDateSelector(state)).toBe(10);

// 或者expect(homeDateSelector(state)).toMatchSnapshot()
...

當然 homeDateSelector 中會有從 state 中得到 dateIdx 這個值的實現程式碼, 比如 state => state.getIn(['ui', 'dateIdx])
selector 是可巢狀的, 但只要正確的 mock redux state, 最終的結果就應該是唯一的。

4. Utils 測試

和普通的js函式型單元測試沒有區別,就不多贅述了。

WWW API測試

WWW API測試是指對server介面的測試, 只要在測試程式碼中呼叫 React Native 的API模組的方法並且驗證返回結果的正確性即可(可能需要 mock 一些 token,device info等資訊)。
和通常的 WWW API 測試的方法幾乎相同。 用Jest實現的好處是保持所有的單元測試用統一的 framework 實現和執行, 用起來比較方便。

這塊測試因為需要真正的連線到 server 上, 因此可以和其他的單元測試分開以提高執行的速度。可以在 package.json 裡面用不同的 yarn script, 如 yarn test-ut, yarn test-wwwapi 來分別執行單元測試和WWW API測試。

Logging 測試

我在 Logging 測試中把 logger 這個 module 在初始化測試時 global 的 mock 了一個 spy 函式。 (logger.logEvent = jest.fn(); global.logEvent = logger.LogEvent)。
這樣在測試其他單元/元件時, 只要程式碼中呼叫到了 logger 模組的方法, 就可以用:

expect(logEvent).toBeLastCalledWith(eventName: 'xxxx', {type: 'xxx'})  

這樣的方法來測試 logging 的正確性。
此外還需要手工的測試 logging 對應的 native module 可以把 logging 傳給 server。這也是必不可少的一步。

Graphql 測試

測試Apollo client時可以用 apollo-test-utils 來 mock 網路返回的結果。

測試 server 的時候和正常的 WWW API 測試類似, 只要保證傳送的請求(同樣需要 mock header 並正確的呼叫 setContext 來設定 graphql 請求的引數)在 server 上返回結果正確即可。只要把 client 呼叫的Query語句覆蓋一遍就足夠了。

一些整合測試

前面講的實際測試方法中都是單元化的去測試, 在實踐中也會有一些整合測試來保證這些單元組裝起來也是work的。

  • 比如把 reducer + selector + UI render 這三個layer組合起來,選擇一個典型的 flow 來寫整合測試。

  • 或者把 WWW API, Action Handler 和 reducer 整合起來, 保證 server 的資料和 client 是可相容的, 防止mock的資料和 server 返回資料不一致導致的問題。

  • 或者把 parent-children Components 做一個整合測試, 也是不錯的測試方案。

如何來規劃整合測試的 scope 也是根據專案不同來選擇合適的方案的,有這樣一層測試可以在不依賴於大量E2E測試的情況下保證各個元件之間也是正確工作的,是對測試效率和測試信心都有好處的一種這種方案。

總結

  • 在 Glow 的 React Native 專案測試中, 我們有大量的單元測試,包含了Component/Reducers/Action Handlers/Selectors/Utils/WWW API/Logging 等等。 有少量的整合性測試和更少量的E2E全面測試。

  • 在 server 端有 server 的單元測試。

  • 在 Code quality 有 eslint, python和Flow type。

  • 此外還有必不可少的人工探索性測試, 來保證自動化測試無法覆蓋的方面以及各種需要想象力的邏輯測試。

我認為這樣的測試體系是比較安全高效的,用大量的自動化測試代替了人不擅長的重複性測試工作。還有些未來測試可以做的事情:

  • 提高單元測試,整合測試,E2E測試的覆蓋率。

  • 穩定的可持續整合系統

  • DashBoard 來記錄追蹤測試結果來評估整體的App質量

就寫到這裡, 希望對閱讀的人有所幫助~