iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

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

Day 08 -「赫菲斯托斯的網」客戶已經火大了,怎麼寫測試? (下)

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201825647xPTu1O1us.png

有一天,太陽神赫利俄斯跑去找赫菲斯托斯,對他說:

「我剛剛看到你老婆阿芙蘿黛蒂,跟戰神阿瑞斯在一起。」

換作一般人,可能已經衝去現場吵翻天了,但赫菲斯托斯沒有,他是鍛冶神,處理問題的方式也很有鍛冶神的風格,他當下做的第一件事就是回工坊開火爐。

他打造了一張網細得像蜘蛛絲,連神都幾乎看不見,怎麼扯也扯不斷,接著把網藏在寢室的床柱與屋樑之間,對外宣布:

「我要去勒姆諾斯島一趟。」

阿瑞斯看他走遠,馬上跑去找阿芙蘿黛蒂,兩人一躺上床,藏好的網立刻纏了上來,兩人立刻手抬不起來、腳也動不了,這才發現事情不妙了。

赫菲斯托斯回來後,還把奧林帕斯的眾神叫來圍觀,眾神們笑成一團。

這讓我很欣賞赫菲斯托斯的做法,不要跟難以正面處理的對手硬碰硬

老闆說

有天早上,PM 笑笑地走過來,那個笑容看起來像是在說:「先講好,我只是來傳話的。」

「老闆說,明天上『春季促銷』,三檔活動有優先順序,有些可以疊、有些互斥,規格等等給你。」

在公司,「老闆說」這三個字不是需求來源,而是神旨,它不會問我們「明天做不做得到」,而是宣告天命已下,剩下的請凡人自己想辦法 TT

還在腦中盤算要改哪裡時,他從茶水間回來後,又補一刀:

「對了,老闆還說,所有訂單處理都要留 log,開始、成功、失敗都要記。」

下午會議一結束,PM 第三度現身:

「還有,老闆說門市系統也要呼叫同一套 Process,不管是櫃檯送單還是後台補單,處理完都要多印一張交接單,普通路口不用。」

離開前還不忘提醒:

「記得喔,是明天喔!」

終於忍不住問:「三件都明天?至少要不要先排個優先順序?」

PM 想了兩秒:「老闆說都很重要啊,你應該有辦法吧?之前同樣的工作量不都可以嗎?因為你有能力才交給你做的!」

說完後他像完成任務一樣點點頭,我們確實沒話說,以前產出確實快,但現在還要寫測試,我們有那個美國時間嗎?


先看我們手上有什麼

仔細看這三個需求,其實切入點不太一樣:

  • 春季促銷:一組有資格、互斥、累加與上限的計算規則。
  • log:固定發生在舊流程前後的行為。
  • 門市系統:核心訂單行為相同,但門市入口處理完還要列印交接單。

今日範例一樣是 Process,但是跟昨天的不太相干啦 XD

我們打開程式碼,發現 OrderProcessor 並不乾淨,可以看到後半段大概長成這樣:

public class OrderProcessor
{
    public void Process(Order order)
    {
        // 驗證、小計、折扣、運費與稅……略

        if (!new InventoryDal().TryReserve(order.Items))
        {
            new OrderDal().Save(order);
            new NotificationClient().Send(order, "庫存不足");
            return;
        }

        order.PaymentTransactionId = new PaymentGatewayClient().Charge(
            order.Id,
            order.TotalAmount,
            order.PaymentToken);

        order.ProcessedAt = DateTime.Now;
        new OrderDal().Save(order);
        new NotificationClient().Send(order, "訂單完成");
    }
}

這裡不是建構子注入了很多依賴,甚至根本沒有可以替換依賴的注入點,真正的問題是 Process 中有太多直接依賴了,我們只想測一個「剛好 300 元」的促銷,卻得被迫先處理一堆跟促銷無關的東西。

沒關係,我們在 Day05 ~ 06 兩天已經學到怎麼透過覆寫技巧來處理這個問題,已經熟練到不能再熟練了!

沒錯,是這樣沒錯,但客戶很急,眼前有太多依賴我們要去做,況且程式碼不是只有 10 幾行的小蛋糕,講更精準一點,如果時間允許,技術上都做得到,但是我們沒辦法在很快的時間及單元測試的保護之內完成所有事情。


為什麼今天不使用新生方法

昨天做過 Sprout Method,那第一個需求簡單,我們就在 OrderProcessor 裡加一個 method 就好:

private decimal CalculateSpringDiscount(Order order)
{
    // 判斷春季折扣
}

如果只是幾行單純判斷,把它留在 OrderProcessor 裡當成新生方法並沒有技術上的問題;我們可以讓它保持 private,經由 Process 間接驗證,或在真的需要直接測試時提供合理的測試接合點。

但這次不是一條小判斷。三檔活動同時包含資格、互斥、累加與折扣上限,已經形成一個可以獨立命名的責任。若把它繼續塞進 OrderProcessor,這個原本就充滿 DAL、付款、通知與時間依賴的 class 只會再多背一套促銷規則;即使勉強開出測試入口,也沒有讓職責變得更清楚。

所以今天不使用 Sprout Method,不是因為做不到,而是這段新行為已經大到值得住進自己的 class,接下來要學的就是 Sprout Class


獨棟透天 Sprout Class

PM 還算人很好,還把春季促銷規格整理給我們:如下:

  • SPRING_A:訂單小計超過 1000 元,折 100 元。
  • SPRING_B:VIP 客戶專屬,滿 500 元折 50 元。
  • SPRING_C:春季新品累計超過 300 元,折 50 元。
  • 符合 A 時只套用 A;未符合 A 時,B 與 C 可以同時套用。
  • 春季促銷總折扣不得超過訂單小計的 15%,上限無條件捨去至整數。
  • 既有滿 5000 元九五折另外累加,而且兩種優惠都依原始小計計算,九五折不計入春季促銷的 15% 上限。
  • 折扣累計不影響後續運費及稅務計算

先標出接合點

我們可以看到舊流程中 滿5000折扣,是透過 discount 變數去做折扣累加的,所以新生類別就只需要接收訂單並回答「春季折扣是多少」就可以了,我們先在折扣累加處寫下預期呼叫,但暫時保持註解:

var discount = 0m;

// discount += new SpringPromotionCalculator(order).Calculate();

discount += subtotal >= 5_000m
    ? Math.Round(subtotal * 0.05m, 0, MidpointRounding.AwayFromZero)
    : 0m;

接著建立一個沒有任何 DAL、付款、通知或時鐘依賴的新 class:

public sealed class SpringPromotionCalculator(Order order)
{
    public decimal Calculate()
    {
        throw new NotImplementedException();
    }
}

現在促銷邏輯已經可以離開 OrderProcessor,單獨被放進測試框架了。

沒有符合任何優惠

來寫第一支測試,可以自己選擇要從哪一個功能開始,這完全取決於各位寫程式的習慣,而為了讓文章跟規格一樣順順的往下走,我就從什麼都不做開始:

[Fact]
public void 沒有達成任何條件_折扣為0()
{
    var order = new Order { Subtotal = 1000m };
    var sut = new SpringPromotionCalculator(order);

    var result = sut.Calculate();

    Assert.Equal(0m, result);
}

先確認是因為 NotImplementedException 亮紅燈,再來將實作補上:

public decimal Calculate()
{
    return 0;
}

雖然看似簡單且無趣,但是其實我們在這一個測試故意去測兩個邊界:

  1. 沒有符合折扣條件,就沒有折扣
  2. 剛好 1000 也不會觸發 A 優惠

當然也可以分成兩個測試,但我自己覺得倒也不必,因為等等加上 A 優惠的時候就能知道這個邊界是否有效了。

SPRING_A

正如同上面所說,我們趕緊來加上下一個測試:

[Fact]
public void 超過1000_套用A()
{
    var order = new Order { Subtotal = 1001m };
    var sut = new SpringPromotionCalculator(order);

    var result = sut.Calculate();

    Assert.Equal(100m, result);
}

確認紅燈是因為我們還沒有去實作它後,就可以趕快完成它:

public decimal Calculate()
{
    if (order.Subtotal > 1000m)
        return 100m;
    return 0m;
}

我們會發現,如果這時候我們眼殘去把 > 寫成 >=,那 超過1000_套用A 這個測試還是會過,而另一個則會跳出紅燈提醒我們,這就是為什麼一開始的劃分邊界其實非常重要,不過並不是說一開始劃分得不好就是什麼十惡不赦的事情,事後再補當然也可以,只是就問題晚點才會發現而已。

綠燈之後,再稍微重構一下測試,加速我們去加後面的部分:

public class SpringPromotionCalculatorTests
{
    [Fact]
    public void 沒有達成任何條件_折扣為0() => AssertDiscount(OrderOf(1000m), 0m);

    [Fact]
    public void 超過1000_套用A() => AssertDiscount(OrderOf(1001m), 100m);

    private Order OrderOf(decimal subtotal)
    {
        return new Order
        {
            Subtotal = subtotal
        };
    }

    private void AssertDiscount(Order order, decimal expected)
    {
        var sut = new SpringPromotionCalculator(order);
        Assert.Equal(expected, sut.Calculate());
    }
}

SPRING_B

這個優惠的判斷跟 A 不一樣,這個是金額有到不用超過,所以我們要用兩組測資來測:

[Theory]
[InlineData(500, 50)]
[InlineData(501, 50)]
public void VIP客戶_滿500_套用B(decimal subtotal, decimal expected)
    => AssertDiscount(OrderOf(subtotal, isVip: true), expected);

private Order OrderOf(decimal subtotal, bool isVip = false)
{
    return new Order
    {
        Subtotal = subtotal,
        Customer = new Customer { IsVip = isVip }
    };
}

此時,假設為了讓剛加入的 B 測試通過,我們一時手快只寫了金額判斷:

if (order.Subtotal >= 500)
    return 50m;

剛加入的 B 方案測試確實會綠燈,但最早「沒有達成優惠條件」的測試會立刻紅燈,因為非 VIP 的 1000 元訂單原本應回傳 0 元,現在卻變成 50 元。

這就是一開始先找出大邊界的好處,後面新增規格時會來得輕鬆很多,所以確認測試因為什麼而紅燈這件事情真的非常重要。

接著就實作正確邏輯讓測試通過吧!

if (order.Customer.IsVip && order.Subtotal >= 500)
    return 50m;

SPRING_C

C 的判斷條件比前兩檔多繞了一層:它不看整張訂單小計,而是只把「春季新品」的金額累加起來,再跟 300 元比。所以測資不能只丟一個數字,得刻意混搭幾種品項,讓測試自己證明三件事:

  1. 只算春季新品:中間那筆 CreateItem(100, 1) 沒標記為新品,如果實作不小心把它也加進去,金額就會爆表,測試就會抓到。
  2. 金額要乘數量CreateItem(100, 2, true) 是單價 100、數量 2,累計 200,逼實作老實寫 UnitPrice * Quantity,而不是只加單價。
  3. 卡在邊界剛好過關:200 加上 CreateItem(101, 1, true) 的 101,剛好是 301,踩在「超過 300」的邊界上一元,剛好回答「跨過門檻才折」這個問題。
[Fact]
public void 春季新品累計超過300_套用C()
    => AssertDiscount(
        OrderOf(1000m, false,
            CreateItem(100, 2, true),
            CreateItem(100, 1),
            CreateItem(101, 1, true)
        ), 50);

private Order OrderOf(
    decimal subtotal,
    bool isVip = false,
    params OrderItem[] items)
{
    return new Order
    {
        Subtotal = subtotal,
        Customer = new Customer { IsVip = isVip },
        Items = items.ToList()
    };
}

private OrderItem CreateItem(decimal amount, int quantity,  bool isSpringNewProduct = false) =>
    new()
    {
        UnitPrice = amount,
        Quantity = quantity,
        IsSpringNewProduct = isSpringNewProduct
    };

確認 300 元案例先得到 0 的紅燈後,加入 C 的最小實作:

if (order.Items
    .Where(item => item.IsSpringNewProduct)
    .Sum(item => item.UnitPrice * item.Quantity) > 300m)
{
    return 50m;
}

加上剛好 300 的邊界測試:

[Fact]
public void 春季新品累計剛好300_沒有折扣()
    => AssertDiscount(
        OrderOf(400m, false,
            CreateItem(100, 2, true),
            CreateItem(100, 1),
            CreateItem(100, 1, true)
        ), 0);

重構一下

三檔活動的測試現在都是綠燈,這正是重構最安全的時機。目前 Calculate 裡塞滿了 SubtotalIsVipWhere...Sum 這些細節,判斷「符不符合」和「折多少」混在一起,讀起來得先在腦中翻譯一遍。趁著還沒疊上「B、C 能疊」「15% 上限」這些規格,我們先把每個資格判斷抽成一個名字說得清楚的方法,讓 Calculate 只留下「符合 A 折 100、否則看 B 和 C」這層意圖。

重構不是為了現在好看,而是為了走更長遠的路,等一下規格會一條一條加上來,如果現在不先把資格判斷收乾淨,後面每加一條規則都得在一坨 if 裡面翻找。

public decimal Calculate()
{
    if (IsEligibleForSpringA())
        return 100m;
    if (IsEligibleForSpringB())
        return 50m;
    if(IsEligibleForSpringC())
        return 50m;
    return 0m;
}

private bool IsEligibleForSpringA()
    => order.Subtotal > 1000m;

private bool IsEligibleForSpringB()
    => order.Customer.IsVip && order.Subtotal >= 500;

private bool IsEligibleForSpringC()
    => order.Items.Where(item => item.IsSpringNewProduct)
        .Sum(item => item.UnitPrice * item.Quantity) > 300m;

B、C 能疊,A 不能疊

目前 B 一符合就直接回傳,所以 B、C 同時符合時只會折到 50 元,這支測試會先把問題抓出來:

[Fact]
public void B與C同時符合_累加兩檔折扣()
    => AssertDiscount(
        OrderOf(900m, isVip: true, CreateItem(301, 1, true)),
        100m);

亮紅燈後實作:

public decimal Calculate()
{
    if (IsEligibleForSpringA())
        return 100m;
    var discount = 0m;
    if (IsEligibleForSpringB())
        discount += 50m;
    if(IsEligibleForSpringC())
        discount += 50m;
    return discount;
}

15% 上限

上限是「折扣不能超過小計的 15%」,要測到它,就得湊出一張「原始折扣已經大於小計 15%」的訂單。這裡有個巧妙的地方:A 和 B 其實湊不出來。A 要成立,小計得先超過 1000,光 15% 就有 150 以上,可是 A 只折 100,永遠碰不到天花板;B 要成立,小計得滿 500,15% 至少 75,而 B 只折 50,一樣頂不到。

只有 C 不一樣,它看的是「春季新品累計超過 300」,跟整張訂單的小計脫鉤,所以我們可以做出一張小計只有 301、卻剛好命中 C 的訂單,讓 50 元折扣去撞 15% 的上限。換句話說,C 是唯一一個能靠自己一檔就超過上限的規則,測到它就足以驗證這個共用出口有沒有把折扣壓下來。

最後讓一張 301 元訂單命中 C,活動原始折扣是 50 元,但 Math.Floor(301m * 0.15m) 只有 45 元:

[Fact]
public void 春季折扣超過小計百分之15_限制折扣並捨去小數()
    => AssertDiscount(
        OrderOf(301m, false, CreateItem(301, 1, true)),
        45m);

這個測試會先得到 50 折扣的紅燈,接著讓所有春季折扣走同一個出口:

public decimal Calculate()
{
    // ... 略
    return ApplyDiscountCap(discount);
}

private decimal ApplyDiscountCap(decimal discount)
{
    var maximumDiscount = Math.Floor(order.Subtotal * 0.15m);
    return Math.Min(discount, maximumDiscount);
}

到這裡功能也就完成了,還剩下最後一步,

稍微整理一下程式碼

上一步的 ApplyDiscountCap 雖然寫好了,但 Calculate 其實還沒真正把它用上,而且「A 互斥、B 與 C 可疊、最後套 15% 上限」這三件事全擠在同一個 method 裡,讀的人得一路盯著 discount 變數跑,才拼得出整檔活動的規則。

這一步我們不加任何新功能,只趁著三檔活動都還是綠燈,把意圖攤平成兩層,先把「可以疊加的 B 和 C」收進 CalculateStackableDiscount,讓 Calculate 用一個三元運算子就把主線講完,「符合 A 就折 100,否則加總可疊加的折扣」再讓所有折扣都從 ApplyDiscountCap 這個唯一出口離開。

public class SpringPromotionCalculator(Order order)
{
    public decimal Calculate()
    {
        var discount = IsEligibleForSpringA()
            ? 100m
            : CalculateStackableDiscount();

        return ApplyDiscountCap(discount);
    }

    private decimal CalculateStackableDiscount()
    {
        var discount = 0m;

        if (IsEligibleForSpringB())
            discount += 50m;

        if (IsEligibleForSpringC())
            discount += 50m;

        return discount;
    }

    private bool IsEligibleForSpringA()
        => order.Subtotal > 1000m;

    private bool IsEligibleForSpringB()
        => order.Customer.IsVip && order.Subtotal >= 500m;

    private bool IsEligibleForSpringC()
        => order.Items
            .Where(item => item.IsSpringNewProduct)
            .Sum(item => item.UnitPrice * item.Quantity) > 300m;

    private decimal ApplyDiscountCap(decimal discount)
    {
        var maximumDiscount = Math.Floor(order.Subtotal * 0.15m);
        return Math.Min(discount, maximumDiscount);
    }
}

看到 A、B、C 三檔活動,很容易手癢想抽 IPromotionRule 介面、每檔一個 class,再塞進一個規則引擎跑迴圈。但先停一下想想:現在到底有幾條規則?就三條,而且它們彼此還有「A 不能疊、B 和 C 能疊、最後套 15% 上限」這種互相牽連的關係,不是一組整齊、可以無腦迭代的同構規則,硬套模式讀者反而更難一眼看懂這檔活動在做什麼。

現在這個版本,Calculate 三行就講完整個故事,細節則各自躲在名字取好的私有方法裡,我覺得剛好落在「意圖清楚」和「不過度設計」之間,而不是為了展示模式而模式。

最後再跑一次綠燈就完成了!

是不是很讚!其實真的還蠻實用的,不過正如一直以來我們所討論的 權衡,新生類別的代價當然是多了一個 class 與概念,有些功能從理想設計來看未必值得獨立成類別,只是我們現在背靠著牆,寧願先讓新 code 有安全網,也不要直接把它寫進舊流程裡,所以這不是永遠比較漂亮的設計,而是被迫時程壓力下比較能承擔風險的選擇。


包前包後 Wrap Method

接下來輪到 log,需求本身不複雜:

  • 訂單開始處理時記一筆。
  • 處理成功時記一筆。
  • 處理失敗時記一筆,並保留原本的例外。

那麼我們是不是可以在 Process 開頭、結尾和每個錯誤出口到處塞 log,不過 log 與「訂單怎麼處理」是兩個不同的事務,它只是包在主流程前後,沒有必要鑽進整段商業邏輯裡到處插旗,況且這樣也會讓職責越來越多。

所以我們就要用到 包裹方法(Wrap Method),跟新生技術不同的是,新生技術是從內向外長出去的,而包裹技術比較像是在外面直接生長,說來蠻有趣的,我們就直接來實作吧!

先定義我們接下來會用到的 Logger 介面:

public interface IProcessLogger
{
    void WriteStarted(int processId, string message);
    void WriteSucceeded(int processId, string message);
    void WriteFailed(int processId, string message, Exception exception);
}

接著使用重構工具中的 Rename,把原本的 Process 改名為 ProcessCore,再建立同名的 Process method 包住它,此時程式碼就會變成這樣:

public void Process(Order order)
{
    ProcessCore(order);
}

protected virtual void ProcessCore(Order order)
{
    // ... 略
}

接著我們就在測試中用子類覆寫它,讓 Process 的包裝行為單獨接受測試:

public class OrderProcessorLogTests
{
    private readonly Mock<IProcessLogger> _mockLogger = new();

    [Fact]
    public void 訂單開始處理_呼叫WriteStarted()
    {
        var sut = new NoOpOrderProcessor(_mockLogger.Object);

        sut.Process(new Order { Id = 42 });

        _mockLogger.Verify(l => l.WriteStarted(42, It.IsAny<string>()), Times.Once);
    }

    [Fact]
    public void 處理成功_呼叫WriteSucceeded()
    {
        var sut = new NoOpOrderProcessor(_mockLogger.Object);

        sut.Process(new Order { Id = 42 });

        _mockLogger.Verify(l => l.WriteSucceeded(42, It.IsAny<string>()), Times.Once);
    }

    [Fact]
    public void 處理失敗_呼叫WriteFailed並重拋例外()
    {
        var sut = new ThrowingOrderProcessor(_mockLogger.Object);

        Assert.Throws<InvalidOperationException>(() => sut.Process(new Order { Id = 42 }));

        _mockLogger.Verify(l => l.WriteFailed(42, It.IsAny<string>(), It.IsAny<Exception>()), Times.Once);
    }

    private sealed class NoOpOrderProcessor(IProcessLogger logger) : OrderProcessor(logger)
    {
        protected override void ProcessCore(Order order) { }
    }

    private sealed class ThrowingOrderProcessor(IProcessLogger logger) : OrderProcessor(logger)
    {
        protected override void ProcessCore(Order order) =>
            throw new InvalidOperationException("模擬失敗");
    }
}

這次要驗證的不是回傳值,而是呼叫端有沒有在正確的路徑呼叫 Logger 方法,以及錯誤時是否有記錄。

Mock 會把測試執行期間收到的呼叫記下來,Verify 則是在執行完成後檢查這份紀錄,像

Verify(logger => logger.WriteStarted(42, It.IsAny<string>()), Times.Once);

其實就是在驗證「WriteStarted 有沒有用訂單編號 42 呼叫,而且剛好一次?」,只要 method 沒被呼叫、參數不符合,或次數不是預期的數量,測試就會紅燈。

所以 Verify 檢查的不是計算結果,而是物件之間有沒有發生預期的互動。

了解在幹嘛後,我們逐步完成並通過測試:

public void Process(Order order)
{
    logger.WriteStarted(order.Id, "訂單開始處理");
    try
    {
        ProcessCore(order);
        logger.WriteSucceeded(order.Id, "訂單處理成功");
    }
    catch (Exception ex)
    {
        logger.WriteFailed(order.Id, "訂單處理失敗", ex);
        throw;
    }
}

這就是 Wrap Method,原本所有呼叫端仍然只需要呼叫 Process,把舊內容移到 ProcessCore 後,我們也能在測試中覆寫它,我們就可以在時間有限的時候,同時能夠驗證到新功能。


不同外衣 Wrap Class

第三個需求大概是這樣:

log 是所有訂單都需要,交接單卻只有門市入口需要,那麼可能我們就勢必還得要做判斷:

orderProcessor.Process(order);

if (isFromStore)
    printer.Print(order);

這段 code 沒錯,但它會強制要所有呼叫端把「門市訂單處理完一定要印交接單」的責任都記住。

如果真的只有一個呼叫點,而且這個 method 很容易測,這樣做確實快速又簡單,不過這次同一套門市規則要給兩個入口使用,我們也希望呼叫端不需要知道這麼多細節的話,我們可以採用 包裹類別(Wrap Class)

首先,我們替原本的 OrderProcessor 加上一個共同介面:

public interface IOrderProcessor
{
    void Process(Order order);
}

public class OrderProcessor(IProcessLogger logger) : IOrderProcessor
{
    public void Process(Order order)
    {
        // ... 略
    }

    protected virtual void ProcessCore(Order order)
    {
        // ... 略
    }
}

一樣先寫測試

先定義列印門市交接單的介面:

public interface IStoreOrderSlipPrinter
{
    void Print(Order order);
}

接著做一個 IOrderProcessor 的測試替身,第一支測試確認門市處理器會把工作交給舊流程,完成後再列印交接單,第二支測試則刻意讓舊流程拋出例外,只要 inner.Process 沒有順利走完,就不能列印交接單,而且原本的例外還要繼續交給呼叫端處理。

public class StoreOrderProcessorTests
{
    private readonly Mock<IOrderProcessor> _mockInner = new();
    private readonly Mock<IStoreOrderSlipPrinter> _mockPrinter = new();

    private StoreOrderProcessor CreateStoreOrderProcessor()
        => new(_mockInner.Object, _mockPrinter.Object);

    [Fact]
    public void 門市訂單處理完成_列印交接單()
    {
        var order = new Order { Id = 42 };
        var sut = CreateStoreOrderProcessor();

        sut.Process(order);

        _mockInner.Verify(processor => processor.Process(order), Times.Once);
        _mockPrinter.Verify(printer => printer.Print(order), Times.Once);
    }

    [Fact]
    public void 原本流程失敗_不列印交接單並重拋例外()
    {
        var order = new Order { Id = 42 };

        var expectedException = new InvalidOperationException("模擬失敗");
        _mockInner
            .Setup(processor => processor.Process(order))
            .Throws(expectedException);
        var sut = CreateStoreOrderProcessor();

        var actualException = Assert.Throws<InvalidOperationException>(() => sut.Process(order));

        Assert.Same(expectedException, actualException);

        _mockPrinter.Verify(
            printer => printer.Print(It.IsAny<Order>()),
            Times.Never);
    }
}

讓測試通過:

public sealed class StoreOrderProcessor(
    IOrderProcessor inner,
    IStoreOrderSlipPrinter printer) : IOrderProcessor
{
    public void Process(Order order)
    {
        inner.Process(order);
        printer.Print(order);
    }
}

學過設計模式的朋友看到這裡,應該已經想舉手了:「這不就是 Decorator?」

https://ithelp.ithome.com.tw/upload/images/20260807/20182564SCrCbhZdrN.png
圖片擷取自網路

對,結構上幾乎一樣,StoreOrderProcessor 持有一個 IOrderProcessor,從外面包住原始邏輯再疊加新行為,不過嘛...知道在幹嘛而且可以達到我們的目的最重要了。

這就是 Wrap ClassStoreOrderProcessor 和既有的 OrderProcessor 都實作 IOrderProcessor,對呼叫端來說兩者都有同一個 Process,差別只是門市版本在舊流程外面多包了一個列印步驟。

最後,我們只需要在 DI 容器中註冊,在請求進來就會依照入口而決定要使用哪一個 Processor:

// Program.cs
builder.Services.AddScoped<IProcessLogger, DatabaseProcessLogger>();
builder.Services.AddScoped<IStoreOrderSlipPrinter, ThermalReceiptPrinter>();

// 一般入口
builder.Services.AddScoped<IOrderProcessor, OrderProcessor>();

// 門市入口
builder.Services.AddKeyedScoped<IOrderProcessor>("store", (sp, _) =>
    new StoreOrderProcessor(
        sp.GetRequiredService<IOrderProcessor>(),
        sp.GetRequiredService<IStoreOrderSlipPrinter>()));

Controller 就依入口不同,取得不同的實作:

public class OrderController(IOrderProcessor processor) { ... }

public class StoreOrderController(
    [FromKeyedServices("store")] IOrderProcessor processor) { ... }

總結

這兩天我們學會了四個技巧可以讓我們來對付客戶,在這些情境下,都保持著一種信念:

不要因為暫時改不動舊的,就讓新需求也跟著同流合污。

它們都不是品質與速度兼得的銀彈,而是在兩者之中權衡而生的手法,它們也都沒有讓原本的 Legacy Code 突然獲得完整測試,但至少它做到了三件事:

  • 新規則有明確的輸入、輸出與測試。
  • 新行為沒有繼續加入到巨大 method。
  • 哪些地方已經被保護、哪些地方還欠驗證,都說得還算清楚。

赫菲斯托斯沒有靠蠻力打贏最能打的阿瑞斯,而是把每一個環節想好,再把網子放在真正有效的位置,面對 Legacy Code,我們也不需要一天之內制服整條舊流程,先把每個可控制、可測試的接合點做好,對以前都不這麼做直接硬幹的我們來說,就已經是如魚得水了。

寫了這麼多 Code,大家也累了,明天主要閒聊一天,重整旗鼓再往下繼續走。

明天我們繼續看:為什麼效率總是這麼差?

Reference


上一篇
Day 07 -「龍牙戰士」客戶已經火大了,怎麼寫測試? (上)
下一篇
Day 09 -「杜卡利翁木箱」為什麼改個按鈕要三天?
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言