
彌達斯是佛律吉亞的國王。有一天,佛律吉亞人發現了一位步履蹣跚、渾身酒氣的老人,那位老人正是酒神戴奧尼索斯的養父—西勒諾斯。
彌達斯認出了他,不但好好招待了他十天,到了第十一天,還親自將他平安送回戴奧尼索斯身邊。
酒神非常感激,決定實現彌達斯的一個願望,彌達斯幾乎沒有猶豫便說:
我希望碰到的每一樣東西,都能變成黃金
戴奧尼索斯答應了他。
一開始,一切都很美好,彌達斯碰了一根橡木枝,枝條立刻泛起金光,碰了一塊石頭,石頭也變成了黃金,就連土塊、麥穗和蘋果也不例外。
直到侍從端來晚餐。
他伸手拿起麵包,麵包立刻在指間變硬,入口只剩下金屬的重量,酒才碰到嘴唇,也變成了黃金。
彌達斯以為自己只是多獲得了一種能力,卻沒有先想清楚
哪些東西不該跟著改變?
我們修改 Legacy Code 時,也很容易踩進同一個坑,我們只看見這次想得到什麼,卻沒有先弄清楚,原本有哪些行為絕對不能跟著改變。
Michael Feathers 在《Working Effectively with Legacy Code》中,將改動軟體的主要理由分成四種:
這四件事看起來都叫作「改 code」,本質卻不相同,真正危險的地方在於:
我們嘴上說自己在做其中一件事,實際上卻不小心做了另一件事
最常見的情況,就是以為自己只是在重構,實際上卻悄悄改變了系統行為。
看到一段舊 code 時,我們通常很快就能察覺哪裡不對勁:變數名稱很奇怪、method 太長、條件判斷一層包著一層。
看久了,心裡難免開始發癢:
這裡稍微整理一下,之後修改應該會容易很多吧?那就先改個變數名稱,這最簡單,成本也最低。
Legacy Code 最可怕的地方,不只是它很醜,而是它往往沒有足夠的安全網,當這套系統還在養活公司,也養活我們時,最穩妥的策略通常不是看到哪裡醜就改哪裡。
每當「順手整理一下」的念頭出現時,可以先停下來提醒一下自己:
沒有需求,就先不要急著動。
會妨礙這次需求修改或 bug 修復的地方,才是眼前最需要處理的地方。
這不是叫大家永遠不要重構,而是要先弄清楚:
我現在做的改動,究竟是為了完成哪一種目的?
因此,在缺乏測試保護時,真正危險的往往不是「我不會寫程式」,而是「這只是一個小改動,應該不會出事」的心態。
來看一段在系統中很常見的 code:
public class InventoryRecord
{
public decimal CalculateTotalPrice(
int quantity,
decimal unitPrice,
bool isVip)
{
decimal total = unitPrice * quantity;
// 大量購買折扣
if (quantity >= 100)
{
total *= 0.95m;
}
// VIP 折抵 50 元
if (isVip)
{
total -= 50m;
}
return total;
}
}
帶有一點潔癖的小張看了,心想:「這個 method 有魔法數字,也可以寫得更簡潔,我就順手整理一下吧。」
於是他改成這樣:
public class InventoryRecord
{
private const decimal BulkDiscountRate = 0.95m;
private const decimal VipDiscount = 50m;
private const int BulkThreshold = 100;
public decimal CalculateTotalPrice(
int quantity,
decimal unitPrice,
bool isVip)
{
var baseTotal = unitPrice * quantity;
var afterBulkDiscount = quantity > BulkThreshold
? baseTotal * BulkDiscountRate
: baseTotal;
return isVip
? afterBulkDiscount - VipDiscount
: afterBulkDiscount;
}
}
看起來好多了?程式碼更短、魔法數字也不見了。
直到 PM 又來找小張:
「客戶反映兩個問題。」
「第一,為什麼你改完以後,剛好買 100 件反而沒有折扣?」
「第二,週年慶要新增八折優惠。」
小張打開程式碼一看,傻住了。
原始程式碼用的是 quantity >= 100,重構後卻不小心變成了 quantity > BulkThreshold,小張原本以為自己只是在「改善設計」,實際上卻改變了行為:
如果沒有測試,這個差異就這樣輕易地溜了過去。
更麻煩的是,當大家回頭追問「原本到底應該怎麼算」時,PM 不一定記得,客戶也不一定記得。最後,有人翻 Git、有人翻需求單、有人憑印象回答,小張只好默默補上一段註解:
// 2026/05/31 老闆說買滿 100 件就要有折扣
我甚至看過有人直接在註解裡開罵,罵得還很難聽 XD
但註解並不是可靠的安全網,它可能過期、可能被忽略,也可能在下次修改時跟著被改掉,更重要的是,它不會在行為被破壞時告訴我們。
修好滿 100 件的問題後,小張接著加入週年慶優惠:
public class InventoryRecord
{
private const decimal BulkDiscountRate = 0.95m;
private const decimal VipDiscount = 50m;
private const decimal AnniversaryDiscountRate = 0.8m;
private const int BulkThreshold = 100;
public decimal CalculateTotalPrice(
int quantity,
decimal unitPrice,
bool isVip,
bool isAnniversarySale)
{
var baseTotal = unitPrice * quantity;
var afterBulkDiscount = quantity >= BulkThreshold
? baseTotal * BulkDiscountRate
: baseTotal;
if (isVip)
{
return afterBulkDiscount - VipDiscount;
}
if (isAnniversarySale)
{
return afterBulkDiscount * AnniversaryDiscountRate;
}
return afterBulkDiscount;
}
}
可行嗎?乍看之下可行:一般客戶在週年慶打八折,VIP 客戶折抵 50 元,滿 100 件也恢復了 95 折。
小張盯著螢幕確認五遍,說了一句「看起來是對的」,然後就 push 上去了……
問題其實藏在這裡:
if (isVip) return afterBulkDiscount - VipDiscount;
if (isAnniversarySale) return afterBulkDiscount * AnniversaryDiscountRate;
如果 VIP 客戶在週年慶購物,他能不能享有週年慶折扣?
按照小張的 code,答案是不能,只要 isVip == true,第一個 if 就會直接 return,後面的週年慶判斷根本不會執行。
問題是,這真的是錯的嗎?我們其實還不知道。
PM 當初只說「週年慶打八折」,沒有人討論 VIP 與週年慶同時成立時應該怎麼計算,但這不代表情境不存在,VIP 客戶當然也可能在週年慶買東西,因此開發前至少應該先確認:
小張不是不聰明,他只是沒有在動手前,逼自己把這些組合與邊界情境想清楚。
讓我們把鏡頭倒回第一次重構之前,如果小張先把目前已確認、必須保留的行為寫成測試,事情可能就會完全不同:
[Theory]
[InlineData(99, 100, false, 9900)]
[InlineData(100, 100, false, 9500)]
[InlineData(101, 100, false, 9595)]
[InlineData(100, 100, true, 9450)]
public void CalculateTotalPrice_依大量購買與VIP規則計算總價(
int quantity,
int unitPrice,
bool isVip,
decimal expected)
{
var record = new InventoryRecord();
var result = record.CalculateTotalPrice(
quantity,
unitPrice,
isVip);
Assert.Equal(expected, result);
}
先簡單說明一下這段 code。
xUnit 是 .NET 常見的測試框架之一,另外兩個常聽到的名字是 NUnit 和 MSTest,這個系列後續的範例都會使用 xUnit。
在 xUnit 裡,標記 [Fact] 的 method 代表一個測試案例,[Theory] 則讓同一段測試邏輯搭配多組資料執行,每一行 [InlineData] 都是一組測試資料,裡面的值會依序傳入 method 的參數。
所以上面雖然只有一個測試 method,測試執行實際上會跑四個案例:
(99, 100, false, 9900):99 件,尚未達到門檻,總價 9900 元。(100, 100, false, 9500):剛好 100 件,套用 95 折,總價 9500 元。(101, 100, false, 9595):超過門檻,總價 9595 元。(100, 100, true, 9450):滿 100 件且為 VIP,9500 元再折抵 50 元,總價 9450 元。這個測試的結構其實很單純:
InventoryRecord。CalculateTotalPrice。Assert.Equal 比對實際結果與預期結果。當小張把 >= 改成 > 時,quantity == 100 的兩組案例就會立刻亮紅燈。
這不代表測試可以自動判斷商業規則正不正確,它保護的只是我們已經確認過、寫進案例裡的行為,至少從此以後,團隊不必只依靠記憶、Git commit 訊息,或可能隨時過期的時間戳記註解。
測試帶給我們最重要的價值,就是:
把「已確認應該怎麼運作」變成可以反覆執行的程式碼
再回頭看週年慶需求。
如果小張先加入 isAnniversarySale 參數,卻沒有急著決定折扣分支,而是先留下一個等待規則確認的測試草稿,事情可能會變成這樣:
[Fact(Skip = "等待 PM 確認 VIP 與週年慶折扣的組合規則")]
public void CalculateTotalPrice_VIP在週年慶_應依確認後的組合規則計算()
{
var record = new InventoryRecord();
var result = record.CalculateTotalPrice(
quantity: 1,
unitPrice: 1000m,
isVip: true,
isAnniversarySale: true);
// 必須先確認規則,才能寫下正確的 expected。
}
Skip 會告訴測試框架暫時不要執行這個案例,它在這裡只是一個短期、醒目的待辦事項,規則確認後,應該立刻移除 Skip,並補上明確的驗證。
光是為了填入正確的 expected,小張就會被迫去問 PM:
VIP 在週年慶期間,是折抵 50 元、打八折,還是兩個都算?如果兩個都算,要先折 50 元再打八折,還是先打八折再折 50 元?滿 100 件的 95 折又能不能一起疊加?
這些問題在開發前問,可能只需要五分鐘,阿如果是上線後才發現,可能就是幾小時的緊急修復、一次向客戶道歉,以及下個月小張再次出現在同一個 method 裡。
所以,當規格還沒說清楚時,請先在腦中告訴自己:
我們現在根本還沒有答案
今天最重要的重點不是「不要重構」,而是每次改動之前,都要先弄清楚自己究竟在做哪一種改動。
小張原本以為自己只是在改善設計,卻沒有先用測試保護既有行為,一個三元運算式看起來比 if 更簡潔(實際上也沒有),但他在改寫的過程中,悄悄把 >= 變成了 >,而且沒有人察覺。
彌達斯只看見自己想獲得的能力,卻沒有先想清楚哪些東西不能跟著變成黃金,小張其實也差不多,他只看見程式碼可以變得更乾淨,卻沒有先確認哪些行為必須保持原樣。
被這次教訓修理過後,小張總算學乖了,下次 PM 拿著新需求出現時,他的第一個念頭就會是:
「我這次先把測試補上,再動 code。」
很好,有進步。
不過,「寫測試」三個字說出口很容易,真正動手時,問題馬上會一個個冒出來:
測試要寫成什麼樣子才算數?
把整套流程連同資料庫一起跑過一遍嗎?
如果跑一輪就要好幾分鐘,那還算是我們想要的單元測試嗎?
明天,我們繼續來看:什麼才算是真正的單元測試?