iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 29

Day 29 – TUnit 進階應用:資料驅動測試與依賴注入深度實戰

  • 分享至 

  • xImage
  •  

前言

在 Day 28 中,我們初步認識了 TUnit 這個新世代測試框架。從 Source Generator 驅動的測試發現,到 AOT 編譯支援,再到流暢式斷言語法,TUnit 展現了現代測試框架的強大潛力。

但光是會寫基本的 [Test][Arguments] 還不夠。在真實的企業專案中,我們面對的挑戰更加複雜:

實務挑戰清單:

  • 大量測試資料:如何優雅地處理數百組測試案例?
  • 複雜依賴關係:在有多層服務依賴的架構中如何進行測試?
  • 資源生命週期管理:如何確保測試間的資源隔離和正確清理?
  • 測試組織與分類:如何有效管理和執行特定類別的測試?

TUnit 的進階功能正是為了解決這些挑戰而設計的。今天我們要深入探討資料驅動測試、生命週期管理、依賴注入等實務技巧,讓你能夠在工作專案中充分發揮這個框架的威力。

本篇內容

今天的內容有:

  • 掌握資料驅動測試的進階技巧:MethodDataSource、ClassDataSource、Matrix Tests 的實戰應用
  • 建立強健的測試基礎設施:Properties、生命週期管理、依賴注入最佳實踐
  • 深度理解 TUnit 的生命週期管理:從建構式到 Dispose,完整掌握測試資源管理
  • 實作真正的 TUnit 依賴注入:使用原生 DI 功能取代手動 Mock 建立

資料驅動測試進階技巧

在 Day 28 中,我們學會了使用 [Arguments] 進行簡單的參數化測試。但當測試資料變得複雜,或者需要動態產生時,就需要更強大的工具。

TUnit 提供了多種資料來源機制,每一種都有其適用場景:

資料來源方式 適用場景 優勢 注意事項
Arguments 簡單固定資料 語法簡潔 資料量不宜過大
MethodDataSource 動態資料、複雜物件 最大靈活性 需要額外方法定義
ClassDataSource 共享資料、依賴注入 可重用性高 類別生命週期管理
Matrix Tests 組合測試 覆蓋率高 容易產生過多測試

MethodDataSource:方法作為資料來源

MethodDataSource 是最靈活的資料提供方式。它允許我們透過方法來產生測試資料,非常適合需要動態產生或從外部來源載入資料的情況。

基本語法與使用方式

讓我們從一個實際的業務場景開始。假設我們要測試一個訂單服務,需要驗證各種訂單建立情況:

[Test]
[MethodDataSource(nameof(GetOrderTestData))]
public async Task CreateOrder_各種情況_應正確處理(string customerId, CustomerLevel level, List<OrderItem> items, decimal expectedTotal)
{
    // Arrange
    var orderService = new OrderService(_repository, _discountCalculator, _shippingCalculator, _logger);

    // Act
    var order = await orderService.CreateOrderAsync(customerId, level, items);

    // Assert
    await Assert.That(order).IsNotNull();
    await Assert.That(order.CustomerId).IsEqualTo(customerId);
    await Assert.That(order.CustomerLevel).IsEqualTo(level);
    await Assert.That(order.TotalAmount).IsEqualTo(expectedTotal);
}

public static IEnumerable<object[]> GetOrderTestData()
{
    // 一般會員訂單
    yield return new object[]
    {
        "CUST001",
        CustomerLevel.一般會員,
        new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "商品A", UnitPrice = 100m, Quantity = 2 }
        },
        200m
    };

    // VIP會員訂單
    yield return new object[]
    {
        "CUST002", 
        CustomerLevel.VIP會員,
        new List<OrderItem>
        {
            new() { ProductId = "PROD002", ProductName = "商品B", UnitPrice = 500m, Quantity = 1 }
        },
        500m
    };

    // 多商品訂單
    yield return new object[]
    {
        "CUST003",
        CustomerLevel.白金會員,
        new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "商品A", UnitPrice = 100m, Quantity = 1 },
            new() { ProductId = "PROD002", ProductName = "商品B", UnitPrice = 200m, Quantity = 2 }
        },
        500m
    };
}

為什麼選擇 MethodDataSource?

相比於 [Arguments] 的固定資料,MethodDataSource 提供了更多彈性:

  1. 複雜物件支援:可以傳遞 List、Dictionary 等複雜類型
  2. 動態產生:資料可以在執行時期動態計算
  3. 外部來源:可以從檔案、資料庫或 API 載入資料
  4. 可讀性更好:測試資料和測試邏輯分離

從檔案載入測試資料

在真實專案中,測試資料往往存放在外部檔案中。讓我們看看如何從 JSON 檔案載入測試資料:

[Test]
[MethodDataSource(nameof(GetDiscountTestDataFromFile))]
public async Task CalculateDiscount_從檔案讀取_應套用正確折扣(
    string scenario, 
    decimal originalAmount, 
    CustomerLevel level, 
    string discountCode, 
    decimal expectedDiscount)
{
    // Arrange
    var calculator = new DiscountCalculator(new MockDiscountRepository(), new MockLogger<DiscountCalculator>());
    var order = new Order
    {
        CustomerLevel = level,
        Items = [new OrderItem { UnitPrice = originalAmount, Quantity = 1 }]
    };

    // Act
    var discount = await calculator.CalculateDiscountAsync(order, discountCode);

    // Assert
    await Assert.That(discount).IsEqualTo(expectedDiscount);
}

public static IEnumerable<object[]> GetDiscountTestDataFromFile()
{
    var filePath = Path.Combine("TestData", "discount-scenarios.json");
    var jsonData = File.ReadAllText(filePath);
    var scenarios = JsonSerializer.Deserialize<List<DiscountScenario>>(jsonData);
    if (scenarios == null)
    {
        yield break;
    }
    foreach (var s in scenarios)
    {
        yield return new object[] { s.Scenario, s.Amount, (CustomerLevel)s.Level, s.Code, s.Expected };
    }
}

/// <summary>
/// 折扣測試情境對應 JSON 結構
/// </summary>
internal class DiscountScenario
{
    public string Scenario { get; set; } = string.Empty;
    public decimal Amount { get; set; }
    public int Level { get; set; }
    public string Code { get; set; } = string.Empty;
    public decimal Expected { get; set; }
}

對應的 JSON 測試資料檔案 (TestData/discount-scenarios.json):

[
    {
        "Scenario": "一般會員無折扣碼",
        "Amount": 1000,
        "Level": 0,
        "Code": "",
        "Expected": 0
    },
    {
        "Scenario": "VIP會員使用VIP折扣碼",
        "Amount": 1000,
        "Level": 1,
        "Code": "VIP50",
        "Expected": 50
    },
    {
        "Scenario": "白金會員使用SAVE20折扣碼",
        "Amount": 1000,
        "Level": 2,
        "Code": "SAVE20",
        "Expected": 250
    }
]

專案檔案配置 (.csproj):

<ItemGroup>
  <Content Include="TestData\discount-scenarios.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </Content>
</ItemGroup>

實務技巧:

在實際專案中,我們通常會建立一個 TestDataHelper 類別來統一管理測試資料的載入:

public static class TestDataHelper
{
    public static IEnumerable<object[]> LoadFromJson<T>(string fileName, Func<T, object[]> converter)
    {
        var filePath = Path.Combine("TestData", fileName);
        var jsonData = File.ReadAllText(filePath);
        var items = JsonSerializer.Deserialize<T[]>(jsonData);
        
        return items?.Select(converter) ?? Enumerable.Empty<object[]>();
    }
}

ClassDataSource:類別作為資料提供者

當測試資料變得更加複雜,或需要共享給多個測試類別時,ClassDataSource 就派上用場了。它允許我們建立專門的資料提供類別,實現更好的程式碼組織。

基本實作模式

[Test]
[ClassDataSource<OrderValidationTestData>]
public async Task ValidateOrder_各種驗證情況_應回傳正確結果(OrderValidationScenario scenario)
{
    // Arrange
    var validator = new OrderValidator(_discountRepository, _logger);

    // Act
    var result = await validator.ValidateAsync(scenario.Order);

    // Assert
    await Assert.That(result.IsValid).IsEqualTo(scenario.ExpectedValid);
    if (!scenario.ExpectedValid)
    {
        await Assert.That(result.ErrorMessage).Contains(scenario.ExpectedErrorKeyword);
    }
}

public class OrderValidationTestData : IEnumerable<OrderValidationScenario>
{
    public IEnumerator<OrderValidationScenario> GetEnumerator()
    {
        // 有效訂單
        yield return new OrderValidationScenario
        {
            Name = "有效的一般訂單",
            Order = CreateValidOrder(),
            ExpectedValid = true,
            ExpectedErrorKeyword = null
        };

        // 客戶ID為空
        yield return new OrderValidationScenario
        {
            Name = "客戶ID為空",
            Order = CreateOrderWithEmptyCustomerId(),
            ExpectedValid = false,
            ExpectedErrorKeyword = "客戶ID"
        };

        // 商品清單為空
        yield return new OrderValidationScenario
        {
            Name = "沒有商品",
            Order = CreateOrderWithNoItems(),
            ExpectedValid = false,
            ExpectedErrorKeyword = "商品"
        };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    private static Order CreateValidOrder() => new()
    {
        CustomerId = "CUST001",
        CustomerLevel = CustomerLevel.一般會員,
        Items = new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "測試商品", UnitPrice = 100m, Quantity = 1 }
        }
    };

    private static Order CreateOrderWithEmptyCustomerId() => new()
    {
        CustomerId = "",
        CustomerLevel = CustomerLevel.一般會員,
        Items = new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "測試商品", UnitPrice = 100m, Quantity = 1 }
        }
    };

    private static Order CreateOrderWithNoItems() => new()
    {
        CustomerId = "CUST001",
        CustomerLevel = CustomerLevel.一般會員,
        Items = new List<OrderItem>()
    };
}

public class OrderValidationScenario
{
    public string Name { get; set; } = "";
    public Order Order { get; set; } = new();
    public bool ExpectedValid { get; set; }
    public string? ExpectedErrorKeyword { get; set; }
}

AutoFixture 整合:自動化測試資料產生

在企業級測試中,手動建立大量測試資料既繁瑣又容易出錯。AutoFixture 是一個強大的測試資料自動產生工具,能夠與 TUnit 的 ClassDataSource 完美整合。

[Test]
[ClassDataSource(typeof(AutoFixtureOrderTestData))]
public async Task ProcessOrder_自動產生測試資料_應正確計算訂單金額(Order order)
{
    // Arrange
    var discountCalculator = new DiscountCalculator(
        new MockDiscountRepository(),
        new MockLogger<DiscountCalculator>());
    var shippingCalculator = new ShippingCalculator();

    // Act - 實際使用 calculator 計算折扣和運費
    var discountAmount = await discountCalculator.CalculateDiscountAsync(order, "PERCENT10");
    var shippingFee = shippingCalculator.CalculateShippingFee(order);
    
    // 更新訂單金額
    order.DiscountAmount = discountAmount;
    order.ShippingFee = shippingFee;

    // Assert - 驗證隨機產生的資料能正常處理
    await Assert.That(order).IsNotNull();
    await Assert.That(order.CustomerId).IsNotEmpty();
    await Assert.That(order.Items).IsNotEmpty();
    
    // 驗證所有項目都有有效的資料
    foreach (var item in order.Items)
    {
        await Assert.That(item.ProductId).IsNotEmpty();
        await Assert.That(item.ProductName).IsNotEmpty();
        await Assert.That(item.UnitPrice).IsGreaterThan(0);
        await Assert.That(item.Quantity).IsGreaterThan(0);
    }

    // 驗證計算結果的合理性
    var expectedSubTotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
    await Assert.That(order.SubTotal).IsEqualTo(expectedSubTotal);
    
    // 驗證折扣計算(PERCENT10 應該是 10% 折扣)
    var expectedDiscount = order.SubTotal * 0.1m;
    await Assert.That(order.DiscountAmount).IsEqualTo(expectedDiscount);
    
    // 驗證運費計算不為負數
    await Assert.That(order.ShippingFee).IsGreaterThanOrEqualTo(0);
    
    // 驗證總金額計算正確
    var expectedTotal = order.SubTotal - order.DiscountAmount + order.ShippingFee;
    await Assert.That(order.TotalAmount).IsEqualTo(expectedTotal);
}

public class AutoFixtureOrderTestData : IEnumerable<Order>
{
    private readonly Fixture _fixture;

    public AutoFixtureOrderTestData()
    {
        _fixture = new Fixture();
        
        // 自訂 Order 的產生規則
        _fixture.Customize<Order>(composer => composer
            .With(o => o.CustomerId, () => $"CUST{_fixture.Create<int>() % 1000:D3}")
            .With(o => o.CustomerLevel, () => _fixture.Create<CustomerLevel>())
            .With(o => o.Items, () => _fixture.CreateMany<OrderItem>(Random.Shared.Next(1, 5)).ToList()));

        // 自訂 OrderItem 的產生規則
        _fixture.Customize<OrderItem>(composer => composer
            .With(oi => oi.ProductId, () => $"PROD{_fixture.Create<int>() % 1000:D3}")
            .With(oi => oi.ProductName, () => $"測試商品{_fixture.Create<int>() % 100}")
            .With(oi => oi.UnitPrice, () => Math.Round(_fixture.Create<decimal>() % 1000 + 1, 2))
            .With(oi => oi.Quantity, () => _fixture.Create<int>() % 10 + 1));
    }

    public IEnumerator<Order> GetEnumerator()
    {
        // 產生多個隨機訂單進行測試
        for (int i = 0; i < 5; i++)
        {
            yield return _fixture.Create<Order>();
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

AutoFixture 的優勢:

  1. 減少維護負擔:不需要手動維護大量測試資料
  2. 發現邊界問題:隨機資料能夠發現程式碼中的潛在問題
  3. 提高測試覆蓋率:自動產生各種可能的組合
  4. 保持測試簡潔:專注於測試邏輯而非資料準備

Matrix Tests:組合測試的威力與陷阱

Matrix Tests 是 TUnit 的獨特功能,它能自動產生所有參數組合的測試案例。這個功能非常適合測試多個變數之間的交互作用。

基本使用方式

[Test]
[MatrixDataSource]
public async Task CalculateShipping_客戶等級與金額組合_應遵循運費規則(
    [Matrix(0, 1, 2, 3)] CustomerLevel customerLevel, // 0=一般會員, 1=VIP會員, 2=白金會員, 3=鑽石會員
    [Matrix(100, 500, 1000, 2000)] decimal orderAmount)
{
    // Arrange
    var calculator = new ShippingCalculator();
    var order = new Order
    {
        CustomerLevel = customerLevel,
        Items = [new OrderItem { UnitPrice = orderAmount, Quantity = 1 }]
    };

    // Act
    var shippingFee = calculator.CalculateShippingFee(order);
    var isFreeShipping = calculator.IsEligibleForFreeShipping(order);

    // Assert - 驗證運費邏輯的一致性
    if (isFreeShipping)
    {
        await Assert.That(shippingFee).IsEqualTo(0m);
    }
    else
    {
        await Assert.That(shippingFee).IsGreaterThan(0m);
    }

    // 驗證特定規則
    switch (customerLevel)
    {
        case CustomerLevel.鑽石會員:
            await Assert.That(shippingFee).IsEqualTo(0m); // 鑽石會員永遠免運
            break;

        case CustomerLevel.VIP會員 or CustomerLevel.白金會員:
            if (orderAmount < 1000m)
            {
                await Assert.That(shippingFee).IsEqualTo(40m); // VIP+ 運費半價
            }
            break;

        case CustomerLevel.一般會員:
            if (orderAmount < 1000m)
            {
                await Assert.That(shippingFee).IsEqualTo(80m); // 一般會員標準運費
            }
            break;
    }
}

這個測試會自動產生 4 × 4 = 16 個測試案例,涵蓋所有可能的組合。

重要注意事項:

  • 使用 [MatrixDataSource] 屬性標記測試方法
  • 每個參數使用 [Matrix(...)] 屬性指定可能的值
  • 由於 C# 屬性限制,enum 必須用數值表示

Matrix Tests 的陷阱與注意事項

Matrix Tests 雖然強大,但也有一些需要注意的地方:

指數級增長問題

// 這會產生 5 × 4 × 3 × 6 = 360 個測試案例!
[Test]
[Matrix(
    [1, 2, 3, 4, 5],
    [CustomerLevel.一般會員, CustomerLevel.VIP會員, CustomerLevel.白金會員, CustomerLevel.鑽石會員],
    [true, false, null],
    ["Standard", "Express", "Overnight", "International", "Pickup", "Digital"]
)]
public async Task ComplexShippingTest(int quantity, CustomerLevel level, bool? expedited, string method)
{
    // 360 個測試案例可能會讓測試執行時間過長
}

實務建議:

  • 限制參數組合數量,避免超過 50-100 個案例
  • 考慮使用 [Arguments] 來指定重要的組合
  • 使用 Theory 測試來補充邊界情況

C# enum 常數限制

在 C# 中,enum 值不能直接在 attribute 中使用作為常數。在 TUnit 的 Matrix Tests 中,我們需要使用數值來代表 enum 值:

// X 這樣寫會編譯錯誤
[Test]
[MatrixDataSource]
public async Task TestMethod(
    [Matrix(CustomerLevel.一般會員, CustomerLevel.VIP會員)] CustomerLevel level) // 編譯錯誤:enum 不是常數
{
}

// O 正確做法:使用數值代表 enum
[Test]
[MatrixDataSource]
public async Task CalculateShipping_使用數值代表Enum_應正確處理各等級(
    [Matrix(0, 1, 2, 3)] CustomerLevel customerLevel, // 0=一般會員, 1=VIP會員, 2=白金會員, 3=鑽石會員
    [Matrix(100, 1000)] decimal orderAmount)
{
    // Arrange
    var calculator = new ShippingCalculator();
    var order = new Order
    {
        CustomerLevel = customerLevel, // TUnit 會自動將數值轉換為對應的 enum
        Items = [new OrderItem { UnitPrice = orderAmount, Quantity = 1 }]
    };

    // Act
    var shippingFee = calculator.CalculateShippingFee(order);

    // Assert - 根據不同客戶等級驗證運費邏輯
    switch (customerLevel)
    {
        case CustomerLevel.鑽石會員:
            await Assert.That(shippingFee).IsEqualTo(0m); // 鑽石會員永遠免運
            break;
        case CustomerLevel.VIP會員 or CustomerLevel.白金會員:
            if (orderAmount < 1000m)
                await Assert.That(shippingFee).IsEqualTo(40m); // VIP+ 運費半價
            else
                await Assert.That(shippingFee).IsEqualTo(0m); // 滿額免運
            break;
        case CustomerLevel.一般會員:
            if (orderAmount < 1000m)
                await Assert.That(shippingFee).IsEqualTo(80m); // 一般會員標準運費
            else
                await Assert.That(shippingFee).IsEqualTo(0m); // 滿額免運
            break;
    }
}

重要提醒:

  • TUnit 會自動將數值轉換為對應的 enum 值
  • 在註解中清楚標明數值與 enum 的對應關係
  • 這種方式比建立常數類別更簡潔直接

Matrix Tests 的最佳實踐

基於實際使用經驗,以下是一些最佳實踐:

1. 適合的使用場景:

  • 業務規則的交叉驗證
  • 配置組合的測試
  • API 參數的有效性檢查

2. 不適合的場景:

  • 簡單的輸入輸出驗證(使用 Arguments 即可)
  • 需要複雡設定的測試
  • 執行時間較長的整合測試

3. 效能考量:

// 好的做法:專注於核心組合
[Test]
[MatrixDataSource]
public async Task TestDiscountLogic(
    [Matrix(true, false)] bool isMember, // 是否為會員
    [Matrix(0, 1, 100, 1000)] int amount) // 關鍵金額門檻
{
    // 專注測試折扣邏輯的關鍵組合 - 總共 2 × 4 = 8 個測試
}

// 避免的做法:過多不必要的組合
[Test]
[MatrixDataSource]
public async Task OverComplexTest(
    [Matrix(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)] int value, // 太多相似的值
    [Matrix(true, false)] bool flag,
    [Matrix("A", "B", "C", "D", "E")] string category) // 可能不是每個組合都有意義
{
    // 產生 10 × 2 × 5 = 100 個測試,可能有很多重複的邏輯
}

小結

在這個章節中,我們深入探討了 TUnit 資料驅動測試的三大核心技術:

  1. MethodDataSource - 適合動態資料和複雜物件
  2. ClassDataSource - 適合共享資料和與 AutoFixture 整合
  3. Matrix Tests - 適合組合測試,但要注意效能影響

這些工具各有適用場景,正確運用它們能大幅提升測試的效率和覆蓋率。在下一個章節中,我們將探討 TUnit 的測試生命週期管理和依賴注入功能。


測試生命週期與依賴注入

在企業級測試中,我們經常需要處理複雜的測試設定和清理工作。TUnit 提供了強大的生命週期管理機制,讓我們能夠精確控制測試的執行過程。

Properties 屬性標記與測試過濾

在大型專案中,我們通常有數千個測試案例。如何有效地組織和過濾這些測試,是測試管理的重要課題。TUnit 的 Properties 功能提供了靈活的測試標記和過濾機制。

基本 Properties 使用

Properties 允許我們為測試添加自訂屬性,然後透過這些屬性來過濾和組織測試:

[Test]
[Property("Category", "Database")]
[Property("Priority", "High")]
public async Task DatabaseTest_高優先級_應能透過屬性過濾()
{
    // 這個測試可以透過 Category=Database 或 Priority=High 來過濾執行
    await Assert.That(true).IsTrue();
}

[Test]
[Property("Category", "Unit")]
[Property("Priority", "Medium")]
public async Task UnitTest_中等優先級_基本驗證()
{
    await Assert.That(1 + 1).IsEqualTo(2);
}

[Test]
[Property("Category", "Integration")]
[Property("Priority", "Low")]
[Property("Environment", "Development")]
public async Task IntegrationTest_低優先級_僅開發環境執行()
{
    // 可以透過多個屬性組合來精確過濾測試
    await Assert.That("Hello World").Contains("World");
}

實務應用場景

環境特定測試

某些測試只應該在特定環境中執行:

[Test]
[Property("Environment", "Development")]
[Property("Category", "Debug")]
public async Task DebugFeature_僅開發環境_除錯功能測試()
{
    // 這種測試只在開發環境執行,不會在正式環境的 CI/CD 中執行
    await Assert.That(DebugHelper.IsEnabled()).IsTrue();
}

[Test]
[Property("Environment", "Production")]
[Property("Category", "Performance")]
public async Task PerformanceTest_正式環境規格_效能驗證()
{
    // 效能測試通常需要在類似正式環境的條件下執行
    var stopwatch = Stopwatch.StartNew();
    // 執行效能測試
    stopwatch.Stop();
    
    await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(1000);
}

測試套件組織

透過 Properties,我們可以建立邏輯上的測試套件:

// 冒煙測試套件:快速驗證基本功能
[Test]
[Property("Suite", "Smoke")]
[Property("Priority", "Critical")]
public async Task SmokeTest_基本功能_必須通過()
{
    await Assert.That(ApplicationStatus.IsHealthy()).IsTrue();
}

// 回歸測試套件:確保既有功能沒有被破壞
[Test]
[Property("Suite", "Regression")]
[Property("Feature", "OrderProcessing")]
public async Task RegressionTest_訂單處理_既有功能正常()
{
    // 回歸測試邏輯
}

// 新功能測試套件:驗證新開發的功能
[Test]
[Property("Suite", "NewFeature")]
[Property("Version", "2.1")]
public async Task NewFeature_版本2點1_新增功能驗證()
{
    // 新功能測試邏輯
}

Properties 的最佳實踐

建立一致的屬性命名規範:

public static class TestProperties
{
    // 測試類別
    public const string CATEGORY_UNIT = "Unit";
    public const string CATEGORY_INTEGRATION = "Integration";
    public const string CATEGORY_E2E = "E2E";
    
    // 優先級
    public const string PRIORITY_CRITICAL = "Critical";
    public const string PRIORITY_HIGH = "High";
    public const string PRIORITY_MEDIUM = "Medium";
    public const string PRIORITY_LOW = "Low";
    
    // 環境
    public const string ENV_DEVELOPMENT = "Development";
    public const string ENV_STAGING = "Staging";
    public const string ENV_PRODUCTION = "Production";
}

// 使用常數確保一致性
[Test]
[Property("Category", TestProperties.CATEGORY_UNIT)]
[Property("Priority", TestProperties.PRIORITY_HIGH)]
public async Task ExampleTest_使用常數_確保一致性()
{
    await Assert.That(1 + 1).IsEqualTo(2);
}

TUnit 測試過濾執行

TUnit 使用 Microsoft Testing Platform,因此在執行測試過濾時,使用的是 dotnet run 而不是傳統的 dotnet test。這是因為 TUnit 採用了不同的測試執行架構。

基本過濾語法

TUnit 的過濾語法使用 --treenode-filter 參數,格式為:

dotnet run --treenode-filter "/*/*/*/*[Property=Value]"

語法說明:

  • /*/*/*/* - 代表測試樹狀結構的路徑模式(Assembly/Namespace/Class/Method)
  • [Property=Value] - 屬性過濾條件
  • 支援組合條件:[(Property1=Value1)&(Property2=Value2)]
  • 支援或條件:[(Property1=Value1)|(Property2=Value2)]

實際過濾範例

基於我們前面定義的 TestProperties 常數,以下是一些實用的過濾範例:

# 只執行單元測試(快速驗證)
dotnet run --treenode-filter "/*/*/*/*[Category=Unit]"

# 只執行高優先級測試
dotnet run --treenode-filter "/*/*/*/*[Priority=High]"

# 執行整合測試(需要外部依賴)
dotnet run --treenode-filter "/*/*/*/*[Category=Integration]"

# 組合條件:執行高優先級的單元測試
dotnet run --treenode-filter "/*/*/*/*[(Category=Unit)&(Priority=High)]"

# 執行冒煙測試套件
dotnet run --treenode-filter "/*/*/*/*[Suite=Smoke]"

# 執行特定功能的測試
dotnet run --treenode-filter "/*/*/*/*[Feature=OrderProcessing]"

# 複雜組合:執行高優先級的單元測試或冒煙測試
dotnet run --treenode-filter "/*/*/*/*[((Category=Unit)&(Priority=High))|(Suite=Smoke)]"

過濾語法的重要注意事項

  1. 路徑模式: /*/*/*/* 是固定格式,代表 Assembly/Namespace/Class/Method 的層級
  2. 屬性名稱大小寫敏感: Categorycategory 是不同的
  3. 值的大小寫敏感: Unitunit 是不同的
  4. 括號的使用: 組合條件必須用括號正確包圍
  5. 引號的使用: 整個過濾字串需要用引號包圍

測試生命週期控制

TUnit 提供了完整的測試生命週期管理機制,讓我們能夠在測試執行的不同階段進行設定和清理工作。

生命週期方法概述

TUnit 支援多個層級的生命週期控制:

生命週期方法 執行時機 適用場景
[Before(Class)] 類別中第一個測試開始前 昂貴的資源初始化(如資料庫連線)
建構式 每個測試開始前 測試實例的基本設定
[Before(Test)] 每個測試方法執行前 測試特定的前置作業
測試方法 實際測試執行 測試邏輯本身
[After(Test)] 每個測試方法執行後 測試特定的清理作業
Dispose 測試實例銷毀時 釋放測試實例的資源
[After(Class)] 類別中最後一個測試完成後 清理共享資源

TUnit 生命週期完整架構解析

基於官方文件和實際測試結果,TUnit 提供了一個比傳統測試框架更豐富的生命週期管理系統。

層級結構

TUnit 提供了多個層級的生命週期控制,從最小到最大範圍:

  1. Test - 個別測試方法層級
  2. Class - 測試類別層級
  3. Assembly - 測試組件層級
  4. TestSession - 測試會話層級
  5. TestDiscovery - 測試發現層級

Before 屬性家族

[Before(HookType)] - 特定範圍執行一次

[Before(Test)]           // 實例方法 - 每個測試前執行
[Before(Class)]          // 靜態方法 - 類別第一個測試前執行一次
[Before(Assembly)]       // 靜態方法 - 組件第一個測試前執行一次
[Before(TestSession)]    // 靜態方法 - 測試會話開始前執行一次
[Before(TestDiscovery)]  // 靜態方法 - 測試發現前執行一次

[BeforeEvery(HookType)] - 全域每次執行

[BeforeEvery(Test)]      // 靜態方法 - 每個測試前都執行(全域)
[BeforeEvery(Class)]     // 靜態方法 - 每個類別前都執行(全域)
[BeforeEvery(Assembly)]  // 靜態方法 - 每個組件前都執行(全域)

After 屬性家族

[After(HookType)] - 特定範圍執行一次

[After(Test)]           // 實例方法 - 每個測試後執行
[After(Class)]          // 靜態方法 - 類別最後一個測試後執行一次
[After(Assembly)]       // 靜態方法 - 組件最後一個測試後執行一次
[After(TestSession)]    // 靜態方法 - 測試會話結束後執行一次
[After(TestDiscovery)]  // 靜態方法 - 測試發現後執行一次

[AfterEvery(HookType)] - 全域每次執行

[AfterEvery(Test)]      // 靜態方法 - 每個測試後都執行(全域)
[AfterEvery(Class)]     // 靜態方法 - 每個類別後都執行(全域)
[AfterEvery(Assembly)]  // 靜態方法 - 每個組件後都執行(全域)

實際執行順序分析

重要:建構式的執行優先級

建構式永遠在所有 TUnit 生命週期屬性之前執行,因為 TUnit 需要先建立測試實例才能執行任何實例方法。建構式執行完成後,才會依照 TUnit 定義的各層級屬性順序執行。

Setup 階段(由大範圍到小範圍)

  1. [Before(TestSession)] - 整個測試會話開始
  2. [Before(Assembly)] - 組件層級初始化
  3. [Before(Class)] - 類別層級初始化
  4. 建構式 - 測試實例建立
  5. [Before(Test)] - 個別測試前設定

Cleanup 階段(由小範圍到大範圍)

  1. [After(Test)] - 個別測試後清理
  2. Dispose/DisposeAsync - 實例清理
  3. [After(Class)] - 類別層級清理
  4. [After(Assembly)] - 組件層級清理
  5. [After(TestSession)] - 整個測試會話結束

重要特性

  1. 建構式優先級建構式永遠在所有 TUnit 生命週期屬性之前執行,這是 C# 語言的基本機制。TUnit 必須先建立測試實例,才能執行任何實例方法的生命週期 Hook
  2. 執行保證:每個 After 方法都會執行,即使前面的方法拋出例外,例外會被收集並在最後拋出
  3. 繼承行為:Before 方法由基礎類別到衍生類別執行;After 方法由衍生類別到基礎類別執行
  4. 全域鉤子[BeforeEvery(...)][AfterEvery(...)] 是全域的,建議放在獨立的 GlobalHooks.cs 檔案中
  5. 方法要求:實例方法用於 [Before(Test)][After(Test)],靜態方法用於所有其他屬性

實際範例:生命週期執行順序

讓我們透過一個實際的例子來看看生命週期方法的執行順序:

public class LifecycleTests
{
    private readonly StringBuilder _logBuilder;
    private static readonly List<string> ClassLog = [];

    public LifecycleTests()
    {
        Console.WriteLine("1. 建構式執行 - 測試實例建立");
        _logBuilder = new StringBuilder();
        _logBuilder.AppendLine("建構式執行");
    }

    [Before(Class)]
    public static async Task BeforeClass()
    {
        Console.WriteLine("2. BeforeClass 執行 - 類別層級初始化");
        ClassLog.Add("BeforeClass 執行");
        await Task.Delay(10); // 模擬非同步初始化
    }

    [Before(Test)]
    public async Task BeforeTest()
    {
        Console.WriteLine("3. BeforeTest 執行 - 測試前置設定");
        _logBuilder.AppendLine("BeforeTest 執行");
        await Task.Delay(5); // 模擬非同步設定
    }

    [Test]
    public async Task FirstTest_應按正確順序執行生命週期方法()
    {
        Console.WriteLine($"4. FirstTest 執行 - 驗證生命週期順序 [{DateTime.Now:HH:mm:ss.fff}]");
        _logBuilder.AppendLine("FirstTest 執行");

        var log = _logBuilder.ToString();
        await Assert.That(log).Contains("建構式執行");
        await Assert.That(log).Contains("BeforeTest 執行");
        await Assert.That(ClassLog).Contains("BeforeClass 執行");
    }

    [Test]
    public async Task SecondTest_應有獨立的實例()
    {
        Console.WriteLine($"4. SecondTest 執行 - 驗證實例獨立性 [{DateTime.Now:HH:mm:ss.fff}]");
        _logBuilder.AppendLine("SecondTest 執行");

        // 每個測試都有新的實例,所以建構式會重新執行
        var log = _logBuilder.ToString();
        await Assert.That(log).Contains("建構式執行");
        await Assert.That(log).Contains("BeforeTest 執行");
    }

    [After(Test)]
    public async Task AfterTest()
    {
        Console.WriteLine("5. AfterTest 執行 - 測試後清理");
        _logBuilder.AppendLine("AfterTest 執行");
        await Task.Delay(5); // 模擬非同步清理
    }

    [After(Class)]
    public static async Task AfterClass()
    {
        Console.WriteLine("6. AfterClass 執行 - 類別層級清理");
        ClassLog.Add("AfterClass 執行");
        await Task.Delay(10); // 模擬非同步清理
    }
}

執行以下的 dotnet run 指令:

# 先移到 /tests/TUnit.Advanced.Lifecycle.Tests 目錄,然後執行以下命令
dotnet run --treenode-filter "*/*/*LifecycleTests/*"

執行結果 (節錄):

1. 建構式執行 - 測試實例建立
1. 建構式執行 - 測試實例建立
2. BeforeClass 執行 - 類別層級初始化
3. BeforeTest 執行 - 測試前置設定
3. BeforeTest 執行 - 測試前置設定
4. SecondTest 執行 - 驗證實例獨立性 [12:50:34.291]
4. FirstTest 執行 - 驗證生命週期順序 [12:50:34.291]
5. AfterTest 執行 - 測試後清理
5. AfterTest 執行 - 測試後清理
6. AfterClass 執行 - 類別層級清理

重要觀察:

  1. 建構式確實在最前面執行:每個測試實例建立時,建構式都會先執行
  2. BeforeClass 只執行一次:在所有測試開始前執行一次類別層級的初始化
  3. BeforeTest 每個測試都執行:在每個測試方法執行前都會呼叫
  4. 測試執行是並行的:可以看到兩個測試方法同時執行
  5. AfterTest 每個測試都執行:在每個測試方法執行後都會呼叫
  6. AfterClass 只執行一次:在所有測試完成後執行一次類別層級的清理

特別是建構式永遠在所有 TUnit 生命週期屬性之前執行,這是 C# 語言的基本機制。TUnit 必須先建立測試實例,才能執行任何實例方法的生命週期 Hook。

TUnit 的 IDisposable 支援

TUnit 完全支援 .NET 的資源清理機制,包括同步的 IDisposable 和非同步的 IAsyncDisposable 介面。這個功能對於需要在測試完成後清理資源的情況非常有用。

執行順序說明:

當測試類別實作了 IDisposableIAsyncDisposable 時,資源清理會在測試生命週期的適當時機自動執行:

建構式 → [Before(Test)] → 測試方法 → [After(Test)] → Dispose/DisposeAsync

重要特性:

  1. 優先順序:如果同時實作了 IDisposableIAsyncDisposable,TUnit 會優先呼叫 DisposeAsync
  2. 每個測試實例:每個測試都會建立新的實例,因此 Dispose 方法會在每個測試完成後執行
  3. 例外安全:即使測試方法或 [After(Test)] 拋出例外,Dispose 方法仍會被呼叫
  4. 非同步支援DisposeAsync 方法的非同步操作會被正確等待

適用場景:

  • 釋放資料庫連線或檔案句柄
  • 清理暫存檔案或目錄
  • 關閉 HTTP 用戶端或網路連線
  • 釋放昂貴的測試資源

這個功能讓 TUnit 的資源管理變得非常可靠,確保測試不會因為資源洩漏而互相影響。

真實專案中的生命週期應用

資料庫測試的生命週期管理

public class DatabaseTestsLifecycleExample
{
    private static TestDatabase? _database;
    private IDbConnection? _connection;

    [Before(Class)]
    public static async Task SetupDatabase()
    {
        // 類別層級:建立測試資料庫(昂貴操作,只做一次)
        _database = await TestDatabase.CreateAsync();
        await _database.MigrateAsync();
    }

    public DatabaseTestsLifecycleExample()
    {
        // 實例層級:為每個測試準備資料庫連線
        _connection = _database?.CreateConnection();
    }

    [Before(Test)]
    public async Task PrepareTestData()
    {
        // 測試層級:為每個測試準備乾淨的測試資料
        await _connection!.ExecuteAsync("DELETE FROM Orders");
        await _connection.ExecuteAsync("DELETE FROM Customers");
        
        // 插入基本測試資料
        await _connection.ExecuteAsync(@"
            INSERT INTO Customers (Id, Name, Level) 
            VALUES ('CUST001', '測試客戶', 'VIP會員')");
    }

    [Test]
    public async Task DatabaseTest_使用乾淨環境_應正常執行()
    {
        // 在這裡,我們確保有乾淨的資料庫環境
        var customerCount = await _connection!.QuerySingleAsync<int>(
            "SELECT COUNT(*) FROM Customers");
        
        await Assert.That(customerCount).IsEqualTo(1);
    }

    [After(Test)]
    public async Task CleanupTestData()
    {
        // 測試後清理(雖然下個測試的 PrepareTestData 也會清理,但這是好習慣)
        await _connection!.ExecuteAsync("DELETE FROM Orders");
    }

    public void Dispose()
    {
        // 實例清理:關閉資料庫連線
        _connection?.Dispose();
    }

    [After(Class)]
    public static async Task TeardownDatabase()
    {
        // 類別清理:移除測試資料庫
        if (_database != null)
        {
            await _database.DropAsync();
            await _database.DisposeAsync();
        }
    }
}

HTTP 用戶端測試的生命週期

public class HttpClientLifecycleExample
{
    private static HttpClient? _httpClient;
    private const string TestBaseUrl = "https://api.example.com";

    [Before(Class)]
    public static async Task SetupHttpClient()
    {
        // 設定共用的 HTTP 用戶端
        _httpClient = new HttpClient { BaseAddress = new Uri(TestBaseUrl) };
        _httpClient.DefaultRequestHeaders.Add("User-Agent", "TUnit-Tests/1.0");
        
        // 等待 API 服務就緒
        await WaitForApiAvailability();
    }

    [Before(Test)]
    public async Task PrepareApiState()
    {
        // 重設 API 狀態到已知的初始狀態
        await _httpClient!.PostAsync("/api/test/reset", null);
    }

    [Test]
    public async Task ApiTest_使用預設狀態_應回傳正確結果()
    {
        // 測試邏輯
        var response = await _httpClient!.GetAsync("/api/status");
        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }

    [After(Class)]
    public static void TeardownHttpClient()
    {
        _httpClient?.Dispose();
    }

    private static async Task WaitForApiAvailability()
    {
        // 實作等待 API 可用的邏輯
        await Task.Delay(100); // 簡化示範
    }
}

依賴注入模式

在現代的 .NET 應用程式中,依賴注入已經成為標準模式。TUnit 提供了強大的依賴注入功能,讓我們能夠在測試中優雅地處理複雜的依賴關係。

TUnit 依賴注入核心概念

TUnit 的依賴注入建構在 Data Source Generators 的基礎上,提供了一個抽象類別 DependencyInjectionDataSourceAttribute<TScope> 來處理大部分的邏輯。我們只需要實作如何建立 DI 範圍以及如何根據類型建立物件。

建立自訂依賴注入資料來源

首先,我們需要建立一個繼承自 DependencyInjectionDataSourceAttribute<TScope> 的自訂屬性:

/// <summary>
/// TUnit 依賴注入資料來源屬性
/// 基於 Microsoft.Extensions.DependencyInjection 實作
/// </summary>
public class MicrosoftDependencyInjectionDataSourceAttribute : DependencyInjectionDataSourceAttribute<IServiceScope>
{
    private static readonly IServiceProvider ServiceProvider = CreateSharedServiceProvider();

    public override IServiceScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata)
    {
        return ServiceProvider.CreateScope();
    }

    public override object? Create(IServiceScope scope, Type type)
    {
        return scope.ServiceProvider.GetService(type);
    }

    private static IServiceProvider CreateSharedServiceProvider()
    {
        return new ServiceCollection()
            .AddSingleton<IOrderRepository, MockOrderRepository>()
            .AddSingleton<IDiscountCalculator, MockDiscountCalculator>()
            .AddSingleton<IShippingCalculator, MockShippingCalculator>()
            .AddSingleton<ILogger<OrderService>, MockLogger<OrderService>>()
            .AddTransient<OrderService>()
            .BuildServiceProvider();
    }
}

關鍵設計要點:

  1. 泛型範圍類型:使用 IServiceScope 作為 DI 範圍的類型
  2. 範圍建立CreateScope 方法負責為每個測試建立獨立的服務範圍
  3. 物件解析Create 方法負責從 DI 容器中解析指定類型的物件
  4. 服務註冊:在 CreateSharedServiceProvider 中統一註冊所有測試所需的服務

使用 TUnit 依賴注入進行測試

有了自訂的依賴注入屬性後,我們可以在測試類別中直接使用依賴注入:

/// <summary>
/// 展示 TUnit 真正的依賴注入功能
/// </summary>
[MicrosoftDependencyInjectionDataSource]
public class DependencyInjectionTests(OrderService orderService)
{
    [Test]
    public async Task CreateOrder_使用TUnit依賴注入_應正確運作()
    {
        // Arrange - 依賴已經透過 TUnit DI 自動注入
        var items = new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "測試商品", UnitPrice = 100m, Quantity = 2 }
        };

        // Act
        var order = await orderService.CreateOrderAsync("CUST001", CustomerLevel.VIP會員, items);

        // Assert
        await Assert.That(order).IsNotNull();
        await Assert.That(order.CustomerId).IsEqualTo("CUST001");
        await Assert.That(order.CustomerLevel).IsEqualTo(CustomerLevel.VIP會員);
        await Assert.That(order.Items).HasCount().EqualTo(1);
    }

    [Test]
    public async Task TUnitDependencyInjection_驗證自動注入_服務應為正確類型()
    {
        // Assert - 驗證 TUnit 已正確注入 OrderService 實例
        await Assert.That(orderService).IsNotNull();
        await Assert.That(orderService.GetType().Name).IsEqualTo("OrderService");
    }
}

TUnit DI 的優勢:

  1. 自動注入:測試類別的建構式參數會自動從 DI 容器中解析
  2. 範圍隔離:每個測試都有獨立的服務範圍,確保測試間不會互相影響
  3. 一致性:與應用程式中的 DI 模式保持一致
  4. 可測試性:可以輕鬆地替換服務實作為 Mock 物件

傳統手動依賴建立的對比

為了展示 TUnit DI 的優勢,我們也可以建立一個使用傳統手動方式的對比測試:

/// <summary>
/// 展示手動依賴建立的傳統方式(對比用)
/// </summary>
public class ManualDependencyTests
{
    [Test]
    public async Task CreateOrder_手動建立依賴_傳統方式對比()
    {
        // Arrange - 手動建立測試所需的依賴(傳統方式)
        var mockRepository = new MockOrderRepository();
        var mockDiscountCalculator = new MockDiscountCalculator();
        var mockShippingCalculator = new MockShippingCalculator();
        var mockLogger = new MockLogger<OrderService>();

        var orderService = new OrderService(mockRepository, mockDiscountCalculator, mockShippingCalculator, mockLogger);

        var items = new List<OrderItem>
        {
            new() { ProductId = "PROD001", ProductName = "測試商品", UnitPrice = 100m, Quantity = 2 }
        };

        // Act
        var order = await orderService.CreateOrderAsync("CUST001", CustomerLevel.VIP會員, items);

        // Assert
        await Assert.That(order).IsNotNull();
        await Assert.That(order.CustomerId).IsEqualTo("CUST001");
        await Assert.That(order.CustomerLevel).IsEqualTo(CustomerLevel.VIP會員);
        await Assert.That(order.Items).HasCount().EqualTo(1);
    }
}

TUnit DI vs 手動依賴建立比較

特性 TUnit DI 手動依賴建立
設定複雜度 一次設定,重複使用 每個測試都需要手動建立
可維護性 依賴變更只需修改一個地方 需要修改所有使用的測試
一致性 與產品程式碼的 DI 一致 可能與實際應用程式不一致
測試可讀性 專注於測試邏輯 被依賴建立程式碼干擾
範圍管理 自動管理服務範圍 需要手動管理物件生命週期
錯誤風險 框架保證依賴正確注入 可能遺漏或錯誤建立某些依賴

實務應用建議

何時使用 TUnit DI:

  • 複雜的服務依賴關係
  • 需要與真實應用程式保持一致的 DI 配置
  • 大量測試需要相同的依賴設定
  • 希望專注於測試邏輯而非依賴管理

何時使用手動依賴建立:

  • 簡單的測試案例
  • 需要特殊的 Mock 行為設定
  • 測試特定的依賴組合
  • 學習和教學目的

進階:多環境依賴注入配置

在真實專案中,我們可能需要針對不同的測試環境設定不同的依賴:

public class TestEnvironmentDependencyInjectionDataSourceAttribute : DependencyInjectionDataSourceAttribute<IServiceScope>
{
    private static readonly IServiceProvider ServiceProvider = CreateEnvironmentSpecificServiceProvider();

    public override IServiceScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata)
    {
        return ServiceProvider.CreateScope();
    }

    public override object? Create(IServiceScope scope, Type type)
    {
        return scope.ServiceProvider.GetService(type);
    }

    private static IServiceProvider CreateEnvironmentSpecificServiceProvider()
    {
        var environment = Environment.GetEnvironmentVariable("TEST_ENVIRONMENT") ?? "Development";
        
        var services = new ServiceCollection();
        
        if (environment == "Integration")
        {
            // 整合測試環境:使用真實的資料庫連線
            services.AddSingleton<IOrderRepository, DatabaseOrderRepository>();
        }
        else
        {
            // 單元測試環境:使用 Mock 物件
            services.AddSingleton<IOrderRepository, MockOrderRepository>();
        }
        
        services.AddSingleton<IDiscountCalculator, MockDiscountCalculator>();
        services.AddSingleton<IShippingCalculator, MockShippingCalculator>();
        services.AddSingleton<ILogger<OrderService>, MockLogger<OrderService>>();
        services.AddTransient<OrderService>();
        
        return services.BuildServiceProvider();
    }
}

這種設計讓我們能夠在不同的測試環境中使用不同的依賴實作,同時保持測試程式碼的一致性。

Mock 物件的設計模式

為了支援測試,我們需要建立適當的 Mock 實作。以下是一些常見的模式:

// 簡單的 Mock Repository
public class MockOrderRepository : IOrderRepository
{
    public Task<bool> SaveOrderAsync(Order order)
    {
        order.OrderId = Guid.NewGuid().ToString();
        return Task.FromResult(true);
    }

    public Task<Order?> GetOrderByIdAsync(string orderId)
    {
        return Task.FromResult<Order?>(null);
    }

    public Task<bool> UpdateOrderAsync(Order order)
    {
        return Task.FromResult(true);
    }

    public Task<bool> DeleteOrderAsync(string orderId)
    {
        return Task.FromResult(true);
    }

    public Task<List<Order>> GetOrdersByCustomerIdAsync(string customerId)
    {
        return Task.FromResult(new List<Order>());
    }
}

// 可設定行為的 Mock Calculator
public class MockDiscountCalculator : IDiscountCalculator
{
    public async Task<decimal> CalculateDiscountAsync(Order order, string discountCode)
    {
        // 模擬業務邏輯:VIP 會員有基本折扣
        var baseDiscount = order.CustomerLevel == CustomerLevel.VIP會員 ? 
            order.TotalAmount * 0.1m : 0m;
        
        return await Task.FromResult(baseDiscount);
    }

    public async Task<bool> ValidateDiscountCodeAsync(string discountCode, CustomerLevel customerLevel, decimal orderAmount)
    {
        return await Task.FromResult(!string.IsNullOrEmpty(discountCode));
    }

    public async Task<DiscountRule?> GetDiscountRuleAsync(string discountCode)
    {
        return await Task.FromResult<DiscountRule?>(null);
    }
}

測試基礎設施的重用模式

在大型專案中,我們通常會建立可重用的測試基礎設施:

public static class TestServiceFactory
{
    public static OrderService CreateOrderService()
    {
        return new OrderService(
            new MockOrderRepository(),
            new MockDiscountCalculator(),
            new MockShippingCalculator(),
            new MockLogger<OrderService>()
        );
    }

    public static OrderService CreateOrderServiceWithCustomRepository(IOrderRepository repository)
    {
        return new OrderService(
            repository,
            new MockDiscountCalculator(),
            new MockShippingCalculator(),
            new MockLogger<OrderService>()
        );
    }
}

// 使用範例
[Test]
public async Task TestWithFactory_簡化測試設定_提高可讀性()
{
    // Arrange
    var orderService = TestServiceFactory.CreateOrderService();
    
    // Act & Assert
    await Assert.That(orderService).IsNotNull();
}

總結與實務建議

在這篇文章中,我們深入探討了 TUnit 的進階功能,從資料驅動測試到依賴注入,從生命週期管理到測試組織。這些技術不僅展現了 TUnit 的強大能力,也體現了現代測試框架的發展方向。

關鍵收穫

資料驅動測試的選擇策略:

  • MethodDataSource:適合動態資料、複雜物件、外部檔案載入
  • ClassDataSource:適合共享資料、AutoFixture 整合、跨測試類別重用
  • Matrix Tests:適合組合測試,但要注意參數數量避免爆炸性增長

TUnit 生命週期管理精髓:

  • 建構式優先級:永遠在所有 TUnit 生命週期屬性之前執行
  • 層級結構清晰:Test → Class → Assembly → TestSession → TestDiscovery
  • 資源清理保證:IDisposable/IAsyncDisposable 與 After 屬性的完美結合

依賴注入最佳實踐:

  • 使用 TUnit 原生 DIDependencyInjectionDataSourceAttribute<TScope> 提供真正的依賴注入
  • 範圍隔離:每個測試都有獨立的服務範圍,確保測試間不會互相影響
  • 一致性維護:與應用程式中的 DI 模式保持一致

測試組織與管理:

  • 使用 Properties 進行測試分類和過濾
  • 建立一致的屬性命名規範
  • 善用測試生命週期方法來管理資源

實務建議

  1. 逐步導入:先從簡單的 MethodDataSource 開始,逐步導入更複雜的功能
  2. 文檔優先:為每個 DataSource 方法提供清楚的註解說明
  3. 測試隔離:確保每個測試都有獨立的資源,避免測試間的相互影響
  4. 效能考量:Matrix Tests 雖然強大,但要注意組合數量爆炸的問題

明日預告

明天我們將探討 Day 30 - TUnit 進階應用 - 執行控制與測試品質和 ASP.NET Core 整合測試實戰,包括:

  • 測試執行控制:如何使用 TUnit 的 Retry 和 Timeout 功能來處理不穩定的測試
  • 測試品質提升:利用顯示名稱和屬性標記來提升測試的可讀性和可維護性
  • ASP.NET Core 整合測試:實際操作如何在 ASP.NET Core 專案中使用 TUnit 進行端到端測試

參考資源

TUnit 官方資源

進階功能文件

Microsoft 官方文件

範例程式碼


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十九天。明天會介紹 Day 30 - TUnit 進階應用 - 執行控制與測試品質和 ASP.NET Core 整合測試實戰。


上一篇
Day 28 - TUnit 入門 - 下世代 .NET 測試框架探索
下一篇
Day 30 - TUnit 進階應用:執行控制與測試品質和 ASP.NET Core 整合測試實戰
系列文
重啟挑戰:老派軟體工程師的測試修練30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言