
代達羅斯是希臘神話裡出了名的工匠,他替克里特王米諾斯設計了一座錯綜複雜的迷宮,用來關住半人半牛的怪物米諾陶洛斯,後來雅典英雄忒修斯闖進迷宮、殺死米諾陶洛斯,還帶著同伴逃了出去( 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 時,我們就不必每次都花同樣的成本,可以依照當下的時間、風險和設計狀態,挑一條比較剛好的路。
小提醒:可以切換分支到 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。
所以接下來的測試會分成四個步驟:
CalcSubtotal,BindingFlags.NonPublic 表示要搜尋非公開成員,BindingFlags.Static 則表示目標是 static method。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 的經驗值,這東西就是熟能生巧啊!畢竟現在工具這麼發達,也就幾秒鐘的事情。
如果我們今天想要直接從入口測,首先我們在第一行就會遇到不得不避開的第三方物件,如果我們這裡不要用覆寫的方式去做的話還能怎樣做呢?
有些第三方 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 ,就請讀者們當作比較難搞的版本,官方沒有介面,也沒有提供測試模式,我們需要自己找方法。
以下就介紹幾個我所知的方法,歡迎各位練習看看!
小提醒:可以切換分支到 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 其實也差不多。
東西都是一體兩面的,學會怎麼權衡才是王道!
畢竟我們的目標從來都不是證明自己有多少招,吧?
明天我們繼續看:改一個地方,到底會波及多少地方?