
德伊阿涅拉是海克力士的妻子,而兩個人最後的悲劇,其實是從一趟看起來再普通不過的渡河開始。
有一天,他們走到埃維諾斯河邊,遇到一名叫做涅索斯的半人馬。
涅索斯平常就在這裡幫旅人渡河,於是他表示:
「沒問題,我可以背你老婆過去。」
結果背到一半,這傢伙居然企圖侵犯德伊阿涅拉,海克力士聽見妻子呼救,立刻拿起弓箭,遠遠一箭射中了涅索斯。
問題是,海克力士的箭可不是普通的箭,他之前殺死九頭蛇之後,曾經把自己的箭浸過九頭蛇的毒液,所以這一箭射下去,涅索斯基本上是沒救了。
但涅索斯都快死了,居然還想再陰海克力士最後一次,他騙德伊阿涅拉說:
「把我傷口流出來的血留下來,以後如果海克力士不愛妳了,只要用這個,就能讓他的感情回到妳身上。」
德伊阿涅拉居然信了!於是她真的把涅索斯的血收了起來,而且這一放,就是很多年。
後來,海克力士帶回了一名叫做伊俄蕾的女人,德伊阿涅拉一看,開始擔心丈夫是不是變心了,這時她突然想起:
「欸,我不是還有那瓶愛情魔藥嗎?」
於是她把涅索斯的血塗在一件長袍上,再叫人送去給海克力士,讓他祭神的時候穿上,結果這哪是什麼愛情魔藥,涅索斯當年可是被沾過九頭蛇毒液的箭射死的,他留下來的血裡,早就混進了劇毒。
海克力士穿上長袍後,隨著祭祀的熱氣升高,毒開始侵蝕他的身體,衣服甚至緊緊黏在皮膚上,想扯下來,連皮肉都會一起被撕走。
直到消息傳回去,德伊阿涅拉才終於明白自己藏了這麼多年的,根本不是什麼挽回愛情的魔藥。
而是涅索斯留給海克力士的一份「延遲很多年」的復仇。
有些 Legacy Code 中,外表看似很乾淨,殊不知有很多我們沒看到的因果關係,而我們今天要做的,就是在動手前先把這些可能會影響的路徑畫出來。
我們直接看 今日範例,這是一個密室逃脫計時系統,產品要加一個「第一個提示免費」的機制,以前每用一次提示就加 60 秒罰時,現在要改成每個關卡的第一次提示不罰時,從第二次開始才算,這裡先簡化模型,假設一個 EscapeRoomSession 就代表一個關卡:
public class EscapeRoomSession
{
private int _hintsUsed = 0;
private int _penaltySeconds = 0;
private readonly IRoomConfig _config;
public EscapeRoomSession(IRoomConfig config) => _config = config;
public void UseHint()
{
// 要在這裡改:第一次提示不罰時
_hintsUsed++;
_penaltySeconds += _config.PenaltyPerHint;
}
private TimeSpan CalculateFinalTime(TimeSpan rawTime) =>
rawTime + TimeSpan.FromSeconds(_penaltySeconds);
public bool IsEligibleForLeaderboard() =>
_hintsUsed <= _config.MaxHintsForRanking;
public SessionReport GenerateReport(TimeSpan rawTime) =>
new SessionReport(
_hintsUsed,
_penaltySeconds,
CalculateFinalTime(rawTime)
);
}
其實這個 class 的設計不算差, EscapeRoomSession 把單一關卡的提示次數、罰時與排行榜資格收在同一個邊界裡,至少從這段程式碼看來,責任還算集中,外部也不能直接修改兩個 private field,測試只要注入 config 就能測,已經友善很多。
不過,很多時候封裝的好並不代表複雜程度低。
乍看之下其實我們只要在 _penaltySeconds 累加之前加個條件就好了,問題是 UseHint() 沒有回傳值,改完當下根本看不出結果,而在裡面做計算的兩個欄位又分別被不同 method 讀取,其中 _penaltySeconds 還會再經過 CalculateFinalTime() 影響最後時間,如果只盯著要改的那幾行,我們就很容易把「第一次免費」當成一個單純的 if,就很有可能沒發現成績與最終時間會跟著改變。
通常在 Legacy Code 中,一個欄位都被十幾個 method 使用,method 又繼續呼叫別的 method,追到第三層後腦袋裡通常就只剩一團線了。
我們真正要找的不只是「誰呼叫了 UseHint()」,而是它改變了哪些狀態,那些狀態又會讓哪些對外行為跟著不同。
所以先不急著挑測試點,也不是看到幾個相關 method 就全部各寫一支測試,我們先從真正要改的位置出發,把「這裡改了,接下來哪裡可能跟著變」先一層一層畫出來。
而我們可以畫 影響草圖(Effect Sketch) 來幫助我們對於複雜的路線視覺化,它不會像 UML 那麼正式,還蠻清晰易懂的。
UseHint() 是我們的改動點,影響草圖就從它先改變哪些狀態開始,再沿著「哪個 method 讀取這個狀態、這個 method 的回傳值又被誰使用」一路往外追。
第一步: UseHint() 會同時改動 _hintsUsed 和 _penaltySeconds,所以我們這樣畫:

接著,_hintsUsed 會影響 IsEligibleForLeaderboard() 與 GenerateReport() 的回傳結果:

_penaltySeconds 則除了直接出現在 GenerateReport(),也會先改變 CalculateFinalTime() 的結果,而這個結果又被 GenerateReport() 使用,所以草圖不只會有「欄位影響 method」,也會出現「method 的結果繼續影響另一個 method」的路徑:

於是我們在這個 Class 中的影響草圖就完成了!每個可能受到這個變更點影響的欄位,以及回傳結果可能受影響的 method,各自是一個節點,箭頭代表效果可能從前一個節點傳到下一個節點。
現在我們可以問自己一個問題:
「這個 class 的使用者,能從哪幾個 method 感受到這次改動的效果?」
CalculateFinalTime 是 private 無法呼叫,能感受到效果的只有兩個:
IsEligibleForLeaderboard():_hintsUsed 會從這裡影響排行榜資格GenerateReport() :_hintsUsed 和 _penaltySeconds 都會在這裡匯聚,CalculateFinalTime() 則參與其中草圖畫完後,我們就要來找能讓測試觀察到改動效果的位置,叫做 攔截點 (Interception Point)。
以這張圖來說,IsEligibleForLeaderboard() 與 GenerateReport() 都是攔截點,它們能從 class 外面被呼叫,也都能讓我們感受到 UseHint() 造成的部分效果。
影響草圖告訴我們「可能影響到哪裡」,攔截點則是在我們有地圖後,再決定「我們要從哪裡觀察它」,不過找到攔截點,並不代表每一個都能測到我們要的。
如果只選 IsEligibleForLeaderboard() 當測試點,可能會是這樣:
[Fact]
public void UseHint_第一次提示_應仍符合排行榜資格()
{
var session = new EscapeRoomSession(new RoomConfig(60, 3));
session.UseHint();
Assert.True(session.IsEligibleForLeaderboard());
}
測試當然會綠燈,但 _penaltySeconds 有沒有按新規則豁免?CalculateFinalTime() 算出來的最終時間對不對?從這裡就完全無從得知。
相較於IsEligibleForLeaderboard,GenerateReport() 剛好可以讓我們一次觀察提示次數、罰時與最終時間,與其貼著改動點只測得到一個變數,我們可以先從它下手。
這種影響路徑多條交會的攔截點,我們可以叫做 匯聚點 (Pinch Point),好的匯聚點不是離使用者最近、也不是單純最外層的 method,而是能用相對低的測試成本,偵測到夠大範圍的行為變化,如果把 影響草圖當成地鐵圖來看,匯聚點就像多條路線交會的大站。
但測試也不是看到匯聚點就往裡面衝,有些匯聚點雖然涵蓋很多路徑,每次測試卻要先準備替換掉一大堆外部服務,那就未必是好的第一個測試點。真正要平衡的是:
需求要的是第一次提示不罰時,之後每一次都要累積罰時,那麼測試如下:
[Theory]
[InlineData(1, 0)]
[InlineData(2, 60)]
[InlineData(3, 120)]
public void UseHint_第一次免費_之後每次累積罰時(
int hintCount,
int expectedPenalty)
{
var session = new EscapeRoomSession(new RoomConfig(60, 3));
var rawTime = TimeSpan.FromMinutes(30);
for (var i = 0; i < hintCount; i++)
session.UseHint();
var report = session.GenerateReport(rawTime);
Assert.Equal(hintCount, report.HintsUsed);
Assert.Equal(expectedPenalty, report.PenaltySeconds);
Assert.Equal(
rawTime + TimeSpan.FromSeconds(expectedPenalty),
report.FinalTime);
}
接著實作程式碼:
public void UseHint()
{
_hintsUsed++;
if (_hintsUsed > 1) return;
_penaltySeconds += _config.PenaltyPerHint;
}
如此我們便可以完成它,不過影響草圖告訴我們 IsEligibleForLeaderboard() 也可能受影響,我們不用每次看到箭頭就要去改 production code,但至少我們應該也要將它視為風險,再依需求與現有測試判斷需不需要再替它補測試。
不是測試越多越好,而是先把有限的資源先放在最有策略的位置。
匯聚點雖然很好找很直觀,但也很容易讓之後所有測試都想要測它,如果這個匯聚點流程很滿,後來每個小規則都得準備一堆測試替身,再跑過完整流程,這些測試即使執行起來不慢,寫起來仍然很重,失敗時也就很難馬上判斷是哪一層出問題。
這就是匯聚點所帶來的陷阱,它可以是快速取得保護的起點,但不能是所有測試永遠落腳的地方,除非他的測試成本夠低。
在《Working Effectively with Legacy Code》中,Michael Feathers 說過:
「想像這樣的場景,選中某區塊的程式碼,IDE 就能提供該區塊程式碼所可能影響的所有變數和方法的列表」。
這本書在 2004 年出版,二十幾年後,這個期待實現到什麼程度?
Rider 和 Visual Studio 都有工具能幫我們追呼叫關係與符號參考,只是兩邊提供的能力與操作方式不完全一樣。
我自己平常習慣用 Rider,所以下面先拿它來示範。
Rider 的數值追蹤 可以透過 Value Origin 以及 Value Destination,追蹤一個值可能從哪裡來、接著又可能流到哪裡。
我們直接拿前面的 _hintsUsed 來做一次,現在我們想知道的是「這個數值改變後,值可能會流去哪裡?」,所以要查的是 Value Destination:
_hintsUsed 的宣告或使用位置上
Inspect This

Value Destination


照這個例子,我們要從結果中核對的,就是 _hintsUsed 是否一路走到 IsEligibleForLeaderboard() 與 GenerateReport(),這些候選路徑攤開後,再把真正與這次需求有關的節點畫進影響草圖。
反過來,如果是想找「這個值到底從哪裡來?」,就改選 Value Origin,沿著結果往回找。
我自己是覺得還蠻好用的,如果要說有沒有工具,一定一大堆是我沒有用過的,但是即便再好用的工具,在幫我們提高效率的同時,我們也要有辨別的能力,不然只會錯的更快速而已。
近代 C# 提供了幾個有助於減少可變狀態的選項,例如 init、readonly record struct、ImmutableArray<T> / ImmutableList<T>...等等,在適合情境下,選用語言特性帶給我們的制約,能一定程度上去降低影響程度。
所以,這些都是寫 code 時可以考慮的選擇,例如如果資料建立後就不再被修改,至少能少掉不少「共享可變狀態」造成的傳播路徑,影響路徑往往也會跟著單純不少,不過要怎麼規範書寫,仍然非常吃團隊共識就是了。
今天做的事情,說穿了就是在改 code 之前,從真正的改動點出發,沿著狀態與 method 的結果往外追,畫成 影響草圖,接著在草圖上找出測試能觀察結果的 攔截點,如果多條路徑剛好在同一個低成本的位置匯合,那裡通常就是很值得優先保護的 匯聚點。
但匯聚點不是什麼終極寶穴,也不是所有測試都要搬去那裡排隊,它只是幫我們在保護範圍、測試成本與失敗定位之間找一個當下比較划算的位置。
省不了的事情就是 睜開眼睛看
接下來我們看:測試都綠燈,怎麼知道它真的有抓到 Bug?