iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

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

Day 25 -「佩涅洛佩的婚床」製作 Golden Master

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260824/20182564cwMulMPOP5.png

奧德修斯離開家二十年,先打了十年特洛伊戰爭,回程又在海上繞了十年,好不容易回到伊薩卡,卻發現家裡早就變了樣,大家都當他已經死了,一群求婚者住進宮殿,天天逼他的妻子佩涅洛佩改嫁。

奧德修斯處理完那群求婚者,終於站到妻子面前,但佩涅洛佩沒有馬上相認,二十年真的太久了,眼前的人即使說得出自己是誰,她也不敢全盤相信,萬一認錯人,這可不是尷尬一下就算了。

於是她故意吩咐僕人,把兩人的婚床搬出房間。

奧德修斯一聽就生氣了,因為那張床根本搬不動,當年他沒有把一棵橄欖樹砍掉,而是直接把活著的樹幹做成床柱,再繞著它蓋起整間房間,除非有人先把橄欖樹從根部砍斷,不然誰也別想把床搬走。

佩涅洛佩等的就是這個反應,床怎麼做、為什麼搬不動,是兩人才知道的細節,外人不可能會知道,直到這時她才終於確定,奧德修斯真的回來了。


一筆一筆 assert 要寫多久

有些 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 TestGolden Master 以及 Approval Testing 當成同義詞交替使用,但我自己傾向把它們看成不同層次的概念:

  • Characterization Test:把 Legacy Code「目前實際的行為」記錄下來,不是為了證明現在的行為正確,而是確保重構後行為沒有意外改變
  • Golden Master:保留一份當前系統輸出作為基準,後續執行的結果都跟它比對。
  • Approval Testing:執行程式,把輸出存成基準檔,人工審查確認後核准為 Golden Master,之後每次執行都跟這份核准過的基準比對。

這樣區分的好處是讓我們能夠清楚,不是每一個 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,而不是抄寫數百段重複的測試程式碼。

簡直是造福各位啊!那麼我們這時候就能夠來執行測試看看!


製作 Golden Master

第一次執行測試時,會因為專案裡還沒有任何核准過的基準而亮紅燈,這是正常的,ApprovalTests 會先把這次實際收到的結果輸出成這個檔案:

BillingReportGeneratorTests.產生會員對帳報表_驗證代表性輸入組合.received.txt

這份 .received.txt 只代表「這次測試跑出來的結果」,但這個結果還沒有被我們當作 Golden Master。

所以我們需要先檢查內容後,再手動將它改名為:

BillingReportGeneratorTests.產生會員對帳報表_驗證代表性輸入組合.approved.txt

.approved.txt 才是我們接受過後的基準,之後再跑測試,ApprovalTests 會把執行結果和 approved file 拿來比較:

  • 內容相同:測試通過。
  • 內容不同:測試失敗,產生新的 received 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.* 是核准過的基準,跟著專案一起提交進版控

紅燈以後,先看 diff

我們來做個實驗,稍微把程式碼改壞看看,假設我們把 production code 的金卡折扣從 5% 改成 6%:

var discountRate = request.MemberTier switch
{   
	// MemberTier.Gold => 0.05m,
    MemberTier.Gold => 0.06m,
    MemberTier.Silver => 0.02m,
     _ => 0m,
};

接著執行測試看看:

https://ithelp.ithome.com.tw/upload/images/20260824/201825640iCEJAuskn.png

除了看到測試紅燈之外,也能看到 IDE 展示了所有與 Golden Master 不符的結果。

所以有了這個測試後,未來如果有新需求需要修改程式碼,大概流程會是這樣的:

  1. 修改程式碼並執行測試。
  2. 測試失敗:查看 received file 與 approved file 的差異。
  3. 差異若符合需求: received file 更新為 approved file,並進入版控。
  4. 差異若不符合需求:回頭修正程式碼。

步驟 3 就是重新確認基準,這個動作一定要做,先把 diff 看完,確認每一項差異都有對應的需求,才更新 .approved.txt,而不是每次看到紅燈的反應都是「反正這次有改,直接全接受」,那 Golden Master 相對來說就會變成是一種假性保護了,要特別注意。


總結

這項技術最需要記住的是,.approved.txt 不會因為檔名就代表它是正確答案,如果第一份基準裡的資料就算錯了,Golden Master 就會很盡責地幫我們保護那個錯誤,所以使用上也要特別的注意。

另外組合數也並不是越多越好,Golden Master 的價值建立在「人真的能審查輸出」這件事上,如果排列組合膨脹到沒有人看得完 snapshot,就應該重新挑選代表性的輸入,或拆成數個較小的 Approval Test。

佩涅洛佩不是因為一張床就認出奧德修斯,而是她知道哪些細節重要,基準檔也一樣,只有被理解、被審查,而不是被盲目覆蓋,才真的能保護系統。

明天我們繼續看:又是API在搞


Reference


上一篇
Day 24 -「九頭蛇海德拉」有完沒完啊!來自 SQL Server 的鬼打牆
下一篇
Day 26 -「潘朵拉的陶罐」從第三方掛了一路到 Producer–Consumer
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言