iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 19
6
Modern Web

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

Day18 | 用 Mock 打造國家機器,驗證函式執行 feat. jest

  • 分享至 

  • xImage
  •  

前言

本篇會提及的 Mock 在 Unit Test 中扮演著很重要的角色,因為單元測試必須將關注點放在要被測試的 function 身上,不能讓不確定性在 function 外的地方發生,因此要使用 Mock 迴避掉不確定性,但是針對不同的狀況也會需要不同的 Mock,就讓本文來解析吧!


前置準備

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

依賴

在提到 Mock 前,我想先解釋一下何謂依賴,這時請大家先從 npm 下載 uuid 這個套件:

npm install uuid --save

接著在 src/utils 中創建一個 car.js,並新增以下程式碼,用途為將選取的商品放置於購物車內:

import uuid from 'uuid/v1';

const car = {

  carContent: [],

  getCurrentCar: () => car.carContent,

  addProdToCar: (name, count) => {
    const workCar = [...car.getCurrentCar()];
    workCar.push({ id: uuid(), name, count });
    return workCar;
  },
};

export default car;

上方程式中的 car 職責為管理購物車的操作(目前只有增加的功能),程式將購物車內容用 carContent 紀錄,我們得用 getCurrentCar 來取得它,然後雖然有點雞肋,但在購物車內增加商品項目的時候,還另外用了 uuid 框架,替每筆資料標記一個唯一值,最後再將整個 car 作為一個 model export 出去。

因此在 addProdToCar 的短短兩行程式碼中,就使用了 getCurrentCar 這個 function,以及框架 uuid,除了更改程式碼以外,少了兩個之中的任何一個都會出錯,這個狀況,就可以說明了 addProdToCar 依賴著 getCurrentCar 和 uuid。

這是一個很好的 function,並不是寫法方面,而是他能夠同時讓我們練習到幾種 Mock 的方法,接下來會一一介紹。

使用方法

Mock 替身函式,也被稱為 Test Double ,作用就如同字面上的意思,當各位在測試某個 function 的時候,多少會遇到需要依賴其他 module 或 function 的狀況,

Mock 是為了讓測試過程專注在要測試的 function 上,讓測試的邏輯性不會因為依賴造成不確定性,而作為依賴 function 的替身進行測試。

以下我們會分別用幾種 Mock 來處理 addProdToCar 中的依賴。

jest.mock()

jest.mock() 用來完全隔離依賴,因此只要對此做 Mock 處理,在測試時執行 addProdToCar ,就不會真的呼叫 uuid,而是用 Mock 紀錄是否真的有執行到 uuid 取得新 id,jest.mock() 的用法如下:

import uuid from 'uuid/v1';

jest.mock('uuid/v1');

把 uuid 從 module uuid/v1 取出來後,直接對整個 module 做 mock,只要這麼做 uuid 在執行時便不會真的執行,但我們又能夠確認是否有確實執行依賴,以下先在__tests__ 目錄下建立一個 car.test.js ,並寫下 addProdToCar 相關的測試案例:

import uuid from 'uuid/v1';
import car from '../src/utils/car';

jest.mock('uuid/v1');

describe('addProdToCar', () => {
  test('check_execute_uuid', () => {
    car.addProdToCar('apple', 3);
    expect(uuid).toHaveBeenCalled();
  });
});

在測試案例 check_execute_uuid 中,先對要測試 addProdToCar 做執行,試著加入第一項產品,但

這時候還不需要在意產品是否正確加入,而是確認依賴 uuid 是否有確實執行。

因此我們做斷言的對象是 uuid,並使用 toHaveBeenCalled 斷言是否有執行過,如果有的話就沒問題,沒有的話代表 uuid 在 addProdToCar 中的依賴是有問題的。

測試結果如下:

既然是 PASS 就代表, uuid 確實有被執行到,而除了用 toHaveBeenCalled 做斷言外,也可以直接從被 Mock 的 uuid 中取得執行的狀態:

test('check_execute_uuid', () => {
  car.addProdToCar('apple', 3);
  // expect(uuid).toHaveBeenCalled();
  // 執行次數為 1
  expect(uuid.mock.calls.length).toBe(1);
});

因為 addProdToCar 會回傳加入產品後的新購物車內容,因此可以用 console 來確認結果為何:

test('check_execute_uuid', () => {
  const newCar = car.addProdToCar('apple', 3);
  console.log(newCar);
  /* 其餘省略 */
});

得出來的結果會因為 uuid 被 Mock,導致沒有真正的邏輯被執行,id 的值就是 undefined:

但某些情況依賴得給我們一些回傳值,才有辦法繼續正常執行完 function,此時就可以使用 mockReturnValue 來指定 mock 的回傳值,下方的測試中將 uuid.mockReturnValue 放入作用域的 beforeAll 中,讓測試開始的時候賦予 Mock 的 uuid 回傳值為字串 9999:

beforeAll(() => {
  uuid.mockReturnValue('9999');
});

接著再執行一次測試,就能看到 undefined 的部分變成 '9999' 了:

如果要設定每次都回傳不同的值也可以選用 mockReturnValueOnce,它只會設定下一次回傳的值,但如果將每一個 mockReturnValueOnce 串起來,就會依序回傳設定的值,例如:

uuid
  .mockReturnValueOnce(123)
  .mockReturnValueOnce(null)
  .mockReturnValue('abc');

上方的設定會讓 uuid 依序回傳 123、null,第三次後永遠回傳'abc'。

因此透過 Mock,我們便能隔離開使用的依賴,在測試時不會真正執行依賴,而是依照 mockReturnValue 設定一個合理的回傳值,讓關注點持續在被測試的 function 上面,不用害怕因為其他依賴導致測試錯誤。

這個概念也可以用在隔離資料層,有時候我們會使用 fetch 等請求,從後端取得資料,但是整個後端也是一個很大的邏輯,我們當然也不能因為後端的邏輯導致錯誤可能出錯,當然也不只這樣,請求資料可能還會受到網路因素的影響,

單元測試應該要再想測試的時候就可以測試,因此 fetch 也需使用 Mock 處理。

但是 jest.mock 是一把雙面刃,如果在測試案例中對所有的依賴都使用 Mock 處理,會造成測試的維護成本上升,並增加撰寫出無效測試的風險,

就以 addProdToCar 內的另一個依賴 getCurrentCar 來說,雖然我們可以使用 mockReturnValue 替 getCurrentCar 設定合理的回傳值,讓 addProdToCar 在測試時不會因為依賴出錯,但是畢竟 getCurrentCar 是我們所寫的邏輯,我們就有可能會在今後的修改上動到它,而問題就來了,當你修改到 getCurrentCar,例如資料結構改變,需從原本回傳陣列改成物件,例如:

const car = {
  getCurrentCar: () => car.carContent,
  // 改成下方
  // getCurrentCar: () => ({ car: car.carContent }),
}

小提醒:
這裡只是舉例,別真的修改哦!

就必須檢查所有用到 getCurrentCar 的測試案例,並將所有的 mockReturnValue 都改成新的回傳物件,

如果不改呢?這裡考大家,如果不改的話,那測試會出錯嗎?

答案是不會,測試仍然會 PASS,為什麼?

因為我們使用 Mock 把 getCurrentCar 和真實的程式碼切開,並利用 mockReturnValue 給它一個陣列 [],那不管 getCurrentCar 的程式碼如何改,測試時 getCurrentCar 都會回傳 [],而不會受真正的 getCurrentCar 影響。

上述的情況會因為 Mock 隔離掉邏輯,忽略了被修改而導致測試結果與事實不符的行為,就叫做「無效測試」,無效測試會讓測試結果一點意義都沒有。

因此如果在寫測試案例的時候,一律使用 jest.mock 隔離掉邏輯,那當依賴的邏輯或回傳值改變時,不是會增加維護測試案例的成本,就是讓測試無效,導致對測試結果沒有信心,也不會再想去碰它。

jest.spyOn()

為了解決上方的問題,使用 Spy 就是很重要的技巧,它也是製作 Mock,因此可以確認依賴執行的狀況,與 Mock 不同的是 Spy 會真的去執行邏輯,也就是說 Spy 的本體如果改變,又會導致受測 function 在執行時出錯,那在單元測試時也就不會 PASS。

Spy 的用法也很簡單,只需要指定 module 和要 SPy 的 Method 名稱給 jest.spyOn(),Jest 就會回傳一個 Spy,例如 module 是 car ,要 Spy 的 Method 是 getCurrentCar 就像這樣子處理,我們就能藉由它回傳的 getCurrentCarSpy 驗證執行狀態:

const getCurrentCarSpy = jest.spyOn(
  car, 'getCurrentCar',
);

而確認增加商品的案例測試就如下,首先一樣先執行 car.addProdToCar 新增一項商品,然後第一個斷言確認 Spy 是否有被執行,第二個斷言則是用 toEqual 確認新增完商品後的樣子是否和預期的一樣,name 和 count 應該沒有問題,id 的部分則是我們一開始替 uuid 設定了 mockReturnValue 為 '9999'。

 test('check_add_prod', () => {
   const newCar = car.addProdToCar('apple', 3);
   expect(getCurrentCarSpy).toHaveBeenCalled();
   expect(newCar).toEqual(
     [{ id: '9999', name: 'apple', count: 3 }],
   );
 });

測試結果:

如此一來,如果我到 src/util/car.js,中更改 getCurrentCar:

const car = {
  // getCurrentCar: () => car.carContent,
  // 改成下方
  getCurrentCar: () => ({ car: car.carContent }),
}

再重新執行測試就會錯誤,而不是替 getCurrentCar 用 mockReturnValue 回傳了一個合理值自爽過測試:

注意:
記得要把 getCurrentCar 改回來。

回歸單一行為

文章中用了兩個測試案例,來驗證 addProdToCar 的執行狀況,但是單元測試的最小單位應該是「單一行為」,以下將文中的兩個測試案例合併成一個,因此最後的測試案例會長這樣子:

import uuid from 'uuid/v1';
import car from '../src/utils/car';

jest.mock('uuid/v1');

const getCurrentCarSpy = jest.spyOn(
  car, 'getCurrentCar',
);

describe('Car', () => {
  beforeAll(() => {
    uuid.mockReturnValue('9999');
  });

  test('check_add_prod', () => {
    const newCar = car.addProdToCar('apple', 3);
    expect(uuid).toHaveBeenCalled();
    expect(getCurrentCarSpy).toHaveBeenCalled();
    expect(newCar).toEqual(
      [{ id: '9999', name: 'apple', count: 3 }],
    );
  });
});

測試結果:

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

PS:
哦對!因為我嫌礙眼,所以就把昨天的 index.test.js 砍掉了。


結尾

當初在釐清 Mock 的時候真的花了很多時間,而且對於 Mock 的種類也不太清楚,主要是因為身邊有可以一起討論的夥伴,成長起來就比較輕鬆,也不會走太多冤望路,希望我也可以成為你們的夥伴之一:)

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


上一篇
Day17 | 不知道對不對,就把邏輯通通測起來 feat. Jest
下一篇
Day19 | Component 的測試方式不私藏
系列文
在 React 生態圈內打滾的一年 feat. TypeScript 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
Askie Lin
iT邦新手 5 級 ‧ 2020-03-25 15:53:32

感謝,讓我很快時間搞懂 jest.mock() 和 jest.spyOn() 的使用時機點!

神Q超人 iT邦研究生 5 級 ‧ 2020-03-26 18:16:25 檢舉

這個我也和朋友互相討論很久,很開心幫助到妳,有任何想法也可以提出來討論!

3
kika
iT邦新手 1 級 ‧ 2021-07-04 18:54:03

最近在看神Q大的系列文章,寫得很讚!
但是想要補充一下~ 新的UUID version4的寫法變成如下:

import { v4 as uuidv4 } from 'uuid';
uuidv4();

看文章的各位可以參考一下!

另外下面的testing可以試試看這個:

describe('Car', () => {
  beforeAll(() => {
    uuidv4.mockImplementationOnce(() => ('9999'));
  });
  test('check_add_prod', () => {
    const newCar = car.addProdToCar('apple', 3);
    expect(uuidv4).toHaveBeenCalled();
    expect(getCurrentSpy).toHaveBeenCalled();
    expect(newCar).toEqual(
      [{ id: '9999', name: 'apple', count: 3 }],
    );
    console.log(newCar);
  });
});

我要留言

立即登入留言