iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 21
3
Modern Web

在 React 生態圈內打滾的一年 feat. TypeScript系列 第 21

Day20 | Component 被 Redux 罩著怎麼測試?

前言

就 Redux 而言,與上一篇的 Counter 不同的地方就是多了 Action 以及 Reducer,而它們也都只是純函數,測試並不會是難點,需要思考的地方主要是需模擬出 Redux 的 Store 讓 Component 不會因為沒有 Provider 提供資料而在 Render 時出錯。


前置準備

  1. 文中的專案會以 Day19 的專案架構繼續講解,如果未跟到前一天的進度,可以從 GitHub 上 Clone 下來。
  2. 一顆擁有學習熱忱的心。

使用方法

建立測試檔案

與測試 Counter 相同,第一步先到__tests__/component 中建立對應的 Component 測試檔案:

|-__tests__
 |-component
  |-Content
   |-Content.test.jsx
  |-Counter

寫下測試案例

撰寫測試案例之前,都要思考該寫些什麼才符合受測 Component,大家可以先以上一章學會的方式,思考一下 Content 這個 Component 擁有哪些行為,並列出要寫下的測試案例,再繼續往下確認有沒有相同。

根據行為,要為 Content 撰寫的測試案例會有兩個:

  1. 確認 Content 有正常 Render 畫面。
  2. 按下按鈕後會送出請求,取得伺服器的資料。

下一步就能依照上方兩點寫下測試案例,但別忘了要先在 Content 的 DOM 加上 data-testid,要設置的分別是最外層的 div,會用它來判斷 Content 有沒有正常 Render,另外是顯示資料的 div 及請求資料的 button

接下來寫下驗證 Content 是否正常 Render 的測試:

輸入指令 npm run test 確認是否通過測試,但顯然出了一點問題:

錯誤內容的重點如下:

regeneratorRuntime is not defined

這裡又是考驗記憶力的時間了,在前幾個章節我們使用 Redux Saga 時,也有遇過類似的問題,而當初我們下載了 @babel/polyfill,並在 webpack.config.js 中的進入點設置預先載入,解決有些較新語法還不支援的問題。

現在我們也遇見了,但是在測試中不會經過 @babel/polyfill 預先載入那些語法,因此這裡另外下載一個 Babel 的 Pugin,在測試時代替 @babel/polyfill 做這件事情:

npm install --save-dev @babel/plugin-transform-runtime

下載完後,打開 .babelrc.js 在 plugins 中加上它:

完成後再執行一次測試,仍然會出現錯誤,但這次的錯誤訊息有點長,筆者就擷取訊息中的精華部分:

關鍵字就是:

Invariant Violation: could not find react-redux context value; please ensure the component is wrapped in a

這個也就是前言裡提到的,測試擁有 Redux 的 Component,該怎麼模擬出 Store 來,要解決這個過程也很簡單,第一步先將 createStoreProvider 以及創建 createStore 使用到的 Reducer import 進測試檔案中:

import { createStore } from 'redux';
import { Provider } from 'react-redux';
import reducer from '../../../src/reducer/todolist';

有了 createStore 以及 reducer 後,便能直接創建 store 了:

const store = createStore(reducer);

這裡特別提一下 Reducer 的初始 State,一般來說,如果不另外指定的話,就會是使用當初在 Reducer 內寫好的 State 初始化給 Store,在測試內也是這樣子,但是有時候專案會需要建立特別的測試環境,要另外指定一開始的 State 時,也可以這麼做:

const store = createStore(
  reducer, { /*另外設置 State */ }
);

有了測試時建立的 store 後,便能在 Render 時將 Provider 放到受測 Component 的外層,並將 store 交給 Provider,讓 Component 在測試時,也有 Store 管理提供資料,完成後測試案例會變成這樣:

接著再執行測試,便能看見結果亮起綠燈 PASS:

萬歲!那接下來的「按下按鈕後會送出請求,取得伺服器的資料」需要拆分為以下幾個步驟:

  1. Component 按下按鈕,會執行 Dispatch,這裡會傳入對應的 Action。
  2. 收到對應的 Action 時,會交給 Redux-Saga 執行非同步請求。
  3. 將 Fetch 回來的資料交給 Reducer 更新 State。
  4. Component 能夠正常顯示更新後的 State 資料。

大概就是這樣子,然後這裡其實滿呼應 Day07 | 從 Hooks 開始的 Component 新生活 這篇所說,因為走在尖端上,所以會遇到一些痛點,這個部分筆者在處理 useDispatch 的時候稍微卡了一下,但最後還是順利解決了,那今天會處理的部份會集中在 Component 身上,也就是第一和四步驟,Redux 及 Redux-Saga 就留到明天。

當然!如果以下範例是筆者查找資料思考過後的做法,如果大家有其他最佳實踐都可以留言告訴我!感激不盡!

第一步驟的難點大概是要替 useDispatch 以及他回傳的 dispatch 做 mock,因此先用 Spy 去 Mock react-redux 的 useDispatch,但是 Spy 得依照整個 Model 取出 Method 才有辦法製造,因此先將 react-redux 內的所有方法統一取出命名,像這樣子:

import * as ReactRedux from 'react-redux';

但這麼做會導致 Content_Check_Render 這個測試案例有問題,因為 Provider 被放到 ReactRedux 裡了,為了防止出錯可以先將 Provider 取出:

const { Provider } = ReactRedux;

前置準備結束後就能開始做 Spy 了:

const mockUseDispatch = jest.spyOn(ReactRedux, 'useDispatch');

再來它會回傳一個 dispatch,那才是按下按鈕後真正會送入 Action 執行的方法,這裡先用 jest.fn(),做一個小的 Mock 用來取代 dispatch

const mockDispatch = jest.fn();

接下來用 mockReturnValuemockUseDispatch 設置回傳 mockDispatch

mockUseDispatch.mockReturnValue(mockDispatch);

useDispatch 打造完 Mock 後,可以接著使用上一個測試案例學到的,為 Component 創建 Redux 環境,測試案例會是這樣子:

而在 Content_Click_ExecuteDispatch 要驗證的事情就是,按下按鈕時dispatch 執行的是不是我們預期的 Action,這個部分可以從 mockDispatch.mock.calls,中去確認它有沒有執行,以及執行時收到的參數為何:

// 取得按鈕並按下
const fetchContentDataBtn = getByTestId('fetchContentDataBtn');
fireEvent.click(fetchContentDataBtn);

// 斷言 mockDispatch 執行時的第一個參數為何
expect(mockDispatch.mock.calls[0][0])
  .toEqual({ type: 'FETCH_DATA_BEGIN' });

下完斷言後就可以執行測試,結果應該如下:

這裡被測出有個地方出錯了,提示中說明 mockDispatch 接收到的參數還要有的 payload,發現到這點後,我也到了 Content 中再確認按下按鈕時有沒有帶任何參數,結果也是沒有的,因此就可以說這個 Action 內的 payload 是多餘的贅 Code,也能開啟 src/action/todolist.js 把 fetchDataBegin 內的 payload 刪掉,因為它不需要任何參數,實務上也沒有給它參數:

export const fetchDataBegin = () => ({
  type: FETCH_DATA_BEGIN,
});

修正完因為測試發現的贅 Code 後就能通過了:

但這時候可以發現在每個測試案例都要重新 Render Component 實在是太多餘了,且要是臨時替 Component 增加一個 Props,那需要改動的地方就很多,這麼一來會喪失對測試案例的可維護性,這個情況可以製作一個函式,他會負責回傳 Render 後的 Component,例如:

這麼一來 Content 如果修改時,就能只維護一個地方就行了,以 Content_Check_Render 為例子測試案例就這麼修正,看起來也簡潔許多:

再來是第四點,要確認 Content 內顯示的是否為正確的資訊,也就是 State 中的 data,但目前為止我們在 Reducer 中的預設 data 是空物件 {},這部分要在測試時替它做一個假的 State,並在 createStore 時和 Reducer 一起創建 Store:

至於 useSelector 呢?其實不需要替它做 Mock,因為如果 contentData 內能夠正確顯示出 testInitState 的值的話,就代表 useSelector 有正確執行了,就像是上方我只替 mockDispatch 做測試,卻沒有斷言 mockUseDispatch 有沒有執行過一樣。

最後下完斷言後,測試案例會變成:

經過測試後,結果也是正常的:

到此 Component 的測試就告一段落了,不過還是得再修正一下 generateComponent,讓它可以傳入 initState,在 Store 中建立測試用的 State:

而 Content_Render_ContentData 改成這樣子:

這麼一來,即使是不同測試案例需要不同的 State 建立 Store就不用每次都另外寫 Render 的設定了!

本文的範例程式碼會提供在 GitHub 上,歡迎各位參考:)


結尾

天真的我曾經以為關於 Redux 的測試講解起來應該容易多了,但沒想到 Hooks 版的 react-redux 會花我那麼多時間,導致要再將 Reducer 和 Saga 的部分拆開到下個章節。

如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!

參考文章

  1. https://medium.com/@pylnata/testing-react-functional-component-using-hooks-useeffect-usedispatch-and-useselector-in-shallow-9cfbc74f62fb

上一篇
Day19 | Component 的測試方式不私藏
下一篇
Day21 | 從測試角度操作 Redux-Saga 和 Reducer
系列文
在 React 生態圈內打滾的一年 feat. TypeScript31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Neo
iT邦新手 5 級 ‧ 2020-07-10 11:52:17

我在測試Content_Render_ContentData的時候一直出錯,
後來發現是因為我自己有使用combineReducers,
加上combineReducers又發現無法帶入initState,
上網查了一下:
https://redux.js.org/recipes/structuring-reducers/initializing-state
最後是用這個方式帶入initState,然後就測試成功了。
希望之後有用combineReducers的朋友們不會花太多時間卡在這裡。
我卡了好久QQ

Neo iT邦新手 5 級 ‧ 2022-06-28 17:52:02 檢舉

補充 新版的ts可能會造成以下錯誤(react-redux的ts)
TypeError: Cannot redefine property: useDispatch at Function.defineProperty ()

可以使用這個方式解決
jest.mock('react-redux', () => ({
__esModule: true,
// @ts-ignore
...jest.requireActual('react-redux'),
}));

https://github.com/aelbore/esbuild-jest/issues/26

weiii576 iT邦新手 5 級 ‧ 2022-07-14 00:45:54 檢舉

我是也是遇到同樣問題,搞超久Q
參考同一個網址,最後改成這樣,給大家參考

const mockDispatch = jest.fn()
jest.mock('react-redux', () => ({
  ...jest.requireActual('react-redux'),
  useDispatch: () => mockDispatch,
}))

describe('Content', () => {
  test('Content_Click_ExecuteDispatch', () => {
    const { getByTestId } = generateComponent(<Content />)
    const fetchContentDataBtn = getByTestId('fetchContentDataBtn')
    fireEvent.click(fetchContentDataBtn)

    expect(mockDispatch).toHaveBeenCalledWith({ type: 'FETCH_DATA_BEGIN' })
  })
})

我要留言

立即登入留言