在 Day 28 中,我們初步認識了 TUnit 這個新世代測試框架。從 Source Generator 驅動的測試發現,到 AOT 編譯支援,再到流暢式斷言語法,TUnit 展現了現代測試框架的強大潛力。
但光是會寫基本的 [Test]
和 [Arguments]
還不夠。在真實的企業專案中,我們面對的挑戰更加複雜:
實務挑戰清單:
TUnit 的進階功能正是為了解決這些挑戰而設計的。今天我們要深入探討資料驅動測試、生命週期管理、依賴注入等實務技巧,讓你能夠在工作專案中充分發揮這個框架的威力。
今天的內容有:
在 Day 28 中,我們學會了使用 [Arguments]
進行簡單的參數化測試。但當測試資料變得複雜,或者需要動態產生時,就需要更強大的工具。
TUnit 提供了多種資料來源機制,每一種都有其適用場景:
資料來源方式 | 適用場景 | 優勢 | 注意事項 |
---|---|---|---|
Arguments | 簡單固定資料 | 語法簡潔 | 資料量不宜過大 |
MethodDataSource | 動態資料、複雜物件 | 最大靈活性 | 需要額外方法定義 |
ClassDataSource | 共享資料、依賴注入 | 可重用性高 | 類別生命週期管理 |
Matrix Tests | 組合測試 | 覆蓋率高 | 容易產生過多測試 |
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 提供了更多彈性:
在真實專案中,測試資料往往存放在外部檔案中。讓我們看看如何從 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
就派上用場了。它允許我們建立專門的資料提供類別,實現更好的程式碼組織。
[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
是一個強大的測試資料自動產生工具,能夠與 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 的優勢:
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(...)]
屬性指定可能的值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 個測試案例可能會讓測試執行時間過長
}
實務建議:
[Arguments]
來指定重要的組合在 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;
}
}
重要提醒:
基於實際使用經驗,以下是一些最佳實踐:
1. 適合的使用場景:
2. 不適合的場景:
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 資料驅動測試的三大核心技術:
這些工具各有適用場景,正確運用它們能大幅提升測試的效率和覆蓋率。在下一個章節中,我們將探討 TUnit 的測試生命週期管理和依賴注入功能。
在企業級測試中,我們經常需要處理複雜的測試設定和清理工作。TUnit 提供了強大的生命週期管理機制,讓我們能夠精確控制測試的執行過程。
在大型專案中,我們通常有數千個測試案例。如何有效地組織和過濾這些測試,是測試管理的重要課題。TUnit 的 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_新增功能驗證()
{
// 新功能測試邏輯
}
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 使用 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)]"
/*/*/*/*
是固定格式,代表 Assembly/Namespace/Class/Method 的層級Category
和 category
是不同的Unit
和 unit
是不同的TUnit 提供了完整的測試生命週期管理機制,讓我們能夠在測試執行的不同階段進行設定和清理工作。
TUnit 支援多個層級的生命週期控制:
生命週期方法 | 執行時機 | 適用場景 |
---|---|---|
[Before(Class)] |
類別中第一個測試開始前 | 昂貴的資源初始化(如資料庫連線) |
建構式 |
每個測試開始前 | 測試實例的基本設定 |
[Before(Test)] |
每個測試方法執行前 | 測試特定的前置作業 |
測試方法 |
實際測試執行 | 測試邏輯本身 |
[After(Test)] |
每個測試方法執行後 | 測試特定的清理作業 |
Dispose |
測試實例銷毀時 | 釋放測試實例的資源 |
[After(Class)] |
類別中最後一個測試完成後 | 清理共享資源 |
基於官方文件和實際測試結果,TUnit 提供了一個比傳統測試框架更豐富的生命週期管理系統。
TUnit 提供了多個層級的生命週期控制,從最小到最大範圍:
[Before(HookType)]
- 特定範圍執行一次
[Before(Test)] // 實例方法 - 每個測試前執行
[Before(Class)] // 靜態方法 - 類別第一個測試前執行一次
[Before(Assembly)] // 靜態方法 - 組件第一個測試前執行一次
[Before(TestSession)] // 靜態方法 - 測試會話開始前執行一次
[Before(TestDiscovery)] // 靜態方法 - 測試發現前執行一次
[BeforeEvery(HookType)]
- 全域每次執行
[BeforeEvery(Test)] // 靜態方法 - 每個測試前都執行(全域)
[BeforeEvery(Class)] // 靜態方法 - 每個類別前都執行(全域)
[BeforeEvery(Assembly)] // 靜態方法 - 每個組件前都執行(全域)
[After(HookType)]
- 特定範圍執行一次
[After(Test)] // 實例方法 - 每個測試後執行
[After(Class)] // 靜態方法 - 類別最後一個測試後執行一次
[After(Assembly)] // 靜態方法 - 組件最後一個測試後執行一次
[After(TestSession)] // 靜態方法 - 測試會話結束後執行一次
[After(TestDiscovery)] // 靜態方法 - 測試發現後執行一次
[AfterEvery(HookType)]
- 全域每次執行
[AfterEvery(Test)] // 靜態方法 - 每個測試後都執行(全域)
[AfterEvery(Class)] // 靜態方法 - 每個類別後都執行(全域)
[AfterEvery(Assembly)] // 靜態方法 - 每個組件後都執行(全域)
建構式永遠在所有 TUnit 生命週期屬性之前執行,因為 TUnit 需要先建立測試實例才能執行任何實例方法。建構式執行完成後,才會依照 TUnit 定義的各層級屬性順序執行。
Setup 階段(由大範圍到小範圍):
[Before(TestSession)]
- 整個測試會話開始[Before(Assembly)]
- 組件層級初始化[Before(Class)]
- 類別層級初始化建構式
- 測試實例建立[Before(Test)]
- 個別測試前設定Cleanup 階段(由小範圍到大範圍):
[After(Test)]
- 個別測試後清理Dispose/DisposeAsync
- 實例清理[After(Class)]
- 類別層級清理[After(Assembly)]
- 組件層級清理[After(TestSession)]
- 整個測試會話結束[BeforeEvery(...)]
和 [AfterEvery(...)]
是全域的,建議放在獨立的 GlobalHooks.cs
檔案中[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 執行 - 類別層級清理
重要觀察:
特別是建構式永遠在所有 TUnit 生命週期屬性之前執行,這是 C# 語言的基本機制。TUnit 必須先建立測試實例,才能執行任何實例方法的生命週期 Hook。
TUnit 完全支援 .NET 的資源清理機制,包括同步的 IDisposable
和非同步的 IAsyncDisposable
介面。這個功能對於需要在測試完成後清理資源的情況非常有用。
執行順序說明:
當測試類別實作了 IDisposable
或 IAsyncDisposable
時,資源清理會在測試生命週期的適當時機自動執行:
建構式 → [Before(Test)] → 測試方法 → [After(Test)] → Dispose/DisposeAsync
重要特性:
IDisposable
和 IAsyncDisposable
,TUnit 會優先呼叫 DisposeAsync
[After(Test)]
拋出例外,Dispose 方法仍會被呼叫DisposeAsync
方法的非同步操作會被正確等待適用場景:
這個功能讓 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();
}
}
}
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 的依賴注入建構在 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();
}
}
關鍵設計要點:
IServiceScope
作為 DI 範圍的類型CreateScope
方法負責為每個測試建立獨立的服務範圍Create
方法負責從 DI 容器中解析指定類型的物件CreateSharedServiceProvider
中統一註冊所有測試所需的服務有了自訂的依賴注入屬性後,我們可以在測試類別中直接使用依賴注入:
/// <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 的優勢:
為了展示 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 | 手動依賴建立 |
---|---|---|
設定複雜度 | 一次設定,重複使用 | 每個測試都需要手動建立 |
可維護性 | 依賴變更只需修改一個地方 | 需要修改所有使用的測試 |
一致性 | 與產品程式碼的 DI 一致 | 可能與實際應用程式不一致 |
測試可讀性 | 專注於測試邏輯 | 被依賴建立程式碼干擾 |
範圍管理 | 自動管理服務範圍 | 需要手動管理物件生命週期 |
錯誤風險 | 框架保證依賴正確注入 | 可能遺漏或錯誤建立某些依賴 |
何時使用 TUnit DI:
何時使用手動依賴建立:
在真實專案中,我們可能需要針對不同的測試環境設定不同的依賴:
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 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 的強大能力,也體現了現代測試框架的發展方向。
資料驅動測試的選擇策略:
TUnit 生命週期管理精髓:
依賴注入最佳實踐:
DependencyInjectionDataSourceAttribute<TScope>
提供真正的依賴注入測試組織與管理:
明天我們將探討 Day 30 - TUnit 進階應用 - 執行控制與測試品質和 ASP.NET Core 整合測試實戰,包括:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十九天。明天會介紹 Day 30 - TUnit 進階應用 - 執行控制與測試品質和 ASP.NET Core 整合測試實戰。