iT邦幫忙

2024 iThome 鐵人賽

DAY 19
0
Software Development

全端實戰心法:小團隊的產品開發大小事系列 第 19

單元測試(一):Unit Tests 要寫些什麼?Side Effect 是怎麼樣的雷區?

  • 分享至 

  • xImage
  •  

上一講聊過了全端開發有哪些重點,有 Unit Testing、Integration Testing 可以確保開發時不會改會既有功能,還有 E2E Testing 來驗證交付的產品是否滿足需求。

今天我們來聊聊單元測試 Unit Testing,在前端和後端開發時有哪些注意事項。

單元測試 Unit Testing

前面聊測試金字塔時提到 Unit Testing 是位於金字塔底端的測試型態,因為撰寫的成本和覆蓋的範圍很低,所以比較能在有限的時間內產出最多的測試案例。

先來看看一個用 JavaScript 所寫的簡單 Unit Test 長成什麼樣子,以下是一個除法操作並算出商數(Quotient)及餘數(Remainder)的 Function 和其 Unit Test:

const divide = (a, b) => {
  const quotient = Math.floor(a / b);
  const remainder = a % b;
  return [quotient, remainder];
}

test('Should return correct quotient and remainder', () => {
  expect(divide(7, 3)).toEqual([2, 1]);
});

這樣的 Unit Test 結構很簡單,中心思想和所有測試一樣,都是

給定 Input,並得到期望的 Output

此處的 Input 就是 a = 7, b = 3,透過我們要驗證的 divide function,得到商數及餘數的 Output [2, 1]

Unit Tests 要寫些什麼?

看完了一個除法的例子,但好像有點簡單,沒什麼好測試的?

上面的例子是一個正向測試 Positive Testing,而除了正向測試外,我們也可以思考看看是不是有反向測試 Negative Testing 以及邊界測試 Boundary Testing 可以撰寫。

所謂反向測試,就是想辦法打爆這個功能,讓其出錯,並且驗證其是否有處理錯誤的能力。用同一個除法的例子來看:

test('Should throw error when divider is 0', () => {
  expect(() => divide(7, 0)).toThrowError();
});

我們都知道除法中除數(Divisor)不能為 0,因此這個測項就是一個預期產生錯誤的例子,因為我們給定的 Input Divisor 為 0,所以應該得不到結果,會拋出個錯誤。

而邊界測試,則是找出有沒有在 Input 資料範圍的極限,或是變化可能會大幅影響結果的地方調整 Input 的數值,例如:

test('Should return correct results', () => {
  expect(divide(7, 1)).toEqual([7, 0]); // Remainder == 0
  expect(divide(7, 8)).toEqual([0, 7]); // Quotient == 0
  expect(divide(7, 2)).toEqual([3, 1]); // Quotient and Remainder != 0
  expect(divide(7, Number.MAX_SAFE_INTEGER)).toEqual([0, 7]); // Divisor is large
  expect(divide(7, -1)).toEqual([-7, 0]); // Negative divisor
  expect(divide(-7, 1)).toEqual([-7, 0]); // Negative dividend
  expect(divide(-7, -1)).toEqual([7, 0]); // Negative divisor and dividend
});

我們在這個例子中將絕大部分除法可能有的 Boundaries 都羅列出來,例如 Output 中的商數和餘數是否為 0 的狀況、Input 中除數和被除數是正數還是負數等等。

所謂邊界測試就是把所有正向測試的例子寫出來,考慮盡可能多的情況。然而我們不太可能一次就把所有情況都列出來,因此在撰寫 Unit Tests 可以先把實際常用的 Cases 寫出來,當我們之後發現有新的 Case 時再來補上即可。

譬如說,這邊的除法 Function 就沒有考慮到測試非整數的情況,因為得出商數及餘數的條件是在 Input 為整數下才合理,如果之後要擴充成可以相容非整數,就要對 Function 進行調整。

理所當然的,改了 Function 就會讓部分現有的 Test Cases 壞掉,我們便會知道有哪些 Cases 因為需求改變而有錯誤,這也就是 Unit Tests 能給我們底氣修改需求和重構的原因。

Unit Tests 的注意事項:小心 Side Effect

聊了 Unit Tests 要測些什麼,我們來看看 Unit Testing 的雷區,Side Effect。

先來看個例子:

我們寫了一個 getMonthDate() 的 Function,輸入 13 碼的 Timestamp 並得到月份加上日期。

const getMonthDate = (timestamp) => {
  const date = new Date(timestamp);
  const month = `${date.getMonth() + 1}`.padStart(2, '0'); // getMonth() is from 0 to 11
  const day = `${date.getDate()}`.padStart(2, '0');
  return `${month}-${day}`;
}

test('Should return correct month and date', () => {
  const timestamp = 1727935200000; // 2024-10-03 06:00:00, UTC+8 time zone
  expect(getMonthDate(timestamp)).toBe('10-03');
});

其中測項裡面給定 Input 是一個在台北時區 10 月 3 號早上 6 點的 Timestamp,並且預期透過 getMonthDate() 來得到 10-03 這個日期。

這裡有什麼問題嗎?

如果我們跑測試的機器,都在同一個時區,也就是台北的時區則不會有什麼問題。

但若是我們的機器時區設定為 UTC+0,例如租用 AWS 或 GCP 的 VM,並且使用預設時區,就會得到測試的錯誤。

UTC+0 及 UTC+8 時區示意
*UTC+0 及 UTC+8 時區示意

我們的測試如果跑在 UTC+0 的機器上,getMonthDate() 由於抓的是機器的時區,就會把給定的 Timestamp 轉換成 10-02 這個日期,而非我們在台北的 10-03,於是原本寫測試時能過的,換了台機器就壞掉了。

何謂 Side Effect?

上述例子所說的情況就是 Side Effect,中文可以翻譯成邊際效應或是副作用。

意思就是當某段 Code 的時候,外部的環境會對其結果造成影響,造成同樣的 Input 會產生不同結果的情況,我們就稱這樣的 Function 有 Side Effect。

例如不同時區取得 Date String 的 Function 就有 Side Effect;取得亂數的 Function 有 Side Effect;又或是某個 Function 修改到全域變數。

修改全域變數的 Side Effect
*修改全域變數的 Side Effect

這些 Function 都會因為外部環境被影響,而造成 Side Effect。

如何測試有 Side Effect 的 Function?

首先在測試之前,如果被測試的 Function 是自己寫的,我們就應該要盡量避免 Side Effect,讓相同的 Input 能夠產生相同的 Output。

如果這個 Function 一定會有 Side Effect,例如讀取系統時間、修改全域變數,那麼我們在測試時就要預防被 Side Effect 的影響,最好的做法就是確保外部環境是我們能控制的。

那麼該如何確保外部環境能控制呢?我們可以模擬外部環境,或是取代外部依賴,來確保測試的穩定性。

例如剛剛的 getMonthDate() 這個 Function,我們是以 UTC+8 的時區來寫測試的,那麼我們就將測試時當下的環境改成 UTC+8 即可。

const timezoneMock = require('timezone-mock');

test('Should return correct month and date', () => {
  timezoneMock.register('Asia/Taipei');
  
  const timestamp = 1727935200000; // 2024-10-03 06:00:00, UTC+8 time zone
  expect(getMonthDate(timestamp)).toBe('10-03');

  timezoneMock.unregister();
});

由於我們給的 Timestamp 是台北時區的 2024-10-03 06:00:00,在預期 Output 為 10-03 的情況下,我們使用 timezone-mock 這個 Library 來模擬台北時區,這樣一來無論這個測試在哪邊跑,都可以得到相同的結果了。


上一篇
全端開發的自動化測試有哪些重點?Unit Testing + Integration Testing + E2E Testing
下一篇
單元測試(二):應對 Side Effect 的 Stub 及 Mock
系列文
全端實戰心法:小團隊的產品開發大小事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言