Working Effectively with Legacy Code:沒測試又不敢動的舊程式,要怎麼安全修改

Legacy Code(遺留程式碼):Michael Feathers 在《Working Effectively with Legacy Code》提出一個很實用的工作定義:缺少測試保護的程式碼,就屬於 legacy code。重點不在它有多舊,而在你修改它時,能不能快速知道原本行為有沒有被破壞。

很多舊系統其實還能改,只是大家已經不敢輕易動它。

網站、會員系統、課程平台、WordPress 外掛、內部後台,只要運作時間夠久,就可能累積大量條件判斷、第三方服務、特殊例外與前人留下來的做法。表面上功能還在跑,但每次要修一個小問題,都擔心另外三個地方一起壞掉。

Michael Feathers 在《Working Effectively with Legacy Code》處理的,就是這種情況:系統正在營運、沒有足夠測試、又不能停下來全部重寫時,怎麼安全地繼續改。

目錄 隱藏

Legacy Code 不一定是老程式,也不一定寫得很差

很多人聽到 legacy code,第一反應是「十年前的老系統」或「程式很亂」。Feathers 給了一個更容易採取行動的定義:沒有測試保護的程式碼。

這個定義的價值,在於把焦點從「程式看起來漂不漂亮」轉到「改動時有沒有快速回饋」,不需要先爭論程式到底算不算寫得差。

一段程式就算寫得很整齊,如果改完只能靠人工到處點、靠記憶確認,很難快速知道自己有沒有破壞既有行為。反過來,一段很老的程式如果有可靠測試保護,修改風險反而可能比較低。

最麻煩的是 Legacy Code Dilemma:要測試,卻得先改程式

遺留系統常遇到一個矛盾:

  • 要安全修改,最好先有測試。
  • 但程式現在耦合太深,根本放不進測試環境。
  • 要讓它能測,通常又得先動到原本程式。

這就是 Feathers 所說的 legacy code dilemma。

比較安全的解法,是把前置修改壓到最小,只做足以建立測試入口的依賴拆解;一旦有安全網,再開始功能修改與重構。

安全修改的核心流程:先取得回饋,再改行為

可以把方法整理成五個步驟:

  1. 找到 Change Point:你這次要修改哪一段程式。
  2. 找到 Test Point:在哪裡最容易觀察這個改動造成的效果。
  3. Break Dependencies:只拆掉阻礙測試的必要依賴。
  4. Characterization Tests:先把目前實際行為固定下來。
  5. Make the Change:最後才改功能,再逐步重構。

這個順序很重要。沒有測試時直接重構,等於一邊換引擎、一邊猜車子還能不能開。

Characterization Test:先記錄現在真的怎麼跑

一般單元測試常從需求出發,問「這段程式應該怎麼做」。Characterization Test(特徵化測試)先問另一個問題:

這段舊程式現在實際上到底怎麼做?

例如一個舊訂單函式,理論上你覺得退款後應該回傳 0,但實際執行卻回傳 -1。做特徵化測試時,不會立刻把 -1 改成你認為正確的答案,而是先把目前行為記錄下來。

因為其他模組可能早就依賴這個「看起來不合理」的結果。先固定現況,再把 bug 修正當成另一個明確變更,風險會低很多。

特徵化測試的任務,是建立變更警報器

這也是特徵化測試和「補一套完美測試」最大的差別。

你不需要第一天就替整個舊系統補齊所有測試。比較實際的策略是:每次要改哪裡,就先保護那一條路徑。

今天修改付款,就先建立付款路徑的測試;下次修改會員權限,再補會員權限的安全網。

這樣測試會沿著真實變更慢慢長出來,比等一個「以後專門花三個月補測試」的計畫更有機會真的完成。

先分清楚 Sensing 和 Separation,才知道依賴要拆到哪裡

Feathers 把依賴問題拆成兩種。Separation 是「程式根本沒辦法被放進測試環境」,例如建構物件時一定會連正式資料庫;Sensing 則是「程式可以跑,但測試看不到結果」,例如結果只被寫進某個外部系統。

這兩種問題需要的處理不同。Separation 要先讓危險依賴可以被替換;Sensing 則要找到能觀察結果的接點。先判斷自己卡在哪一種,才能避免為了測一個方法,把整個系統拆得太深。

Seam:找到不必大改就能替換行為的位置

Feathers 很重要的一個概念是 Seam(接縫)。簡單說,就是你可以在不改那段核心程式的情況下,從別處替換它依賴的行為。

例如一段付款程式直接自己建立資料庫連線,測試時就很難控制;如果資料庫可以從建構子傳入,正式環境傳真實資料庫,測試環境則傳假的資料來源,這裡就形成了一個可測試的 seam。

常見可以形成 seam 的位置包括:

  • 建構子參數。
  • 方法參數。
  • 介面。
  • 可替換的服務物件。
  • 模組匯入。
  • 時間、設定與外部 API 的抽象層。

重點是找到最小修改就能讓測試接進來的位置,不需要為了測試到處加入抽象。

Dependency Breaking:先拆測試阻礙,不要順便大整理

舊程式常見測不起來的原因包括:

  • 建構物件時就直接連資料庫。
  • 到處使用 static 或 singleton。
  • 方法裡直接呼叫第三方 API。
  • 一個方法同時做查詢、計算、寫入與寄信。
  • 框架物件一路滲進核心邏輯。

Feathers 提供很多 dependency-breaking techniques(依賴拆解技巧),例如 Parameterize Constructor、Extract Interface、Subclass and Override Method。

這些技巧的共同精神,是只做足以建立測試環境的最小改動;這時候還不適合順便大幅整理結構。因為這時候安全網還沒建立,改得越多,未知風險越大。

行為修改和結構整理,要分兩步做

最危險的做法之一,是在同一個修改裡同時改變商業規則、重新命名、拆方法、搬檔案、換資料存取方式。

如果最後測試失敗,你很難知道問題是功能改錯,還是結構調整時不小心改到行為。

更安全的順序是:

結構性調整 → 測試仍通過 → 行為修改 → 測試通過。

一次只做一件事,會讓問題更容易定位。

真的完全測不起來時,可以用 Sprout 或 Wrap

有些舊系統短期內就是無法放進測試環境。這時 Feathers 提出 Sprout(萌芽)與 Wrap(包覆)技巧。

Sprout Method / Sprout Class:不要把新邏輯塞進那個巨大、無法測試的舊方法,而是把新功能寫成一個新的、可測試的方法或類別,再讓舊程式只多一行呼叫。

Wrap Method / Wrap Class:如果你要在舊行為前後加入通知、紀錄、驗證等功能,可以包在外層,而不是深入舊邏輯裡大改。

這些做法屬於戰術:先把這次變更的風險控制住,再逐步改善周圍程式,不必把它們當成終極架構。

Golden Master:面對複雜輸出時,先把整體結果固定

如果舊程式會產生很長的報表、JSON、PDF 資料、匯出檔或複雜 HTML,一開始很難逐欄寫測試。

這時可以使用 Golden Master(黃金樣本)的概念:先保存目前一組代表性輸出的完整結果,修改後再比較差異。

它先建立一個低成本警報器,讓你知道「這次改動是不是連不該動的地方都改了」;之後仍可以逐步補成更細緻的測試。

Scratch Refactoring:先用重構理解舊程式,再全部還原

有些程式不是立刻要修改,而是你根本看不懂。Feathers 提到一種很實用的理解方法:先在暫時分支裡大膽 Extract Method、重新命名、拆類別,把原本結構整理到自己看得懂。

這一輪修改的目的只是學習,不一定要提交。理解完之後把變更全部還原,再根據剛剛得到的結構認知設計正式、安全的修改路徑。

這比一邊讀、一邊猜、一邊直接改正式程式更安全,因為你可以把「理解系統」和「修改系統」分成兩件事。

Effect Sketch:先畫出改動會影響哪裡

有時最大的風險,是你根本不知道這段程式會影響哪裡。

Feathers 建議用 Effect Sketch(影響草圖)追蹤:這個方法改變什麼資料、哪些地方會讀這些資料、結果最後會出現在畫面、資料庫、Email 還是 API,以及哪一個位置可以一次觀察很多上游行為。

如果能找到一個 Pinch Point(收斂點),少數幾個測試就可能覆蓋很大一片行為。這比一開始從第一行讀到最後一行更有效率。

時間真的很趕時,先做最小安全版本

真實專案常常不會給你兩週先整理測試。這時候不必把整套方法做滿,至少守住三件事:

  1. 把這次變更範圍縮到最小,不順便整理旁邊程式。
  2. 在最接近變更效果的位置留下一個可重複的驗證。
  3. 修改完成後,讓下一次再碰同一區域時比這次更容易測。

如果某個區域第一次修改時只能用 sprout 或手動黃金樣本保護,第二次再碰到它,就值得把這裡正式放進測試環境。這樣技術債會沿著實際需求逐步下降,不必等待一次大型專案才開始處理。

為什麼 Big-Bang Rewrite 常常不是第一選擇?

遇到很亂的舊系統,很自然會想:「乾脆重寫比較快。」

問題是老系統裡通常藏著很多沒有被文件記錄的規則,例如特殊客戶例外、歷史資料格式、第三方服務怪異限制、退款與權限邊界,以及多年來修過的極端案例。

全部重寫時,這些知識很容易一起丟掉。

所以 Feathers 的方法更偏向「cover and modify」:沿著實際要改的地方補安全網,一點一點把可測試範圍擴大。

如果你的系統已經同時有速度、耦合與可讀性問題,也可以延伸看系統愈做愈慢、愈改愈亂?架構最佳化應該先從哪裡開始。

把方法放到 WordPress、外掛與 AI 生成程式也一樣適用

現在很多網站與內部工具是快速拼出來的:WordPress 外掛、Code Snippet、API 串接、AI 協助產生的 PHP、JavaScript、Python。

程式能跑,不代表之後就能安全改。

如果某個片段已經處理訂單、會員、付款或內容權限,下一次再改之前,可以先確認目前哪些輸入會得到哪些結果、建立最小測試或可重複驗證方式、把最難測的外部依賴隔開,再只修改一個行為。

這比每次都叫 AI「整段重寫得更漂亮」安全得多。

一套可以直接採用的 Legacy Code 修改流程

步驟 要做什麼
1. 定義變更 只寫清楚這次要改哪一個行為
2. 找 Change Point 定位這次要改的程式
3. 找 Test Point 找最容易觀察結果的位置
4. 最小拆依賴 只做到能放進測試環境
5. Characterize 把目前行為固定下來
6. 修改 只改這次需要的行為
7. Refactor 測試綠燈後再整理結構
8. 留下安全網 讓下次改同一區域成本更低

什麼情況不用把整套流程做得很重?

不是每次改字串、CSS 或完全獨立的小功能,都需要做完整 legacy-code 儀式。

這套方法特別適合:

  • 修改會影響付款、會員、權限與資料的程式。
  • 大家都不敢碰的舊模組。
  • 沒有測試、又曾經改一處壞三處的程式。
  • 要加入新功能,但不確定現有行為的區域。
  • 無法直接大改或停機重寫的營運系統。

風險低、可快速回復的修改,可以使用更輕量的驗證;風險越高,越值得先取得回饋再改。

常見問題

Legacy Code 就是老舊程式嗎?

Feathers 的工作定義更重視是否有測試保護,而不是程式年齡。新寫的程式如果完全沒有測試,也可能很快成為難以安全修改的 legacy code。

Characterization Test 會不會把 bug 也固定下來?

有可能,這正是它的用途之一:先確認目前實際行為,再把 bug 修正當成獨立變更。否則你可能同時改掉其他人已依賴的行為。

是不是要先把整個舊系統補完測試才能開發?

不用。比較實際的做法是沿著這次要修改的路徑補測試,讓覆蓋率隨真實變更逐步成長。

Seam 和 Dependency Injection 是同一件事嗎?

不完全相同。Dependency Injection 是建立 seam 的常見方法之一;seam 的概念更廣,重點是存在一個可從外部替換行為的接點。

舊系統太亂,直接重寫是不是比較快?

有時重寫有必要,但不能只因程式看起來亂就假設重寫風險更低。原系統可能累積大量隱性規則,最好先盤點行為與替換風險。

AI 可以直接幫我把 Legacy Code 重構掉嗎?

AI 可以協助理解、產生測試、提出重構方向與執行局部修改,但安全順序仍然重要:先固定既有行為、縮小變更範圍,再讓 AI 幫你改,會比直接整段重寫可靠。

安全修改 Legacy Code 的關鍵,是讓每次變更都比上一次更容易

《Working Effectively with Legacy Code》最有價值的地方,是提供一套能在真實營運系統裡逐步前進的方法,不必先把所有舊程式一次洗乾淨。

先找變更點,再找測試點;必要時只拆最小依賴,用 characterization test 固定目前行為,最後才修改功能。每次動一個區域,就替那個區域多留下一點安全網。

當安全網慢慢增加,原本「誰都不敢碰」的程式,就會逐步變成可以理解、可以驗證,也可以繼續演進的系統。

參考資料

Michael C. Feathers:Working Effectively with Legacy Code,Pearson/Prentice Hall,2004。
Martin Fowler:Refactoring: Improving the Design of Existing Code。

元氣站長
元氣站長

經營網路事業,賺取持續收入!
網路賺錢修煉,成就理想人生!