測試與程式碼品質

為舊版程式碼加上測試:重構前一定要做的事

對舊版程式碼來說,比重構更優先的是先用特性化測試固定目前的行為。本篇也以實務流程整理如何透過接縫,切開因相依性而無法測試的程式碼。

閱讀 3 分鐘
為舊版程式碼加上測試:重構前一定要做的事 封面圖

「這段程式碼一碰就不知道哪裡會爆掉,所以沒辦法修。」

面對舊版程式碼時,大家應該都至少說過一次這句話吧。

先說結論。重構前唯一該做的事,就是先建立**「原封不動固定目前程式碼行為的測試」**。

在把程式碼整理得更漂亮之前,先釘死它目前到底做了什麼。

今天就照我實際替舊版程式碼加上測試時走過的順序,完整說明一次。只要遵守順序,就能大幅降低「修到一半爆掉」的事故。


為什麼測試要比重構優先?

先從重構的定義開始說起。

重構是在不改變外部可見行為的前提下,只改善內部結構的工作。

這裡的核心,是「不改變行為」這個約定。

要用什麼確認自己遵守了這個約定?就是測試。

沒有測試,我們就會憑著「好像沒變」的感覺部署。憑感覺部署付款程式碼……光想就讓人捏一把冷汗。

所以 Michael Feathers 在《有效處理遺留程式碼》中直接這樣定義:「舊版程式碼就是沒有測試的程式碼」

判準不是新不新,而是有沒有安全網。


重構前檢查清單(重點總結)

為了忙碌的讀者,先整理順序。

  1. 先決定要修改程式碼的範圍(邊界)
  2. 加上記錄目前行為的特性化測試
  3. 確認測試是綠燈(成功)
  4. 到這時才以小單位進行重構
  5. 每個步驟都重新執行測試

只要依序完成這五件事,就已經成功一半。下面逐一說明。

顯示 PASSED 綠色測試列的程式碼編輯器,以及貼滿便利貼的螢幕
亮起一盞綠燈的瞬間,心裡就踏實多了

特性化測試要怎麼加?

這是最常被問到的問題:「不知道行為,要寫什麼測試?」

這裡要反過來思考。不是知道正確答案才寫,而是直接把程式碼目前吐出的結果固定成正確答案

這就叫做特性化測試(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. 測試寫得很亂也沒關係嗎?

沒關係。特性化測試就像臨時鷹架。重構完成、程式碼變乾淨後,再順手整理測試即可。

在顯示 TEST OK 勾勾的畫面前敲打機械式鍵盤的手
只包住一個函式,手感就會輕鬆很多

最後其實就是一個順序。先固定(測試)→再修改(重構)→再確認。

舊版程式碼可怕,不是因為程式碼很糟,而是因為沒有安全網。今天就挑一個函式加上特性化測試吧。從那之後,動手會輕鬆很多。為你加油!

延伸閱讀