iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

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

Day 05 -「阿基里斯之踵」如何感覺到程式碼?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260804/20182564oyqPzIpqRp.png

在後世流傳最廣的版本裡,阿基里斯還是嬰兒的時候,他的母親忒提斯就被告知,這個孩子長大後會成為很厲害的戰士,卻也很可能死在特洛伊。

做母親的哪接受得了這種預言?於是她抱著阿基里斯來到冥河邊,抓著他的腳跟,把整個人浸進河水裡。

傳說身體被冥河浸過的地方都不會受傷,而偏偏忒提斯手裡握著阿基里斯的那隻腳,腳跟的部分根本沒有碰到河水,也就是這個全身最不起眼的一小塊,使得最後成為阿基里斯唯一的弱點。

後來阿基里斯真的去了特洛伊,真的強到幾乎沒人攔得住他,直到帕里斯在阿波羅的幫助下,一箭射中他的腳,永遠不可能倒下的英雄就倒在唯一沒有被冥河保護到的地方,英文後來也把這種「平常看不出來,出事時卻足以致命的弱點」叫做 Achilles' heel,而這也是後來阿基里斯腱這個名稱的由來。

Legacy Code 也經常如此,方法確實執行了、資料也確實被加進清單,但結果就是跟我們想的不一樣,既沒有回傳,也沒有留下外部可以觀察的狀態,程式看起來正常運作,測試卻沒辦法覆蓋到真正需要確認的地方。

而要讓當前這段 Legacy Code 能夠被測試,第一件要做的事情必不可少,「拆開依賴」。


兩個拆依賴的理由

很多人以為拆開依賴只是為了讓「程式碼乾淨」,但其實拆依賴背後有兩個完全不同的理由:

Separation(隔離)

想像有個 ReportService,建構子裡直接 new SqlConnection(connectionString)connectionString 又從 ConfigurationManager 讀進來。就算我們只想測一段簡單的邏輯,也得先備好能連線的資料庫、正確的環境設定,才有辦法把物件建出來,整條依賴鏈環環相扣,所以「都還沒開始測就超麻煩!」。

Sensing(感測)

像一段把推送結果直接寫進外部訊息佇列的邏輯,方法回傳 void,事後完全不知道有沒有東西被推進去、推了幾筆、條件有沒有判對,所以資訊都會在外部系統裡,測試就像瞎了眼,什麼都看不到。

真實的 Legacy Code 通常兩者都會兼具啦,我們能做的就是一邊切開依賴,一邊替看不見的行為裝上感測器,今天要做的範例練習,是一種很常見、也很實用的起手式,尤其適合依賴被直接寫死在方法內,又暫時不適合大幅調整設計的情況。

而為了要能夠做單元測試,「拆開依賴」不可少,這個動作不一定立刻改善 production code 的整體設計,但能降低測試對外部的依賴,先替後續重構取得安全網。


接上義肢

https://ithelp.ithome.com.tw/upload/images/20260804/20182564VQdQJXOc65.jpg
截錄自電影《搶救雷恩大兵》

當士兵斷手斷腳卻還活著,我們怎麼辦?接上義肢啊!幸運的話他還能繼續上戰場(聽起來有點殘忍 XD)。

我們的 Legacy Code 也是這個道理,它已經是身經百戰的勇士,受傷的地方沒辦法馬上痊癒,但我們可以靠復健慢慢讓它變好,而復健之前得先幫它接上義肢,至少讓它能正常走路吧。

往後的程式碼範例都會有兩個分支: master 分支是一個乾淨的分支,而 Refactoring 分支都會是重構過後的版本。

而在 今日範例 中,公司現在跑著的 CheckService,要決定哪些訊息該推送給裝置:

public class CheckService
{
    private readonly IServiceProvider _provider;
    private List<MessageRule> _pushList = [];

    public CheckService(IServiceProvider provider) { _provider = provider; }

    public void CheckAndPush(DeviceInfo device)
    {
        // 從資料庫撈規則
        var whereStr = "device_sn = @sn and is_valid = 1";
        var param = new { sn = device.Sn };
        var ruleList = _provider.GetService<IMessageRule>().GetList(whereStr, param);

        // 用現在時間篩出符合的
        var now = DateTime.Now;
        foreach (var rule in ruleList)
        {
            if (rule.StartDate < now && rule.EndDate > now)
            {
                _pushList.Add(rule);
            }
        }
    }
}

這是典型「乍看沒辦法測」的活生生案例,仔細看,在我們撰寫測試時會發現有三個地方是失明的,我們似乎觀測不到這邊實際上資料的變化:

_provider.GetService(...)   // 真的會連 DB
DateTime.Now                // 我們沒辦法控制現在幾點
_pushList.Add(rule)         // 沒辦法驗證有沒有存入資料

我的天,這也太麻煩了吧...但別怕,掌握一點 OOP 小訣竅之後,我們會發現這其實沒那麼難。


抽方法、開縫

在動手之前有個前提需要讓各位知道,在沒有測試保護的情況下,盡量不改變程式碼原有的行為,簽名不動、output 不動、現有呼叫者就完全感受不到我們動過手腳。

所以第一步我們會只做一件很無聊的事,把難測的程式碼用 Extract Method 抽出來,把可見度改成 protected virtual

所幸現代 IDE 都有好用的重構工具,我自己開發還是習慣用 Rider,不過 Visual Studio 也能做到一樣的事情。

把難以測試的部分選起來,按下重構快捷鍵,或是按右鍵選擇 Refactor This,接著用 Extract Method 把方法抽出來。

https://ithelp.ithome.com.tw/upload/images/20260804/20182564JNmf5QWqEX.png

https://ithelp.ithome.com.tw/upload/images/20260804/20182564E8uxZoAq1A.png

我們按照上述步驟接著對其他不可測的部分也做抽取。

protected virtual List<MessageRule> GetRuleList(DeviceInfo device)
{
    var whereStr = "device_sn = @sn and is_valid = 1";
    var param = new { sn = device.Sn };
    return _provider.GetService<IMessageRule>().GetList(whereStr, param);
}

protected virtual DateTime GetNow() => DateTime.Now;

// 提供資料的可見度
protected List<MessageRule> GetPushList() => _pushList;

主邏輯就會如下:

public void CheckAndPush(DeviceInfo device)
{
    var ruleList = GetRuleList(device);
    var now = GetNow();
    foreach (var rule in ruleList)
    {
        if (rule.StartDate < now && rule.EndDate > now)
        {
            _pushList.Add(rule);
        }
    }
}

表面上看起來什麼都沒變,程式依然照常運作,但我們已經悄悄留下一個可以控制行為的切入點,未來不必動到原本的核心邏輯,就能從這裡改變程式的行為,這個位置我們稱之為 Seam (接縫)


縫合、接上

而我們把 Seam 給鑿出來後,接下來則是要透過 override 來將原本在父類別的方法給替換掉,以便我們可以做「單元測試」。

接下來做一個 FakeCheckService 來繼承 CheckService ,把兩個 virtual 方法通通覆寫掉:

public class FakeCheckService(IServiceProvider provider) : CheckService(provider)
{
    private List<MessageRule> _stubRules = [];
    private DateTime _stubNow;

    public void SetRules(List<MessageRule> rules) => _stubRules = rules;
    public void SetNow(DateTime now) => _stubNow = now;

    protected override List<MessageRule> GetRuleList(DeviceInfo device) => _stubRules;
    protected override DateTime GetNow() => _stubNow;

    public IReadOnlyList<MessageRule> GetPushList() => base.GetPushList();
}

這時候我們會發現,DB 被截胡了、時間能凍結了、pushList 的結果也能被側錄了,而 CheckService 則完全不受影響,那麼這樣就可以開始寫單元測試了嗎?當然可以,但是在這之前,我們先來認識一下什麼是 Test Double


等等,先認識測試替身

先岔題一下,剛才在 FakeCheckService 中覆寫的方法,還有等一下會出現在測試裡的玩意,本質上都是「在測試裡代替正式依賴」的物件,這種東西有個統稱叫做 測試替身(Test Double)

而「測試替身」只是統稱,依照它在測試中負責的工作,還能再分成五種常見角色:

https://ithelp.ithome.com.tw/upload/images/20260804/20182564fTFp1x9dfN.png

類型 在測試做什麼 適合的情境 在餐廳做什麼
Dummy 什麼都不做,只用來補齊參數或建構式 測試目標根本不會使用這些依賴 用餐固定收 10% 服務費 XD
Stub 回傳測試預先安排的答案 控制外部依賴的結果,讓程式走進特定分支 提供事先準備好的固定套餐
Spy 記錄呼叫次數與傳入參數,供測試事後檢查 確認程式如何呼叫方法或外部服務 大廚偷偷紀錄實習生在煮菜時,用了什麼、做了幾次
Mock 事先定義預期的互動,並檢查預期是否成立 呼叫順序、次數或參數本身就是要驗證的行為 大廚拿著食譜逐項驗收,少一步都會提出來
Fake 提供簡化但真的能運作的實作 用記憶體代替資料庫等較完整的情境 規模小一點的 Buffet

不過! 這五個名詞不是考試範圍,不會因為分不清 SpyMock 就不能寫測試,了解它們只是為了在看文件或和團隊討論時,知道大家大概在說哪一種替身,不必把時間花在爭論某個物件到底應該貼哪張標籤。

像剛剛的 FakeCheckService,雖然名稱裡有 Fake,實際上只是替測試覆寫接縫的子類別,若照嚴格定義,TestableCheckService 也許更精確,但沒有必要為了這個現在就衝回去改名,因為名稱不會讓測試突然變好或變壞,真正重要的是大家能不能看懂它在測試裡負責什麼。

而且現代測試套件通常可以產生多種角色,同一個套件產生的物件,可以只當 Dummy、設定回傳值後當 Stub,也可以記錄互動供我們驗證,甚至就算團隊把這些東西通通叫作 Mock,也不至於世界末日,只要能明確在測試中說明我要它做什麼,那就達成目的了。

那 Moq 又是什麼?

上面講的是「角色」,而 Moq 則是幫我們把這些角色快速生出來的工具。它是 .NET 常用的測試替身套件,可以替介面或可覆寫的成員動態建立物件,不必為每個情境都手寫一個替身 class,安裝方式如下:

dotnet add package Moq

它的名稱雖然叫 Moq,產生出來的物件卻不一定都是 Mock,角色定義仍然取決於測試怎麼使用它,例如如果今天我們要做一個 Dummy 替身,那最省事的寫法就是

Mock.Of<T>()

它會直接生出一個符合介面、卻什麼邏輯都沒有的空殼物件,沒有設定的話會由 Moq 提供空值、預設值或空集合,待會我們就是拿它來當作 Dummy 注入建構子,因為測試中根本不會用到這些依賴。


起來走走看

萬事俱備,只欠測試,直接看程式碼:

[Theory]
[InlineData(-1, 0)] // 開始前
[InlineData( 0, 0)] // 剛好等於 StartDate
[InlineData( 1, 1)] // 有效時段內
[InlineData(10, 0)] // 剛好等於 EndDate
[InlineData(11, 0)] // 結束後
public void CheckAndPush_不同時間位置_應只加入有效時段內的規則(
    int daysFromStart,
    int expectedCount)
{
    var start = new DateTime(2026, 4, 10);
    var service = new FakeCheckService(Mock.Of<IServiceProvider>());
    service.SetNow(start.AddDays(daysFromStart));
    service.SetRules([
        new MessageRule
        {
            Sn = 100,
            StartDate = start,
            EndDate = start.AddDays(10)
        }
    ]);

    service.CheckAndPush(new DeviceInfo { Sn = 23 });

    Assert.Equal(expectedCount, service.GetPushList().Count);
    if (expectedCount == 1)
        Assert.Equal(100, service.GetPushList().First().Sn);
}

我們現在還不知道這個邊界是否符合真正需求,這支測試會先記錄現有行為,確認規格後,再決定是否修改判斷與測試。

這裡的 Mock.Of<IServiceProvider>() 就是剛剛提到的 DummyFakeCheckService 繼承自 CheckService,而 CheckService 的建構子需要一個 IServiceProvider,不給的話編譯就不會過,但我們已經把 GetRuleList 整個蓋掉了,所以 _provider 在測試裡根本沒機會被呼叫到,塞這個空殼進去只是為了讓建構子過關,不會影響任何結果。

現在測試中已經隔離資料庫與系統時間,並替這段流程中的「有效時段判斷與加入清單」建立了第一層保護,之後如果我們破壞了目前被這支測試描述的時間篩選行為,它就會亮紅燈,至於查詢條件與資料庫合作是否正確,仍需要其他測試保護,雖然我不是醫師,但這聽起來不錯對吧?以上這些步驟流程我們就稱之為 Subclass and Override

順帶提醒一下,virtual 是在沒有安全網時很實用的技巧沒錯,不過不代表新的程式碼也要到處為了測試而到處繼承,如果我們可以好好整理設計,那其實不太需要使用這樣的方法,但在眼前這種不敢大改,又急著取得第一層保護的 Legacy Code 裡,Subclass and Override 技術仍然是很好用的。


那要測到什麼程度才夠?

很多人剛開始最糾結這個問題:「到底要寫到多細?」我自己會用幾條指標:

1. 重要分支、條件組合與邊界值要注意: 在看到 if 的地方都要特別注意,像剛剛的 <>,剛好等於開始或結束時間時到底算不算有效,這種地方最容易藏著差一個等號的錯誤,重要分支與邊界值要優先,條件彼此交互影響時,再挑選具有代表性或高風險的組合。

2. 挑重要的測,不要盲目追求覆蓋率: 有測試總比沒測試好,所以先測會比較痛的地方,即便 Coverage 只沒有特別漂亮也可以,因為覆蓋率只能告訴我們哪些 code 被跑過,不能替我們證明 Assert 有沒有成功防守到。

「別迷信覆蓋率!!!」

3. 重要的最重要:「到底哪些邏輯絕對不能出錯?」這不是我們自己能回答的,得問問業務或 PM,甚至也可以直接問說「這個壞掉損失最大的是哪塊」,所以我們不需要把每個地方都測得很仔細,反而最重要的是業務最在意的核心邏輯,覆蓋率均勻分布不代表風險均勻分布,與其把每一行都跑過,不如先把讓老闆可能會臭罵一頓的幾個情境測好,剩下的再接著補上。

小心 AI

為什麼?因為我們是用測試在思考程式碼,AI 當然可以直接幫我們用 AAA (Arrange-Act-Assert)做組織、或用 GWT (Given-When-Then) 描述情境、巨量測資,甚至知道現在我們很躁所以空調都幫忙調低,但「需求」這件事情,我認為最不能馬虎,因為 AI 不在需求形成的現場,工程師仍然必須和客戶、PM 或領域專家確認,系統真正要解決什麼、為什麼要做,以及哪些邊界不能漏,所以理論上我們更接近需求的源頭(六邊形工程師 XD),最後還是得由我們自己負責背鍋

舉個例子,我知道現在很多工程方法都在試著替 AI 加上更多約束,偏偏前陣子剛好看到同事用 Loop Engineering 讓 Agent 自主開發、反覆驗證再修正,說是大拇指的啦!測試情境看起來好像沒問題,仔細一看 Assert 的卻是 Test Double 自己產生的資料,換句話說,即使產品程式碼壞掉了,測試也可能繼續亮綠燈,因為它根本就在測假貨。

var expected = stub.GetResult();

service.Execute();

Assert.Equal(expected, stub.GetResult());

這個例子不能證明 AI 永遠寫不出好測試,有可能只是流程構築的不夠好,這告訴我們雖然 AI 可以幫忙寫測試,但我們不能把思考與驗收一起外包,我們還是要寫能對產品行為提出問題的測試,不然正確解答人人都知道都有辦法綠燈,那豈不是太簡單,要幾千幾萬個都嘛沒問題:)

BTW,現在也有不少研究與實務,開始把「突變測試」拿來評估 LLM 產生的測試,因為突變測試會故意對產品 code 做一些破壞,再看測試會不會紅燈,工具可以使用 Stryker.NET 確認測試是否真的有能力失敗,不過它也不是銀彈,仍然不能完美的證明需求本身正確,這又是另一個話題了,不過至少方向很清楚,讓 AI 幫忙產出之後,還要有另一個機制回頭挑戰它,沒工具沒概念就算了,不能連時間也沒有吧 TT


總結

我認為軟體工程是這樣,聽起來是「工程」沒錯,但真正要讓工程長期穩定,也需要工程師的「工藝」,好的工藝會支撐好的工程,而好的工程環境也會讓工藝能夠持續累積,我個人認為兩者其實是相輔相成的。

今天我們學到,擷取方法(Extract Method) 加上 子類別化與覆寫(Subclass and Override Method),這套萬用式做的是不改變原本的業務判斷、公開簽名,只是把幾段難測的程式抽出來、再繼承覆寫,一切就不一樣了。

到這裡我們手上會的東西其實已經累積不少,知道合格的單元測試長什麼樣、也學會怎麼解開依賴,但工具多了反而出現新的問題,實際接到需求的時候,可能還是會有卡住的時候,這些動作到底該照什麼順序出手?

明天我們繼續看:Legacy Code 的必要儀式

Reference


上一篇
Day 04 -「特洛伊木馬」單元測試?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言