上一講聊過了全端開發有哪些重點,有 Unit Testing、Integration Testing 可以確保開發時不會改會既有功能,還有 E2E 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]
。
看完了一個除法的例子,但好像有點簡單,沒什麼好測試的?
上面的例子是一個正向測試 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 要測些什麼,我們來看看 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 的機器上,getMonthDate()
由於抓的是機器的時區,就會把給定的 Timestamp 轉換成 10-02
這個日期,而非我們在台北的 10-03
,於是原本寫測試時能過的,換了台機器就壞掉了。
上述例子所說的情況就是 Side Effect,中文可以翻譯成邊際效應或是副作用。
意思就是當某段 Code 的時候,外部的環境會對其結果造成影響,造成同樣的 Input 會產生不同結果的情況,我們就稱這樣的 Function 有 Side Effect。
例如不同時區取得 Date String 的 Function 就有 Side Effect;取得亂數的 Function 有 Side Effect;又或是某個 Function 修改到全域變數。
*修改全域變數的 Side Effect
這些 Function 都會因為外部環境被影響,而造成 Side Effect。
首先在測試之前,如果被測試的 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 來模擬台北時區,這樣一來無論這個測試在哪邊跑,都可以得到相同的結果了。