測試無效輸入:從字串解析、NaN 判斷到 toThrow,練習堵住程式碼裡容易被忽略的邏輯漏洞與盲點
延續前一篇提到的 unhappy path,這篇實際動手把加法函式擴充成能處理各種奇怪輸入的版本,一步步練習「發現漏洞、寫測試、補實作」的循環。
先讓全班決定:加法函式該怎麼處理字串輸入
Steve 讓學員自己決定一個設計問題:如果傳進來的是字串,add 函式該怎麼辦?照 JavaScript 原生的加法運算子,字串會被直接串接,例如 '1' + '1' 會得到 '11',而不是數學上的加法結果。有學員提議應該讓它做字串串接,但討論後,全班決定讓這個 add 函式比原生的加法運算子更聰明一點:把看起來像數字的字串,先解析成數字再相加。
把字串解析成數字:這次要修兩次才過
先寫下這個期待中的行為:
javascript
it('should parse strings into numbers', () => {
expect(add('1', '1')).toBe(2);
});這時候測試是紅燈,錯誤訊息第一次真正有意義:預期得到 2,卻收到 '11',因為兩個字串被直接串接了。Steve 先加上判斷,把第一個參數轉成數字:
javascript
export default function add(a, b) {
if (typeof a === 'string') {
a = Number(a);
}
return a + b;
}存檔後測試依然失敗,結果還是 '11',因為第二個參數還是字串。把同樣的判斷加到 b 上之後,測試才真正轉綠:
javascript
export default function add(a, b) {
if (typeof a === 'string') {
a = Number(a);
}
if (typeof b === 'string') {
b = Number(b);
}
return a + b;
}遇到無法解析的字串:先讓它失敗
字串不一定都能被解析成數字,例如傳進一個 'potato'。這時候期待的行為是讓函式直接生氣、拋出錯誤,而不是安靜地算出一個 NaN。但在還沒實作拋出錯誤的邏輯之前,Steve 先讓大家看看它現在會怎麼失敗,得到的結果是 NaN,不符合預期,測試自然是紅燈。
toThrow 的技巧:為什麼要包一層匿名函式
要測試「這段程式碼應該拋出錯誤」,不能直接把呼叫寫在 expect 裡,因為那樣一旦真的拋出錯誤,會直接讓整個測試函式炸掉,而不是被測試框架捕捉當作一次驗證。正確的寫法是把呼叫包進一個匿名函式,讓 expect 接手控制什麼時候真正呼叫它:
javascript
it('should get mad if you give it a string that cannot be parsed into a number', () => {
expect(() => add(2, 'potato')).toThrow();
});一開始這個測試也是紅燈,因為函式還沒有任何拋出錯誤的邏輯,expect 收到的是「這個函式沒有拋出錯誤」的結果,跟預期不符。這種紅燈不是壞事,只是代表還在往目標前進的路上。
抓出邏輯裡的漏洞:只檢查一個參數不夠
接著補上判斷:如果字串解析後變成 NaN,就拋出錯誤:
javascript
if (isNaN(b)) {
throw new Error('...');
}測試通過了,但這正是需要練習「找邏輯漏洞」的地方:只檢查了 b,那 a 呢?如果把兩個參數對調,讓不能解析的字串換成第一個參數,剛剛那個判斷完全抓不到。這時候可以新增一個測試,故意把順序換過來驗證這個情境,確認它一樣會失敗,再把判斷擴大成同時檢查兩個參數:
javascript
if (isNaN(a) || isNaN(b)) {
throw new Error('...');
}對抗心態:假裝自己是最偷懶的實作者
Steve 提到一種結對程式設計的練習遊戲:一個人負責寫測試,另一個人負責用最偷懶、最荒謬的方式讓測試通過。如果測試套件裡只有第一個「加法應該等於某個值」的測試,對方完全可以寫一個永遠回傳固定值的函式來蒙混過關,測試一樣是綠燈。破解這種偷懶實作的方法,就是不斷補上新的測試案例,逼實作者沒辦法再靠寫死答案矇混,最終逼出一個真正正確的實作。這也呼應了前面反覆強調的:光是盯著程式碼,不斷設想「這裡還有哪裡可能出錯」,本身就是測試工作很重要的一部分,不完全是動手寫程式碼的技巧問題。
只驗證「有拋出錯誤」還不夠:也要斷言錯誤訊息
toThrow() 不帶任何參數時,只要函式拋出任何錯誤,測試就會通過,不管是不是預期中的那個錯誤。這帶來一個比 flaky test(隨機失敗、原因不明的測試)更危險的問題:一個明明不該通過、卻通過了的測試,會讓人誤以為程式碼是對的。舉例來說,一個單純的錯字或跟預期無關的例外,都可能讓 toThrow() 誤判成功。
解法是進一步斷言拋出的錯誤訊息內容,確認拋出的確實是預期中的那個錯誤,而不是隨便一個錯誤:
javascript
expect(() => add(2, 'potato')).toThrow('...');toThrow 可以整段比對,也可以只比對錯誤訊息裡的一部分。完全比對的好處是精準,但代價是只要之後想調整錯誤訊息的用字,就得跟著改測試;只比對關鍵片段則比較有彈性,不用在意訊息裡不重要的細節(例如包含時間戳記之類會變動的內容),只要確認關鍵資訊有出現在錯誤訊息裡就好。
案例累積起來,才是信心的來源
隨著案例一個個補上,測試套件開始能在很短的時間內(多數情況下不到一秒),重新驗證程式碼在每一種已知的邊界情境下是否還照預期運作。改動程式碼後跑一次幾千個測試、幾秒內全部通過,雖然不代表絕對沒有犯錯,但確實大幅降低了「我是不是把什麼東西改壞了」的疑慮。
至於哪些邊界案例該寫成測試,某種程度上也是主觀判斷。像 true + true 在 JavaScript 裡會等於 2,這種行為要接受、還是要求函式對非數字型別直接報錯,沒有標準答案,重點在於這個函式實際上想達成什麼目的。
Q&A:驗證邏輯需要另外寫測試嗎?
有學員(Female Student 2)問,如果程式碼裡已經對使用者輸入做過驗證,測試案例裡還需要重複驗證那些邏輯嗎?界線該怎麼抓?
Steve 的答案是:驗證邏輯本身就該寫測試。即使這段驗證邏輯深藏在某個元件裡,例如一個登入表單裡負責檢查密碼欄位的某一行程式碼,也值得獨立出來寫測試。他分享團隊裡不同工程師的風格差異:有人習慣把所有驗證邏輯直接寫在元件裡,他自己則傾向走另一個極端,把邏輯拆進一個工具函式目錄裡,例如把「密碼要包含大寫字母」「密碼要包含數字」各自拆成獨立、能單獨測試的小函式,再組合成完整的密碼驗證邏輯。
這樣拆分的好處,在程式碼庫變大之後特別明顯:當測試套件裡有某個測試失敗,如果失敗的是一個命名清楚、只做一件事的小函式(例如專門檢查字串裡有沒有大寫字母),馬上就能知道問題出在哪裡,不需要在一大團驗證邏輯裡慢慢排查。
複習
在 JavaScript 裡,怎麼把字串解析成數字?
可以用型別轉換的方法,例如 Number()、parseInt() 或 parseFloat(),把字串形式的數字轉換成實際的數值
在 JavaScript 裡,把兩個字串相加時可能會遇到什麼問題?
JavaScript 會把字串直接串接起來,而不是進行數學上的加法,所以 '1' + '1' 的結果會是 '11',而不是 2
在 JavaScript 裡,要怎麼判斷一個值是不是「非數字」?
可以使用 isNaN() 函式來檢查一個值是不是「非數字」。這在嘗試把字串解析成數字之後特別好用,如果那個值沒辦法被轉換成有效的數字(例如解析 'potato' 的情況),isNaN(value) 就會回傳 true
進行加法運算時,有哪些可能造成問題的輸入?
像 'potato' 這種無法轉成數字的字串、布林值(true/false)、undefined、null,以及其他非數字型別,都可能在做加法時造成非預期的行為
為什麼替函式的邊界案例撰寫測試很重要?
測試邊界案例有助於及早發現潛在問題、確保程式碼的穩健性、建立對程式碼運作的信心,並在開發早期就抓出非預期的輸入情境
小測驗
把驗證邏輯拆成更小的函式,有什麼好處?
更容易找出測試失敗的根本原因處理函式裡可能出現的無效輸入時,該考慮什麼?
針對不同類型的輸入,明確定義該有的預期行為測試一個應該拋出錯誤的函式時,為什麼要把函式呼叫包在一個匿名函式裡?
讓測試框架能控制函式何時真正執行,並在框架自己的錯誤處理邏輯裡捕捉這個錯誤撰寫錯誤處理的測試時,斷言具體的錯誤訊息有什麼關鍵好處?
避免測試在函式拋出任何錯誤(而不是真正預期的那個錯誤)時,也被誤判為通過一個「不該通過卻通過了」的測試,危險在哪裡?
會讓人誤以為程式碼是正確的,即使實際上並非如此此文章是 FrontendMasters 上的 Testing Fundamentals 課程筆記
