iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 19
2

前言

本篇會提及的 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 依賴著 getCurrentCaruuid

這是一個很好的 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 斷言是否有執行過,如果有的話就沒問題,沒有的話代表 uuidaddProdToCar 中的依賴是有問題的。

測試結果如下:

既然是 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 依序回傳 123null,第三次後永遠回傳'abc'

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

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

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

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

就以 addProdToCar 內的另一個依賴 getCurrentCar 來說,雖然我們可以使用 mockReturnValuegetCurrentCar 設定合理的回傳值,讓 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 確認新增完商品後的樣子是否和預期的一樣,namecount 應該沒有問題,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 }),
}

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

注意:
記得要把 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. TypeScript31

尚未有邦友留言

立即登入留言