iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 22
3
Modern Web

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

Day21 | 從測試角度操作 Redux-Saga 和 Reducer

前言

先複習一下上一個章節裡我們做了什麼,首先是將 Content 的按鈕行為拆成四個步驟:

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

而我們對 Content 驗證了「按下按鈕後 dispatch 是否會傳入正確的 Action 執行。」和「Store 內如果有資料那 Content 會不會正常顯示」這兩件事情,也就是上方的第一和四點,也許大家覺得很疑惑,為什麼不對 Fetch 做 Mock 然後直接對最後的 Content 有沒有 Render 新資料做驗證?而是要拆成四個部分,分別做測試。

這就回到了所謂的「單一行為」,如果我們將以上四點都放在一個測試案例中,也就是直接撰寫「按下了按紐,然後確認畫面是不是有 Render 新資料」,那問題來了,當這個測試出錯的時候,可以馬上發現錯誤是哪裡造成的嗎?

沒辦法對吧?雖然這麼說,但也是有所有單元測試都通過,最後卻無法執行的狀況,例如:


(圖片來源:http://blog.sina.com.cn/s/blog_6617b7740100pihp.html

門是門、陽台是陽台、鐵軌是鐵軌,各個功能都正常,組合起來卻悲劇到不行,因此單元測試只是對程式碼的第一道防線,除此之外還需要其他測試整合,例如端對端測試(所謂的人工測試),端對端測試是以使用者的角度去做測試,測試出來的結果也比較接近使用者的需求,但缺點就是測試耗費的成本較高,不像單元測試可以在幾秒間就得到結果,但個單元組合起來也可能造成專案運行失敗。

本篇會針對 Redux-Saga 與 React-Redux 的 Reducer 行為進行單元測試。


前置準備

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

使用方法

整理 Saga 內容

因為當時在學習 Redux-Saga 的時候,把 Saga 的 function 都放在 Action 中,而且負責獲取資料的 fetchData 也沒有抽出來,再進行測試前先來整理一下這些程式碼,首先打開 src/action/todolist.js,看到這個 fetchData,現在它是這個樣子:

第一步先將 call 內的 fetch 拆成一個 function:

接著在 src 下建立一個目錄 api,我們把負責做 api 請求的 function 放在那裡,例如 getContent

|-src
 |-api
  |-content.js

除此之外,我也要把 Action 裡像 fetchDatamySaga 關於 Saga 的部分移出去,存放位置會放在 src/sagas 下,我會在那裡建立一個 content.js 管理屬於 content 的非同步 Flow。

src/sagas/content.js 的內容如下,因應待會要做測試,所以我把會執行的 fetchDataexport

這裡要注意別忘了修改 src/sagas/index.js,因為 mySaga 的位置改變了,然後我也順勢重新命名 Saga 的名稱:

這麼一來 src/action/todolist.js 裡就真的只剩 Action 而已:

這就是我要的乾乾淨淨。

最後我們做了那麼多改動,如果是使用端對端測試,可能就要把專案運行起來,然後將所有相關功能都測過一遍,但是現在我們有了第一道防護網「單元測試」,試著執行 npm run test 吧!

確認所有的測試案例都通過後,甚至連專案都不需運行就知道沒問題了!

Saga 測試案例

整理好後,並確認原有功能都沒受到影響後,就能繼續寫下新的測試案例了,首先在__tests__ 底下建立 sagas 目錄,然後創建 content.test.js,接下來會在這裡寫下關於 src/sagas/content.js 的測試。

對 Saga 的單元測試很有趣,因為 Saga 內都是用 Generator Function,所以它每執行一次都會停在 yield(如果忘記了可以到這裡複習),以 fetchData 為例,要驗證的是它是否會按照我們理想中的順序,以:

  1. call 呼叫 getContent 獲取資料
  2. 將取得的資料用 put 觸發 Reducer
  3. 完成這次的 Generator Function 行為

三個部分執行,只要都正確就沒問題了!

第一步先把與 fetchData 內有執行到的 function 都 import 進來,並把 fetchData 的執行交給變數 generator

然後用 next() 一步一步看它每次停在 yield 的部分是否如我們所想,第一步就是驗證是否是使用 call 呼叫 getContent

// 取出第一次執行到 yield 的部分
const callGetDataApi = generator.next().value;

// 驗證是不是用 call 呼叫 getContent
expect(callGetDataApi).toEqual(call(getContent));

第二步是確認有沒有將 getContent 回傳的資料用 put 觸發 Reducer,這時候我們還沒有用 Mock 隔離 fetch,以後也不需要,因為當我們再一次對 generator 執行 next() 時,帶入 next 的參數就會放進上一次執行的結果,例如我這麼執行:

// 執行 next 時帶入 123
const successGetData = generator.next('123').value;

// ==== 分隔線 ====
// src/sagas/content.js
export function* fetchData() {
  // 123 會被丟回上一次執行的 data 中
  const data = yield call(getContent);
  yield put(fetchDataSuccess(data));
}

也就是說我可以利用把參數傳入 next 來偽造 yield call(getContent) 回傳的值,因此第二次執行可以這麼驗證是否有使用 put 把獲取到的資料丟給 Reducer:

const successGetData = generator.next('mockResponse').value;
expect(successGetData).toEqual(put(fetchDataSuccess('mockResponse')));

最後要驗證的是,該次的 Generator Function 結束了沒,這裡使用 .next().done 判斷 truefalse,最後 FetchData_Execute_ApiFlow 的測試案例會長這樣子:

完成後輸入 npm run test,執行測試,確認是否有問題:

Reducer 測試案例

記得我在昨天的時候說過 Redcuer 其實不難對吧!因為它就只是個純函數,待會會來驗證 src/reducer/todolist.js 的 FETCH_DATA_SUCCESS 有沒有問題,是否能將拿到的資料寫入 State 的正確欄位。

第一步仍然是在__tests__ 中新增 reducer 目錄,然後建立測試檔案:

|-__tests__
 |-reducer
  |-todolist.test.js

並將要測試的 Reducer 和 Action import

這裡複習一下 Reducer,它是個函數,在執行時會傳入兩個參數,第一個是 State,第二個是 Action 物件,Reducer 會依照 Action 的 Type 選擇對 State 做什麼事情,並在完成後將 State 回傳。

todolistReducer 為例,不論初始的 State 是什麼,我總是期望它接收到 fetchDataSuccess 產生的 Action 時,能夠將新值寫到 State 中的 data 內,因此就能這麼做斷言:

上方給 todolistReducer 第一個參數的初始 State 是 {} 空物件,第二個參數是 fetchDataSuccess 產生的 Action,得到的結果會希望原本的 {} 空物件多了 data 屬性,其值為送入 fetchDataSuccess 的參數。

完成後便可執行測試,可以看到又多一個 PASS 紀錄了:

至於 src/action/todolist.js 內的 fetchDataSuccess 是否需要被驗證就看個人了,因為在 Todolist Reducer 的測試案例中,也沒有替 fetchDataSuccess 製作 Mock,如果 fetchDataSuccess 產生的 Action 發生錯誤時,那該測試案例也不會成功。

而沒有替 fetchDataSuccess 使用 Spy 的原因是因為我可以從「結果」,也就是 Reducer 回傳的值就能知道它到底有沒有執行,而不是再對 Spy 做 toHaveBeenCalled 的斷言,如果在測試案例裡同時驗證「結果」與「執行過程」,就等於在一個測試案例裡測了兩種相同東西,因為為了要產生結果「執行過程」就是必要的,如果沒有「執行過程」,會造成「結果」不正確,測試也會失敗。

如果遇到這種情況,就得先靜下來傾聽自己內心的聲音,在該測試案例中究竟是「執行過程」重要或是「結果」重要?如果太貪心兩個都要,可能就會造成過度指定。

過度指定

過度指定是在 單元測試的藝術 [第二版] 一書中看來的,意思是你應該想想在你要測試的單一行為中,什麼才是這個行為真正重視的,以上面的例子而言,驗證了「過程」及「結果」就觸碰到了過度指定的線了,但是只用文字說明還是太抽象了對吧!其實早在前幾張剛學測試時,我們就寫出過度指定的測試案例了,猜猜看是什麼?

答案是__tests__/car.test.js,讓現在的我們看看這個例子:

在上方的測試案例裡,雖然 Mock 了 uuid 要提供預設值,也 Spy 了 getCurrentCarSpy 驗證是否有執行,但是對於 check_add_prod 來說,到底什麼才是最重要的?

這麼說就很明顯了吧!當然是最後的結果是否有加進 carContent 中,而且因為我們替 Mock 的 uuid 設置了 mockReturnValue ,所以如果 uuid 沒執行期望中的 id 就不會是 '9999',測試就會失敗,getCurrentCar 也是相同的,若它沒有正常的取出 carContent,又怎麼會得出最後的結果呢?

所以動動手,試著修改一下 check_add_prod 吧!修改後的結果會放到今天的 GitHub 中,可以再去翻閱確認修改方向正不正確。

驗證執行過程

聽起來只需要驗證最後的結果就行了,那什麼時候又該「驗證執行過程」呢?

在受測對象沒有回傳值,或不會產生任何狀態改變的時候,就驗證執行過程

其實在上一個篇章,就有個測試案例很好的去驗證了執行過程:

光是點擊按鈕這個動作,是不會有任何回傳值可以提供驗證的,這時候我們選擇對觸發 Reducer 的 dispatch 做 Mock,並在該測試案例中僅驗證執行時所帶入的 Action 是否正確,如果 dispatch 沒執行的話,測試案例便會出錯。

因此要曉得需要寫下哪些測試案例,就得先了解單一行為,且更要了解在該單一行為內重視的是什麼,如此一來就能寫下優秀的測試案例了!

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


結尾

本篇文章在最後提出過度指定這個非常重要的概念,並來個回馬槍打翻之前學習 Mock 的例子(check_add_prod 的部分),希望大家會喜歡這個安排,也可以更了解如何避免過度指定,以及察覺自己寫的案例中潛藏的問題。

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

參考資料

  1. https://codeburst.io/how-i-test-redux-saga-fcc425cda018

上一篇
Day20 | Component 被 Redux 罩著怎麼測試?
下一篇
Day22 | 創建假 History ,騙過真 Router
系列文
在 React 生態圈內打滾的一年 feat. TypeScript31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
連城
iT邦新手 4 級 ‧ 2020-07-06 18:47:32

所以過度指定 希望的是在單元測試中主要是測試結果 除非不會有結果才會測試過程 嗎?

神Q超人 iT邦研究生 5 級 ‧ 2020-07-18 22:19:36 檢舉

對!「在單元測試中主要是測試結果 除非不會有結果才會測試過程」這個是測試的方式,畢竟沒有結果就只能測該函式有沒有被呼叫了。

重點是要在該測試裡找到一項你要測試的目的,如果你測試過多(重複性的)就是過度指定了。

我要留言

立即登入留言