
奧德修斯離開家二十年,先打了十年特洛伊戰爭,回程又在海上繞了十年,好不容易回到伊薩卡,卻發現家裡早就變了樣,大家都當他已經死了,一群求婚者住進宮殿,天天逼他的妻子佩涅洛佩改嫁。
奧德修斯處理完那群求婚者,終於站到妻子面前,但佩涅洛佩沒有馬上相認,二十年真的太久了,眼前的人即使說得出自己是誰,她也不敢全盤相信,萬一認錯人,這可不是尷尬一下就算了。
於是她故意吩咐僕人,把兩人的婚床搬出房間。
奧德修斯一聽就生氣了,因為那張床根本搬不動,當年他沒有把一棵橄欖樹砍掉,而是直接把活著的樹幹做成床柱,再繞著它蓋起整間房間,除非有人先把橄欖樹從根部砍斷,不然誰也別想把床搬走。
佩涅洛佩等的就是這個反應,床怎麼做、為什麼搬不動,是兩人才知道的細節,外人不可能會知道,直到這時她才終於確定,奧德修斯真的回來了。
有些 Legacy System API 的 request 一路長成大型 model,request 設定全部都塞在一起,導致我們如果要對它進行測試時,光是排列組合可能就會用到懷疑人生了...
今日範例 中的 BillingReportGenerator 就是這種情況,它接收的 BillingReportRequest 已經有二十多個欄位,還帶著一組計費明細:
var request = new BillingReportRequest
{
TenantId = "TW-01",
TenantName = "臺灣營運中心",
AccountId = "C-1048",
AccountName = "新城會議中心",
TaxId = "54321098",
BillingEmail = "billing@newcity.example",
BillingAddress = "臺北市信義區信義路五段 7 號",
Period = "2026-07",
MemberTier = MemberTier.Gold,
Currency = "TWD",
Locale = "zh-TW",
TimeZone = "Asia/Taipei",
TaxMode = TaxMode.Exclusive,
TaxRate = 0.05m,
IncludeServiceFee = true,
ServiceFeeRate = 0.06m,
ShowItemizedBreakdown = true,
GroupItemsByCategory = true,
RoundingMode = RoundingMode.HalfUp,
PaymentMethod = PaymentMethod.BankTransfer,
PaymentTermDays = 30,
PurchaseOrderNo = "PO-202607-0188",
RequestedBy = "finance-batch",
LineItems =
[
new BillingLineRequest
{
Code = "VENUE-AM",
CategoryCode = "VENUE",
CategoryName = "場地",
Description = "大型會議室上午場",
Quantity = 1,
UnitPrice = 6_000m,
},
// .... 略
],
};
這個 Legacy production code 會根據 request 一路做判斷,再一步一步組出 report:
public sealed class BillingReportGenerator
{
public BillingReport Generate(BillingReportRequest request)
{
// 先把原始明細換算成金額;數量為 0 的資料不進報表。
var calculatedLines = request.LineItems
.Where(item => item.Quantity > 0)
.Select(item => new CalculatedLine(
item.Code,
item.CategoryCode,
item.Quantity,
Round(item.Quantity * item.UnitPrice, request.RoundingMode)))
.ToList();
var subtotal = calculatedLines.Sum(line => line.Amount);
// 會員等級不是原封不動搬到 response,還會決定折扣。
var discountRate = request.MemberTier switch
{
MemberTier.Gold => 0.05m,
MemberTier.Silver => 0.02m,
_ => 0m,
};
var discount = Round(
subtotal * discountRate,
request.RoundingMode);
var amountAfterDiscount = subtotal - discount;
// 服務費、稅制和捨入模式共同決定最後金額。
var serviceFee = request.IncludeServiceFee
? Round(amountAfterDiscount * request.ServiceFeeRate,
request.RoundingMode)
: 0m;
var taxableAmount = amountAfterDiscount + serviceFee;
var tax = request.TaxMode switch
{
TaxMode.Exclusive => Round(
taxableAmount * request.TaxRate,
request.RoundingMode),
TaxMode.Inclusive => Round(
taxableAmount - taxableAmount / (1 + request.TaxRate),
request.RoundingMode),
_ => 0m,
};
var total = request.TaxMode == TaxMode.Exclusive
? taxableAmount + tax
: taxableAmount;
// 付款期限不是 request 裡的現成日期,要從結算月份月底往後推。
var periodEnd = DateOnly.ParseExact(
$"{request.Period}-01",
"yyyy-MM-dd",
CultureInfo.InvariantCulture)
.AddMonths(1)
.AddDays(-1);
var dueDate = periodEnd.AddDays(request.PaymentTermDays);
var entries = BuildEntries(
calculatedLines,
request,
discount,
serviceFee,
tax);
return new BillingReport
{
ReportId = Guid.NewGuid(),
GeneratedAt = TimeZoneInfo.ConvertTime(
TimeProvider.System.GetUtcNow(),
TimeZoneInfo.FindSystemTimeZoneById(request.TimeZone)),
DueDate = dueDate,
Total = total,
Entries = entries,
};
}
// ... 完整程式碼歡迎到 Github 索取
}
這組 request 進來後,原始明細會先算出小計,再依序套用金卡折扣 5%、服務費 6% 與外加稅 5%,最後產生到期日、總額與明細報表。
假設我們正準備替它做完整的特徵測試的話,會極度痛苦:
[Fact]
public void 產生會員對帳報表_逐筆比對()
{
var generator = new BillingReportGenerator();
var report = generator.Generate(CreateGoldMemberRequest());
Assert.Equal(new DateOnly(2026, 8, 30), report.DueDate);
Assert.Equal(18_927m, report.Total);
Assert.Equal(7, report.Entries.Count);
Assert.Equal(
new BillingReportEntry(
ReportEntryType.Category, "CATERING", 20, 6_000m),
report.Entries[0]);
Assert.Equal(
new BillingReportEntry(
ReportEntryType.Category, "CLEANING", 1, 800m),
report.Entries[1]);
// ... 略
}
// ... 還有更多測試
這些 Assert.Equal 本身沒有問題,到期日、總額等重要契約,本來就值得驗證,但很麻煩的是很多筆輸出都要各自寫一個 assert,每筆四個值和所在位置也全部抄進測試,再多幾個分類或調整規則,光是這些測試就搞死人了,而且這還只是其中一個測試。
所以!今天容我向各位介紹 Golden Master。
Golden Master 這個名字,借用的是軟體發行業的慣用語,被送進工廠壓製的母碟(Gold Master),所有出廠的複本都從它製作,一旦確認就不再更動。
這個概念被帶進測試領域後,我們保留的是系統在一組固定輸入下產生的完整輸出,未來每次執行測試都會跟這份基準比對。
社群裡有不少說法會把 Characterization Test、Golden Master 以及 Approval Testing 當成同義詞交替使用,但我自己傾向把它們看成不同層次的概念:
這樣區分的好處是讓我們能夠清楚,不是每一個 Characterization Test 都必須使用基準檔快照,只有當輸出包含大量衍生項目、每筆都是可觀察行為、又沒辦法一個一個手寫驗證時,用快照比對才是最實際的選擇,我認為思路並沒有變,做法不同而已。
這三個詞彼此的界線確實很模糊,社群裡大多數討論也是混用的,我擅自做這樣的分類,主要是為了在團隊中可以更方便溝通:
我們用實作 Approval Testing,產生並核准一份 Golden Master,而這份 Golden Master 最終是拿來保護既有行為,扮演 Characterization Test 的角色。
ApprovalTests 工具是一個拿來實踐 Golden Master 概念的工具,採用的正是 Approval Testing 的工作流程,先收到這次執行的輸出結果,人工審查確認後核准為基準檔案,之後再透過差異告訴我們哪些輸入組合的輸出改變了。
我們可以透過以下指令安裝 ApprovalTests :
dotnet add package ApprovalTests
接著我們來書寫測試,因為會有多個會影響行為的輸入,每個組合又會產生一整組 Entries,因此我們使用 CombinationApprovals.VerifyAllCombinations 來幫我們自動的組合出排列組合:
[UseReporter(typeof(DiffReporter))]
public class BillingReportGeneratorTests
{
private static readonly JsonSerializerOptions ApprovalJson = new()
{
Converters = { new JsonStringEnumConverter() },
};
private readonly BillingReportGenerator _generator = new();
[Fact]
public void 產生會員對帳報表_驗證代表性輸入組合()
{
CombinationApprovals.VerifyAllCombinations(
GenerateReportJson,
[MemberTier.Silver, MemberTier.Gold],
[TaxMode.None, TaxMode.Inclusive, TaxMode.Exclusive],
[false, true],
[BreakdownMode.Hidden, BreakdownMode.Itemized, BreakdownMode.Grouped],
[RoundingMode.HalfUp, RoundingMode.ToEven],
[15, 30]);
}
private string GenerateReportJson(
MemberTier memberTier,
TaxMode taxMode,
bool includeServiceFee,
BreakdownMode breakdownMode,
RoundingMode roundingMode,
int paymentTermDays)
{
var request = BillingReportRequestFactory.Create(
memberTier, taxMode, includeServiceFee,
breakdownMode, roundingMode, paymentTermDays);
var report = _generator.Generate(request);
return JsonSerializer.Serialize(ToApproval(report), ApprovalJson);
}
private static BillingReportApproval ToApproval(BillingReport report) =>
new(report.DueDate, report.Total, report.Entries);
}
這裡礙於篇幅只展示測試的程式碼,相關 Helper 同樣會一起放在 Github 上,那麼我們來解說一下程式碼:
[UseReporter(typeof(DiffReporter))] 裝飾在整個測試類別上,告訴 ApprovalTests 失敗時自動用 diff 工具並排開啟 received 與 approved,差異會直接在螢幕上展開,不用翻 log 找哪一行不對。
JsonStringEnumConverter 則可以讓 enum 值在 snapshot 裡以字串呈現,而不是數值,這份 snapshot 最終是要給人審查的,例如顯示"Gold" 比 1 好讀太多了。
CombinationApprovals.VerifyAllCombinations 的第一個參數是實際執行的 delegate,後面每個陣列各代表一個輸入維度,它會自動展開所有排列組合,逐一呼叫 delegate,把全部輸出合併成同一份基準快照。
BillingReportRequestFactory 負責把六個關鍵維度填進一個完整的 BillingReportRequest,其他二十多個欄位統一給預設值,每次組合只換測試在乎的維度,不需要在測試裡把整份 request 一遍一遍重新宣告,所以要測到多細,完全看實務上的程式碼結構如何。
ToApproval 則負責把 BillingReport 轉成只包含業務相關欄位的快照物件。
因此這支測試的六個輸入會形成 2 × 3 × 2 × 3 × 2 × 2 = 144 種組合,每一種又可能產生數筆 Entries,如果今天我們手寫這些測試的話,我們得維護 144 組不同輸入的排列組合,透過 ApprovalTests 則會保留完整結果,讓我們把時間花在審查 diff,而不是抄寫數百段重複的測試程式碼。
簡直是造福各位啊!那麼我們這時候就能夠來執行測試看看!
第一次執行測試時,會因為專案裡還沒有任何核准過的基準而亮紅燈,這是正常的,ApprovalTests 會先把這次實際收到的結果輸出成這個檔案:
BillingReportGeneratorTests.產生會員對帳報表_驗證代表性輸入組合.received.txt
這份 .received.txt 只代表「這次測試跑出來的結果」,但這個結果還沒有被我們當作 Golden Master。
所以我們需要先檢查內容後,再手動將它改名為:
BillingReportGeneratorTests.產生會員對帳報表_驗證代表性輸入組合.approved.txt
.approved.txt 才是我們接受過後的基準,之後再跑測試,ApprovalTests 會把執行結果和 approved file 拿來比較:
.approved.txt 裡的每一行對應一個輸入組合,格式是「label => JSON 輸出」,以其中兩行為例:
[Silver, None, False, Grouped, HalfUp, 30] => {"DueDate":"2026-08-30","Total":14800,"Entries":[{"Type":"Category","Code":"CATERING","Quantity":20,"Amount":6000},{"Type":"Category","Code":"VENUE","Quantity":2,"Amount":8000}]}
[Gold, Exclusive, True, Grouped, HalfUp, 30] => {"DueDate":"2026-08-30","Total":18927,"Entries":[{"Type":"Category","Code":"CATERING","Quantity":20,"Amount":6000},{"Type":"Category","Code":"VENUE","Quantity":2,"Amount":8000},{"Type":"Discount","Code":"DISCOUNT_GOLD","Quantity":1,"Amount":-895},{"Type":"ServiceFee","Code":"SERVICE_FEE","Quantity":1,"Amount":1020},{"Type":"Tax","Code":"TAX_EXCLUSIVE","Quantity":1,"Amount":901}]}
//... 略
label 由 ApprovalTests 從每個參數的 .ToString() 自動組成,中括號內的順序就是 VerifyAllCombinations 陣列的順序,右邊則是 GenerateReportJson 回傳的字串,這次測試執行的 144 組輸出會被我們定義為 Golden Master,之後每次執行測試,產出的結果都會再與這份 .approved.txt 進行比對。
這兩種檔案的版控規則也很簡單:
.received.* 是每次執行的暫存結果,加進 .gitignore 不追蹤.approved.* 是核准過的基準,跟著專案一起提交進版控我們來做個實驗,稍微把程式碼改壞看看,假設我們把 production code 的金卡折扣從 5% 改成 6%:
var discountRate = request.MemberTier switch
{
// MemberTier.Gold => 0.05m,
MemberTier.Gold => 0.06m,
MemberTier.Silver => 0.02m,
_ => 0m,
};
接著執行測試看看:

除了看到測試紅燈之外,也能看到 IDE 展示了所有與 Golden Master 不符的結果。
所以有了這個測試後,未來如果有新需求需要修改程式碼,大概流程會是這樣的:
步驟 3 就是重新確認基準,這個動作一定要做,先把 diff 看完,確認每一項差異都有對應的需求,才更新 .approved.txt,而不是每次看到紅燈的反應都是「反正這次有改,直接全接受」,那 Golden Master 相對來說就會變成是一種假性保護了,要特別注意。
這項技術最需要記住的是,.approved.txt 不會因為檔名就代表它是正確答案,如果第一份基準裡的資料就算錯了,Golden Master 就會很盡責地幫我們保護那個錯誤,所以使用上也要特別的注意。
另外組合數也並不是越多越好,Golden Master 的價值建立在「人真的能審查輸出」這件事上,如果排列組合膨脹到沒有人看得完 snapshot,就應該重新挑選代表性的輸入,或拆成數個較小的 Approval Test。
佩涅洛佩不是因為一張床就認出奧德修斯,而是她知道哪些細節重要,基準檔也一樣,只有被理解、被審查,而不是被盲目覆蓋,才真的能保護系統。
明天我們繼續看:又是API在搞