iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

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

Day 13 -「代達羅斯的翅膀」不好測要怎麼測?用別種方法測

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260813/20182564PuirrPptVI.png

代達羅斯是希臘神話裡出了名的工匠,他替克里特王米諾斯設計了一座錯綜複雜的迷宮,用來關住半人半牛的怪物米諾陶洛斯,後來雅典英雄忒修斯闖進迷宮、殺死米諾陶洛斯,還帶著同伴逃了出去( Day01 的故事)。

米諾斯得知消息後,認定負責建造迷宮的代達羅斯有罪,乾脆把他和兒子伊卡洛斯一起關進那座迷宮。

代達羅斯當然想逃,可是海上有米諾斯控制,陸地上也到處都是他的勢力,根本找不到能安全離開克里特島的方法,照理說已經沒有出口了吧?

但代達羅斯發現,米諾斯管得到陸地和海洋,卻管不到天空。

於是他把羽毛由短到長排好,中間用線固定,底部用蠟黏住,慢慢做出兩對像鳥一樣彎曲的翅膀,出發前,他特別提醒伊卡洛斯:

「不要飛得太低,海水會弄濕羽毛,也不要飛得太高,太陽會把蠟烤軟,只要跟著自己飛在中間就好。」

兩個人最後真的飛離了克里特島,可是伊卡洛斯越飛越興奮,漸漸離開父親帶領的路線,飛得離太陽愈來愈近,蠟受熱軟化,羽毛一片片散開,他也跟著掉進海裡。

代達羅斯回頭找不到兒子,只看見羽毛漂在水面上。最後他找到伊卡洛斯的遺體,把孩子埋葬,也開始痛恨那門原本救他們逃出生天的技藝。

他確實找到了米諾斯封鎖之外的出口,卻沒能把兒子一起帶走。

不好測的程式碼就很像米諾斯封起來的海路和陸路,我們若只盯著「怎麼繼承、怎麼 override」這一條路,當然會覺得每個出口都被鎖死了,今天我們來看 .NET 裡幾個排除測試障礙方法,重點不是把封印硬撬開,而是如何判斷什麼時候我們有什麼相對應的策略。


首先

先來看看 今日範例,是一個月結報表匯出服務,今天我們想替 CalcSubtotal 加上特徵測試:

public class ReportExporter
{
    public byte[] Export(int month)
    {
        var pdf = new NewtonPdfDocument(); // 第三方套件
        var rows = LoadRows(month);
        var subtotal = CalcSubtotal(rows);
        pdf.AddTable(rows, subtotal);
        return pdf.Render();
    }

    private IReadOnlyList<ReportRow> LoadRows(int month)
        => month == 5
            ? [new ReportRow(Quantity: 1, UnitPrice: 31.1m)]
            : Array.Empty<ReportRow>();

    private static decimal CalcSubtotal(IReadOnlyList<ReportRow> rows)
    {
        decimal sum = 0m;
        foreach (var r in rows)
            sum += r.Quantity * r.UnitPrice;
        return Math.Ceiling(sum);
    }
}

看到程式碼可能會有人問:

「這才短短幾行,簡單啦!」

「前幾天不是已經學過怎麼處理了?就把 CalcSubtotal 變成 protected 再做一個子類別去轉接它啊!」

「不然就是我們可以先把用到第三方套件的地方抽成方法,一樣再用子類別覆寫它不就好了?」

確實,這些方法我們確實都很會用了,也能解決現在的問題,所以今天我也沒打算再講同樣的東西,今天我們來介紹點不一樣的,也許這些技巧學會了,遇到 Legacy Code 時,我們就不必每次都花同樣的成本,可以依照當下的時間、風險和設計狀態,挑一條比較剛好的路。


InternalsVisibleTo 走後門

小提醒:可以切換分支到 Refactoring-InternalsVisibleTo

在 .NET 裡,Assembly 可以先把它想成程式編譯完成後的一個單位,常見的形式就是 .dll 或 .exe。

平常我們會在同一個 Solution 裡放主程式專案和測試專案,不過它們各自 Build 之後,通常會是兩個不同的 Assembly,對編譯器來說,就像住在隔壁的兩棟房子,看得到彼此,但不能隨便闖進去。

internal 則像是只在同一棟房子裡有效的門禁卡,只要是標記成 internal 的 class 或 method,在同一個 Assembly 裡的程式可以使用,其他 Assembly 則預設看不到。

private 的範圍則更小,只有宣告它的 class 自己能用,所以即使把 CalcSubtotal 從 private 改成 internal,位於另一個 Assembly 的測試專案還是進不來。

所以這時候就輪到 InternalsVisibleTo 出場了。

InternalsVisibleTo 的意思是讓另一個 Assembly 可以看見我這個專案裡的 internal 成員。

如果使用 SDK-style 的 .NET 專案,可以修改 src.csproj 檔案加入這一行:

<ItemGroup>
  <InternalsVisibleTo Include="test" />
</ItemGroup>

這行的效果就是可以讓我們的 test 專案可以看到帶有internal 的東西。

所以接著我們將 CalcSubtotal 稍微改一下,將 private 改成 internal:

internal static decimal CalcSubtotal(IReadOnlyList<ReportRow> rows) { ... }

這樣封裝性會不會受影響?確實有一點點代價,private 只有類別本身看得到,改成 internal 之後,同一個 Assembly 裡的其他類別也看得見了。

不過對組件外部的呼叫方來說,internal 和 private 是完全一樣不透明的,沒有任何改變,InternalsVisibleTo 也是具名授權,得把測試專案的名稱明確列出來才能通行,不是廣播給所有人,所以圍牆並沒有拆掉,只是換了一道比較窄的門。

於是測試專案就找得到它了:

[Fact]
public void CalcSubtotal()
{
    List<ReportRow> rows =
    [
        new (Quantity: 1, UnitPrice: 15.5m),
        new (Quantity: 1, UnitPrice: 14m)
    ];

     Assert.Equal(30m, ReportExporter.CalcSubtotal(rows));
}

如此一來便可以做測試了,神奇吧?

在不大幅修改設計的前提下,internal + InternalsVisibleTo 是 .NET 裡算很快的測試接縫,但代價是測試會知道內部成員。哪天我們只是替 method 改名、搬家,對外行為明明沒有改,測試也可能跟著壞掉,所以我會把它當成取得安全網的過渡手段,而不是看到 private 就一律改成 internal。


祕技:反射

小提醒:可以切換分支到 Refactoring-Reflection

反射(Reflection)是 .NET 在程式執行期間查看型別資訊,並動態尋找、操作其成員的機制,平常直接呼叫 ReportExporter.CalcSubtotal(rows) 時,編譯器看到 CalcSubtotal 是 private 就會阻止我們呼叫,反射則不是直接呼叫 method,而是先取得 ReportExporter 的型別資訊,再請 runtime 從裡面找出符合條件的 method。

所以接下來的測試會分成四個步驟:

  1. typeof(ReportExporter) 取得代表這個 class 的 Type 物件。
  2. 呼叫 GetMethod(),用名稱尋找 CalcSubtotalBindingFlags.NonPublic 表示要搜尋非公開成員,BindingFlags.Static 則表示目標是 static method。
  3. 找到後會拿到一個 MethodInfo,可以把它想成這個 method 在 runtime 裡的操作把手,找不到時則會得到 null,所以要先確認它確實存在。
  4. 最後透過 Invoke() 執行它,第一個參數代表要在哪個物件上呼叫,不過 CalcSubtotal 是 static method,不需要物件實例,因此傳入 null,而第二個參數則是依照原 method 參數順序放好的 object[],Invoke() 會把執行結果包成 object 回傳,我們再確認它確實是預期的 decimal。

這四個步驟合在一起,測試就會長成這樣:

using System.Reflection;
using src;

namespace test;

public class ReportExporterTests
{
    [Fact]
    public void CalcSubtotal()
    {
        var method = typeof(ReportExporter).GetMethod(
            "CalcSubtotal",
            BindingFlags.NonPublic | BindingFlags.Static);

        Assert.NotNull(method);

        IReadOnlyList<ReportRow> rows =
        [
            new(Quantity: 1, UnitPrice: 14m),
            new(Quantity: 1, UnitPrice: 15.5m)
        ];

        var result = method!.Invoke(null, [rows]);

        Assert.Equal(30m, Assert.IsType<decimal>(result));
    }
}

這個測試不必修改 Production Code,臨時替完全碰不得的 Legacy Code 補特徵測試時,確實可能救得了急,不過! method 名稱變成字串,參數和回傳值也要到執行測試時才知道能不能對上,編譯器幫不上多少忙,只要改名、改簽名或搬家,測試就可能在 runtime 壞掉。

所以反射可以是一雙臨時做出來的翅膀,但我不會把它當成長期方案,有機會修改設計時,還是得採取正規作法,由編譯器保護的邊界比較踏實。

講個有趣的,會知道這個做法也是幾年前曾拜讀程杰大大的名著《大話設計模式》,讓我對「反射」這個詞還蠻感興趣的,於是就稍微研究了一下,其中提到一句話:

『反射反射,程序員的快樂!』

現在想起來,確實蠻快樂痛苦的 XD


搬家

小提醒:可以切換分支到 Refactoring-Calculator

第三種方法「搬家」,通常我們會覺得一個地方不好測,很有可能是因為本來就設計的不好,那麼,我們就先來開始試試看以不同的看法來切入看看 ReportExporter

一條把報表列加總、再決定進位的計算規則,它真的屬於「PDF 匯出服務」嗎?

不屬於吧?至少從目前的程式碼看起來,它只是剛好被寫在這裡,它應該要是一條獨立的業務計算,跟怎麼「匯出」一點關係都沒有,那我們不如就讓它搬出去,變成一個公開、可以直接測的東西,在這個階段我們一樣可以依靠強大的 IDE 來幫助我們做 Extract Class

public static class ReportRowSubtotalCalculator
{
    public static decimal Execute(IReadOnlyList<ReportRow> rows)
    {
        decimal sum = 0m;
        foreach (var r in rows)
            sum += r.Quantity * r.UnitPrice;
        return Math.Ceiling(sum);
    }
}

這樣就可以把 Execute 改成 public 了,這樣主程式的 Export 也就會自動變成改動後的樣子了。

public byte[] Export(int month)
{
    var pdf = new NewtonPdfDocument(); // 第三方套件
    var rows = LoadRows(month);
    var subtotal = ReportRowSubtotalCalculator.Execute(rows);
    pdf.AddTable(rows, subtotal);
    return pdf.Render();
}

搬完之後就不需要任何後門了,測試直接呼叫 Execute 就好:

[Fact]
public void CalcSubtotal()
{
    List<ReportRow> rows =
    [
        new (Quantity: 1, UnitPrice: 15.5m),
        new (Quantity: 1, UnitPrice: 14m)
    ];

    Assert.Equal(30m, ReportRowSubtotalCalculator.Execute(rows));
}

「不對啊!阿你不是一直強調不要公開嗎?」

是的,如果是為了測試方便,直接把 ReportExporter 的內部步驟公開,那確實是非常不好的做法。

但現在情況不一樣了,搬家後的 ReportRowSubtotalCalculator 本來就是為了表達「計算小計」這項獨立能力,計算正是這個 class 的職責,把它公開,是讓呼叫端使用一個職責清楚的物件,而不是請大家進來操作 ReportExporter 的內部零件。

當然,搬出去也不代表非得 public 不可,重點從來不是「為了測試」而怎樣怎樣,而是這個行為放在哪裡、應該對誰承諾。

兩種方式都很好,就看當下的場景跟時機點,不然的話我自己的話會優先試搬家,直接開後門的話,如果最後確認它其實是獨立職責,那多半還是會走到職責分離的嘛,我覺得就看自己面對 Legacy Code 的經驗值,這東西就是熟能生巧啊!畢竟現在工具這麼發達,也就幾秒鐘的事情。


第三方 sealed

如果我們今天想要直接從入口測,首先我們在第一行就會遇到不得不避開的第三方物件,如果我們這裡不要用覆寫的方式去做的話還能怎樣做呢?

有些第三方 SDK 會把核心型別設成 sealed不讓我們去繼承,或者雖然 class 可以繼承,但我們真正想替換的 method 並不是 virtual,通常就是告訴我們:

「這裡不提供加蓋服務。」

原因是因為只要允許繼承,套件作者承諾的就不只是幾個公開 method,還包含哪些 method 可以 override、建構過程會留下什麼狀態...等等,能做的操作太多了。

今天我們繼承後偷偷改了一個步驟,哪天套件升版、內部流程一調整,全世界各種神奇子類別可能就一起完蛋,最後大家還是回頭向廠商發客服。

所以有些套件會用 sealed 縮小需要長期維護的擴充面,保護內部狀態或安全條件,runtime 也可能因此獲得一些最佳化空間。

我們大概了解套件後,可以點進去來看看它到底長什麼樣子:

public sealed class NewtonPdfDocument
{
    public void AddTable(IReadOnlyList<ReportRow> rows, decimal subtotal) { /* 排版 */ }
    public byte[] Render() { /* 算圖、驗 license */ }
}

它是 sealed,我們不能繼承它再覆寫 AddTable()Render(),不過這不代表封住了我們的去路。

先看看套件有沒有後門

看到第三方套件不好測,第一步可以先翻一下官方文件,看看它有沒有提供可以替換的介面、Factory、Builder、Handler、Callback,或是不會碰到真實環境的 in-memory 模式,廠商如果已經準備好擴充點,直接沿著它設計通常最省事,也比較不容易在套件升級時跟官方的使用方式打架。

不過看到 Factory 也別太早歡呼,假如它只是讓我們決定「怎麼建立」,建立出來的仍然是同一個 sealed 型別,而且後續 method 也不能替換,那我們只是換了 new 的位置,還沒有真的控制它的行為。

今天這個虛構的 NewtonPdfDocument ,就請讀者們當作比較難搞的版本,官方沒有介面,也沒有提供測試模式,我們需要自己找方法。

以下就介紹幾個我所知的方法,歡迎各位練習看看!

Delegate

小提醒:可以切換分支到 Refactoring-Delegate

在 C# 裡,Delegate 可以先把它想成「一份 method 的型別契約」,它會寫清楚這個 method 要接收哪些參數、最後回傳什麼型別。

只要某個 method 或 Lambda 的輸入與輸出符合這份契約,我們就能把它放進 Delegate 變數裡,像傳遞一般物件一樣,把「等等要執行的動作」傳給另一個 class。

跟介面不同的是,介面描述的是「這個物件會做哪些事情」,Delegate 描述的則是「這一個動作長什麼樣子」。

如果我們只想把「拿報表資料產生 PDF」這個動作換掉,還不需要為一個 method 建立整個介面,可以先定義一個有名字的 Delegate:

public delegate byte[] PdfRenderer(
    IReadOnlyList<ReportRow> rows,
    decimal subtotal);

這表示任何想成為 PdfRenderer 的 method,都要接收報表列與小計,最後回傳 byte[]

其實也能直接寫成:

Func<IReadOnlyList<ReportRow>, decimal, byte[]>

效果差不多,不過 PdfRenderer 一眼就看得出這個動作要負責什麼,讀起來也比較不吃力。

那麼我們接下來把第三方套件的產製動作抽取成一個方法,並放到呼叫端 Program(38行處):

private static byte[] PdfRendering(
	IReadOnlyList<ReportRow> rows,
	decimal subtotal)
{
     var pdf = new NewtonPdfDocument(); // 第三方套件
     pdf.AddTable(rows, subtotal);
     return pdf.Render();
}

那麼接著我們把這個動作從建構子注入到 ReportExporter

public class ReportExporter
{
    private readonly PdfRenderer _renderPdf;

    public ReportExporter(PdfRenderer renderPdf)
        => _renderPdf = renderPdf;

    public byte[] Export(int month)
    {
		var rows = LoadRows(month);
        var subtotal = CalcSubtotal(rows);
        return _renderPdf(rows, subtotal);
    }
}

接著在呼叫端把真的第三方套件呼叫傳進去:

var exporter = new ReportExporter(PdfRendering);

測試時則換成另一個符合相同簽名的 Lambda,記下收到的小計,完全不需要建立 NewtonPdfDocument

[Fact]
public void CalcSubtotal()
{
    decimal capturedSubtotal = 0m;
    var exporter = new ReportExporter((_, subtotal) =>
    {
        capturedSubtotal = subtotal;
	    return [];
    });

    exporter.Export(month: 5);

    Assert.Equal(32m, capturedSubtotal);
}

這裡注入的不是一個「假的 PDF 物件」,而是一個「測試版本的 PDF 產生動作」。

不過這個做法還是有缺點,當依賴真的只有一個動作時,Delegate 很輕巧,但如果後面陸續加入頁首、圖片、分頁、浮水印,參數越長越誇張,或呼叫端需要分開控制多個步驟,就代表一個 Delegate 開始撐不住了,這時候有明確的介面會比較好。

「但是我覺得最大的缺點就是,太麻煩了吧?我用其他更好的做法早就做完了,哪還需要在呼叫端注入啊?」

會這樣質疑是正常的,如果這次只想驗證小計計算,前面把規則搬進 ReportRowSubtotalCalculator,直接測 Execute() 就夠了,沒必要為了測一條計算規則,連 PDF 產生流程都改成 Delegate。

Delegate 真正派得上用場的時機,是我們想從 Export() 這個公開入口驗證整段協作,卻又不想在測試裡真的建立 PDF。依賴總要有人提供,把 PdfRendering() 放到呼叫端 ,不是平白多繞一圈,而是把「使用 PDF 產生動作」和「決定正式環境要用哪個套件」分開,依照不同環境注入不同動作,ReportExporter 則不需要知道這個方法做什麼。

動作變多,就包起來

小提醒:可以切換分支到 Refactoring-Writer

如果我們對 SOLID 有一點了解,會發現這正是 DIP 常處理的問題。

其實 ReportExporter 根本不需要直接認識 NewtonPdfDocument 這個具體物件,它只需要一個能接收表格並輸出 byte[] 的協作者,我自己會把這種包住外部服務的介面叫做 Gateway,名稱不重要,重要的是它能夠幫我們解耦合。

我們先來看看抽取出來的介面會長怎樣:

public interface IPdfWriter
{
    void Initialize();
    void AddTable(IReadOnlyList<ReportRow> rows, decimal subtotal);
    byte[] Render();
}

接著實作 IPdfWriter

public sealed class NewtonPdfDocumentWriter : IPdfWriter
{
    private NewtonPdfDocument _doc = new();

    public void Initialize()
        => _doc = new NewtonPdfDocument();

    public void AddTable(IReadOnlyList<ReportRow> rows, decimal subtotal)
        => _doc.AddTable(rows, subtotal);

    public byte[] Render() => _doc.Render();
}

此時主程式就會變成以下這樣,透過注入的方式來使用產製報表的服務

public class ReportExporter
{
    private readonly IPdfWriter _pdf;
    public ReportExporter(IPdfWriter pdf) => _pdf = pdf;

    public byte[] Export(int month)
    {
        _pdf.Initialize();
        var rows = LoadRows(month);
        var subtotal = CalcSubtotal(rows);
        _pdf.AddTable(rows, subtotal);
        return _pdf.Render();
    }
	// ...略
}

那麼我們就可以來對其做測試了。

測試時我們就透過 Moq 套件來對其做 Verify 的驗證

[Fact]
public void CalcSubtotal()
{
    var pdf = new Mock<IPdfWriter>();
    var exporter = new ReportExporter(pdf.Object);
    exporter.Export(month: 5);

    pdf.Verify(x => x.AddTable(
        It.IsAny<IReadOnlyList<ReportRow>>(),
        32m), Times.Once);
}

這裡沿用互動驗證,確認 ReportExporter 把正確小計交給 IPdfWriter,如此便解決第三方套件所帶給我們的一些麻煩。

NewtonPdfDocument 是我們無法修改的第三方物件,IPdfWriter 則是我們希望使用的介面,因為我們沒有直接去繼承,而是把它包起來再呼叫,也就繞過了 sealed 的限制。

熟悉設計模式的讀者們,應該就不難發現,這個結構其實很像

Adapter Pattern(轉接器模式)

確實有一個名稱,除了方便溝通,也可以耍酷啦 XD


總結

今天介紹了不少招,但我覺得最重要的其實不是記住「遇到 private 用什麼、遇到 sealed 又用什麼」。

不然哪天看到一個 private method,二話不說就先掏出反射技術:

「鑰匙忘記帶了?沒問題,我先把玻璃敲破」

...能進去是能進去啦,但最好先想一下這樣要花多少錢 XD

所以總結一下今天聊的技巧,我自己會怎麼去看:

遇到的狀況 可以考慮的技術 我會怎麼看它
想先快速替內部邏輯補安全網 InternalsVisibleTo 借測試專案一張門禁卡
Production Code 完全不能碰 Reflection 打破窗戶,要賠錢的
邏輯本來就不屬於這個 Class Extract Class 別開後門了,直接幫它搬家
外部依賴其實只有一個動作 Delegate 為一個動作抽 Interface 有點殺雞用牛刀
第三方 SDK 開始有一堆操作 Adapter Pattern 把難搞的東西關起來

各位會發現,這些東西其實沒有哪一招是「正解」。

Reflection 並沒有比較邪惡,Interface 也沒有自帶聖光,InternalsVisibleTo 更不是用了就會被架構大神逐出師門。

真正的差別在於:現在只是需要一張安全網,還是已經準備開始整理設計?

時間緊急的時候,如果還堅持一定要寫出完美的設計,可能 Bug 還沒修完,人就先下課了。

代達羅斯沒有因為路都走不通,就花三個 Sprint 說服米諾斯把港口打開 :)

他換了一條路,而我們面對 Legacy Code 其實也差不多。

東西都是一體兩面的,學會怎麼權衡才是王道!

畢竟我們的目標從來都不是證明自己有多少招,吧?

明天我們繼續看:改一個地方,到底會波及多少地方?

Reference


上一篇
Day 12 -「奧革阿斯牛棚」啊不就刪掉就好了?
下一篇
Day 14 -「涅索斯愛情魔藥」改一個地方,到底會波及哪裡?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言