「這段程式碼一碰就不知道哪裡會爆掉,所以沒辦法修。」
面對舊版程式碼時,大家應該都至少說過一次這句話吧。
先說結論。重構前唯一該做的事,就是先建立**「原封不動固定目前程式碼行為的測試」**。
在把程式碼整理得更漂亮之前,先釘死它目前到底做了什麼。
今天就照我實際替舊版程式碼加上測試時走過的順序,完整說明一次。只要遵守順序,就能大幅降低「修到一半爆掉」的事故。
為什麼測試要比重構優先?
先從重構的定義開始說起。
重構是在不改變外部可見行為的前提下,只改善內部結構的工作。
這裡的核心,是「不改變行為」這個約定。
要用什麼確認自己遵守了這個約定?就是測試。
沒有測試,我們就會憑著「好像沒變」的感覺部署。憑感覺部署付款程式碼……光想就讓人捏一把冷汗。
所以 Michael Feathers 在《有效處理遺留程式碼》中直接這樣定義:「舊版程式碼就是沒有測試的程式碼」。
判準不是新不新,而是有沒有安全網。
重構前檢查清單(重點總結)
為了忙碌的讀者,先整理順序。
- 先決定要修改程式碼的範圍(邊界)
- 加上記錄目前行為的特性化測試
- 確認測試是綠燈(成功)
- 到這時才以小單位進行重構
- 每個步驟都重新執行測試
只要依序完成這五件事,就已經成功一半。下面逐一說明。
特性化測試要怎麼加?
這是最常被問到的問題:「不知道行為,要寫什麼測試?」
這裡要反過來思考。不是知道正確答案才寫,而是直接把程式碼目前吐出的結果固定成正確答案。
這就叫做特性化測試(Characterization Test)。
方法意外地簡單。先隨便填一個值並執行測試。測試失敗時會告訴你「實際值是這個」,把那個值原樣貼回去就完成了。
// 1) 因為不知道實際回傳值,所以故意填入錯誤的值
@Test func 折扣計算_目前行為() {
let result = calcDiscount(user: user, cart: cart)
#expect(result == 0) // 失敗並告知實際值
}
// 2) 將失敗訊息中顯示的實際值(例: 1500)原樣固定
// #expect(result == 1500)
現在這個測試就成了守護「這段程式碼原本會回傳1500」這個事實的守門員。
之後重構時不小心得到1200,測試就會亮紅燈,立刻抓出問題。
現在先不判斷程式碼好不好。因為目標只是先固定它目前的樣子。
無法加上測試的程式碼該怎麼辦?
這才是舊版程式碼真正的高牆。當 DB、外部 API、目前時間等無法控制的東西埋在函式正中央,測試就跑不起來。
這時 Feathers 提出的**「接縫(Seam)」**概念就派得上用場。稍微切斷程式碼流程,建立可以塞入假資料的位置。
最安全的方法,是只把真正需要的那一行抽成函式參數。
例如函式內直接呼叫현재시간()時,只要改成透過引數接收它即可。這樣就能在測試中傳入想要的時間。
有一點要注意。為了加測試而做的這個最小修改,也要盡可能機械化且小心地完成。因為這個區域還沒有安全網。
常見問題(Q&A)
Q. 要先做到幾%的涵蓋率才能開始?
不需要涵蓋全部。只要包住現在馬上要修改的部分,也就是那個邊界,就足夠了。100%很多時候不是目標,而是陷阱。
Q. 沒時間加測試怎麼辦?
我也很了解那種壓力。這時只要先用特性化測試包住唯一要修改的函式,再開始動手。5分鐘就能避免最糟的事故。
Q. 測試寫得很亂也沒關係嗎?
沒關係。特性化測試就像臨時鷹架。重構完成、程式碼變乾淨後,再順手整理測試即可。
最後其實就是一個順序。先固定(測試)→再修改(重構)→再確認。
舊版程式碼可怕,不是因為程式碼很糟,而是因為沒有安全網。今天就挑一個函式加上特性化測試吧。從那之後,動手會輕鬆很多。為你加油!

