iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 14

Day 14 -「涅索斯愛情魔藥」改一個地方,到底會波及哪裡?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260814/20182564wxxzhiztsw.png

德伊阿涅拉是海克力士的妻子,而兩個人最後的悲劇,其實是從一趟看起來再普通不過的渡河開始。

有一天,他們走到埃維諾斯河邊,遇到一名叫做涅索斯的半人馬。

涅索斯平常就在這裡幫旅人渡河,於是他表示:

「沒問題,我可以背你老婆過去。」

結果背到一半,這傢伙居然企圖侵犯德伊阿涅拉,海克力士聽見妻子呼救,立刻拿起弓箭,遠遠一箭射中了涅索斯。

問題是,海克力士的箭可不是普通的箭,他之前殺死九頭蛇之後,曾經把自己的箭浸過九頭蛇的毒液,所以這一箭射下去,涅索斯基本上是沒救了。

但涅索斯都快死了,居然還想再陰海克力士最後一次,他騙德伊阿涅拉說:

「把我傷口流出來的血留下來,以後如果海克力士不愛妳了,只要用這個,就能讓他的感情回到妳身上。」

德伊阿涅拉居然信了!於是她真的把涅索斯的血收了起來,而且這一放,就是很多年。

後來,海克力士帶回了一名叫做伊俄蕾的女人,德伊阿涅拉一看,開始擔心丈夫是不是變心了,這時她突然想起:

「欸,我不是還有那瓶愛情魔藥嗎?」

於是她把涅索斯的血塗在一件長袍上,再叫人送去給海克力士,讓他祭神的時候穿上,結果這哪是什麼愛情魔藥,涅索斯當年可是被沾過九頭蛇毒液的箭射死的,他留下來的血裡,早就混進了劇毒。

海克力士穿上長袍後,隨著祭祀的熱氣升高,毒開始侵蝕他的身體,衣服甚至緊緊黏在皮膚上,想扯下來,連皮肉都會一起被撕走。

直到消息傳回去,德伊阿涅拉才終於明白自己藏了這麼多年的,根本不是什麼挽回愛情的魔藥。

而是涅索斯留給海克力士的一份「延遲很多年」的復仇。

有些 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,所以我們這樣畫:

https://ithelp.ithome.com.tw/upload/images/20260814/201825641UnM6Ae6Va.png

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

https://ithelp.ithome.com.tw/upload/images/20260814/20182564YdX3wSZQlb.png

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

https://ithelp.ithome.com.tw/upload/images/20260814/20182564eadliXauxz.png

於是我們在這個 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() 算出來的最終時間對不對?從這裡就完全無從得知。


匯集點

相較於IsEligibleForLeaderboardGenerateReport() 剛好可以讓我們一次觀察提示次數、罰時與最終時間,與其貼著改動點只測得到一個變數,我們可以先從它下手。

這種影響路徑多條交會的攔截點,我們可以叫做 匯聚點 (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,但至少我們應該也要將它視為風險,再依需求與現有測試判斷需不需要再替它補測試。

不是測試越多越好,而是先把有限的資源先放在最有策略的位置。

別為了一棵樹放棄整片森林

匯聚點雖然很好找很直觀,但也很容易讓之後所有測試都想要測它,如果這個匯聚點流程很滿,後來每個小規則都得準備一堆測試替身,再跑過完整流程,這些測試即使執行起來不慢,寫起來仍然很重,失敗時也就很難馬上判斷是哪一層出問題。

這就是匯聚點所帶來的陷阱,它可以是快速取得保護的起點,但不能是所有測試永遠落腳的地方,除非他的測試成本夠低。


2026 年了,有沒有工具幫上忙?

在《Working Effectively with Legacy Code》中,Michael Feathers 說過:

「想像這樣的場景,選中某區塊的程式碼,IDE 就能提供該區塊程式碼所可能影響的所有變數和方法的列表」。

這本書在 2004 年出版,二十幾年後,這個期待實現到什麼程度?

呼叫關係與線索

Rider 和 Visual Studio 都有工具能幫我們追呼叫關係與符號參考,只是兩邊提供的能力與操作方式不完全一樣。

我自己平常習慣用 Rider,所以下面先拿它來示範。

Rider 的數值追蹤 可以透過 Value Origin 以及 Value Destination,追蹤一個值可能從哪裡來、接著又可能流到哪裡。

我們直接拿前面的 _hintsUsed 來做一次,現在我們想知道的是「這個數值改變後,值可能會流去哪裡?」,所以要查的是 Value Destination

  1. 把數標放在 _hintsUsed 的宣告或使用位置上

https://ithelp.ithome.com.tw/upload/images/20260814/20182564LE9LytdKh0.png

  1. 接著右鍵或快捷鍵開啟 Inspect This

https://ithelp.ithome.com.tw/upload/images/20260814/201825646TsAsSktf0.png

  1. 在清單中選擇 Value Destination

https://ithelp.ithome.com.tw/upload/images/20260814/201825643E4LNpbNuS.png

  1. Rider 會在 Find 視窗列出可能的目的地,展開節點就能繼續往下追,點選節點,則可以跳到對應的初始化或賦值位置

https://ithelp.ithome.com.tw/upload/images/20260814/20182564bkGTKxnMDw.png

照這個例子,我們要從結果中核對的,就是 _hintsUsed 是否一路走到 IsEligibleForLeaderboard()GenerateReport(),這些候選路徑攤開後,再把真正與這次需求有關的節點畫進影響草圖。

反過來,如果是想找「這個值到底從哪裡來?」,就改選 Value Origin,沿著結果往回找。

我自己是覺得還蠻好用的,如果要說有沒有工具,一定一大堆是我沒有用過的,但是即便再好用的工具,在幫我們提高效率的同時,我們也要有辨別的能力,不然只會錯的更快速而已。

從根本上讓影響少一點:C# 的書寫走向

近代 C# 提供了幾個有助於減少可變狀態的選項,例如 initreadonly record structImmutableArray<T> / ImmutableList<T>...等等,在適合情境下,選用語言特性帶給我們的制約,能一定程度上去降低影響程度。

所以,這些都是寫 code 時可以考慮的選擇,例如如果資料建立後就不再被修改,至少能少掉不少「共享可變狀態」造成的傳播路徑,影響路徑往往也會跟著單純不少,不過要怎麼規範書寫,仍然非常吃團隊共識就是了。


總結

今天做的事情,說穿了就是在改 code 之前,從真正的改動點出發,沿著狀態與 method 的結果往外追,畫成 影響草圖,接著在草圖上找出測試能觀察結果的 攔截點,如果多條路徑剛好在同一個低成本的位置匯合,那裡通常就是很值得優先保護的 匯聚點

但匯聚點不是什麼終極寶穴,也不是所有測試都要搬去那裡排隊,它只是幫我們在保護範圍、測試成本與失敗定位之間找一個當下比較划算的位置。

省不了的事情就是 睜開眼睛看

接下來我們看:測試都綠燈,怎麼知道它真的有抓到 Bug?

Reference


上一篇
Day 13 -「代達羅斯的翅膀」不好測要怎麼測?用別種方法測
下一篇
Day 15 -「機智奧德修斯」故意搞破壞的 Mutation Test
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言