dotnet-testing-advanced-tunit-advanced
SKILL.md
TUnit 進階應用:資料驅動測試、依賴注入與整合測試實戰
技能概述
本技能涵蓋 TUnit 進階應用技巧,從資料驅動測試到依賴注入,從執行控制到 ASP.NET Core 整合測試實戰。
核心主題:
- 資料驅動測試進階技巧 (MethodDataSource、ClassDataSource、Matrix Tests)
- Properties 屬性標記與測試過濾
- 測試生命週期與依賴注入
- 執行控制 (Retry、Timeout、DisplayName)
- ASP.NET Core 整合測試 (WebApplicationFactory)
- 效能測試與負載測試
- TUnit + Testcontainers 複雜基礎設施編排
- TUnit Engine Modes 與疑難排解
資料驅動測試進階技巧
資料來源方式比較
| 資料來源方式 | 適用場景 | 優勢 | 注意事項 |
|---|---|---|---|
| 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.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
};
}
從檔案載入測試資料:
[Test]
[MethodDataSource(nameof(GetDiscountTestDataFromFile))]
public async Task CalculateDiscount_從檔案讀取_應套用正確折扣(
string scenario,
decimal originalAmount,
CustomerLevel level,
string discountCode,
decimal expectedDiscount)
{
var calculator = new DiscountCalculator(new MockDiscountRepository(), new MockLogger<DiscountCalculator>());
var order = new Order
{
CustomerLevel = level,
Items = [new OrderItem { UnitPrice = originalAmount, Quantity = 1 }]
};
var discount = await calculator.CalculateDiscountAsync(order, discountCode);
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 };
}
}
ClassDataSource:類別作為資料提供者
當測試資料需要共享給多個測試類別時使用:
[Test]
[ClassDataSource<OrderValidationTestData>]
public async Task ValidateOrder_各種驗證情況_應回傳正確結果(OrderValidationScenario scenario)
{
var validator = new OrderValidator(_discountRepository, _logger);
var result = await validator.ValidateAsync(scenario.Order);
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
};
yield return new OrderValidationScenario
{
Name = "客戶ID為空",
Order = CreateOrderWithEmptyCustomerId(),
ExpectedValid = false,
ExpectedErrorKeyword = "客戶ID"
};
}
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 }
}
};
}
AutoFixture 整合:
public class AutoFixtureOrderTestData : IEnumerable<Order>
{
private readonly Fixture _fixture;
public AutoFixtureOrderTestData()
{
_fixture = new Fixture();
_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()));
_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();
}
Matrix Tests:組合測試
自動產生所有參數組合的測試案例:
[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;
}
}
⚠️ Matrix Tests 注意事項:
- 使用
[MatrixDataSource]屬性標記測試方法 - 由於 C# 屬性限制,enum 必須用數值表示
- 限制參數組合數量,避免超過 50-100 個案例
- 這會產生 4 × 4 = 16 個測試案例
Properties 屬性標記與測試過濾
基本 Properties 使用
[Test]
[Property("Category", "Database")]
[Property("Priority", "High")]
public async Task DatabaseTest_高優先級_應能透過屬性過濾()
{
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");
}
建立一致的屬性命名規範
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 使用 dotnet run 而不是 dotnet test:
# 只執行單元測試
dotnet run --treenode-filter "/*/*/*/*[Category=Unit]"
# 只執行高優先級測試
dotnet run --treenode-filter "/*/*/*/*[Priority=High]"
# 組合條件:執行高優先級的單元測試
dotnet run --treenode-filter "/*/*/*/*[(Category=Unit)&(Priority=High)]"
# 或條件:執行單元測試或冒煙測試
dotnet run --treenode-filter "/*/*/*/*[(Category=Unit)|(Suite=Smoke)]"
# 執行特定功能的測試
dotnet run --treenode-filter "/*/*/*/*[Feature=OrderProcessing]"
過濾語法注意事項:
- 路徑模式
/*/*/*/*代表 Assembly/Namespace/Class/Method 層級 - 屬性名稱大小寫敏感
- 組合條件必須用括號正確包圍
測試生命週期管理
生命週期方法概述
| 生命週期方法 | 執行時機 | 適用場景 |
|---|---|---|
[Before(Class)] |
類別中第一個測試開始前 | 昂貴的資源初始化(如資料庫連線) |
建構式 |
每個測試開始前 | 測試實例的基本設定 |
[Before(Test)] |
每個測試方法執行前 | 測試特定的前置作業 |
測試方法 |
實際測試執行 | 測試邏輯本身 |
[After(Test)] |
每個測試方法執行後 | 測試特定的清理作業 |
Dispose |
測試實例銷毀時 | 釋放測試實例的資源 |
[After(Class)] |
類別中最後一個測試完成後 | 清理共享資源 |
Before/After 屬性家族
// Before 屬性
[Before(Test)] // 實例方法 - 每個測試前執行
[Before(Class)] // 靜態方法 - 類別第一個測試前執行一次
[Before(Assembly)] // 靜態方法 - 組件第一個測試前執行一次
[Before(TestSession)] // 靜態方法 - 測試會話開始前執行一次
// After 屬性
[After(Test)] // 實例方法 - 每個測試後執行
[After(Class)] // 靜態方法 - 類別最後一個測試後執行一次
[After(Assembly)] // 靜態方法 - 組件最後一個測試後執行一次
[After(TestSession)] // 靜態方法 - 測試會話結束後執行一次
// 全域鉤子
[BeforeEvery(Test)] // 靜態方法 - 每個測試前都執行(全域)
[AfterEvery(Test)] // 靜態方法 - 每個測試後都執行(全域)
實際範例
public class LifecycleTests
{
private readonly StringBuilder _logBuilder;
private static readonly List<string> ClassLog = [];
public LifecycleTests()
{
Console.WriteLine("1. 建構式執行 - 測試實例建立");
_logBuilder = new StringBuilder();
}
[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 TestMethod_應按正確順序執行生命週期方法()
{
Console.WriteLine("4. TestMethod 執行");
await Assert.That(ClassLog).Contains("BeforeClass 執行");
}
[After(Test)]
public async Task AfterTest()
{
Console.WriteLine("5. AfterTest 執行 - 測試後清理");
await Task.Delay(5);
}
[After(Class)]
public static async Task AfterClass()
{
Console.WriteLine("6. AfterClass 執行 - 類別層級清理");
await Task.Delay(10);
}
}
重要觀察:
- 建構式優先級:永遠在所有 TUnit 生命週期屬性之前執行
- BeforeClass 只執行一次:在所有測試開始前執行一次
- 測試執行是並行的:多個測試方法可能同時執行
- AfterClass 只執行一次:在所有測試完成後執行一次
依賴注入模式
TUnit 依賴注入核心概念
TUnit 的依賴注入建構在 Data Source Generators 基礎上:
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();
}
}
使用 TUnit 依賴注入
[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會員);
}
[Test]
public async Task TUnitDependencyInjection_驗證自動注入_服務應為正確類型()
{
await Assert.That(orderService).IsNotNull();
await Assert.That(orderService.GetType().Name).IsEqualTo("OrderService");
}
}
TUnit DI vs 手動依賴建立比較
| 特性 | TUnit DI | 手動依賴建立 |
|---|---|---|
| 設定複雜度 | 一次設定,重複使用 | 每個測試都需要手動建立 |
| 可維護性 | 依賴變更只需修改一個地方 | 需要修改所有使用的測試 |
| 一致性 | 與產品程式碼的 DI 一致 | 可能與實際應用程式不一致 |
| 測試可讀性 | 專注於測試邏輯 | 被依賴建立程式碼干擾 |
| 範圍管理 | 自動管理服務範圍 | 需要手動管理物件生命週期 |
| 錯誤風險 | 框架保證依賴正確注入 | 可能遺漏或錯誤建立某些依賴 |
執行控制與測試品質
Retry 機制:智慧重試策略
[Test]
[Retry(3)] // 如果失敗,重試最多 3 次
[Property("Category", "Flaky")]
public async Task NetworkCall_可能不穩定_使用重試機制()
{
var random = new Random();
var success = random.Next(1, 4) == 1; // 約 33% 的成功率
if (!success)
{
throw new HttpRequestException("模擬網路錯誤");
}
await Assert.That(success).IsTrue();
}
適合使用 Retry 的情況:
- 外部服務呼叫:API 請求、資料庫連線可能因網路問題暫時失敗
- 檔案系統操作:在 CI/CD 環境中,檔案鎖定可能導致暫時性失敗
- 並行測試競爭:多個測試同時存取共享資源時的競爭條件
不適合使用 Retry 的情況:
- 邏輯錯誤:程式碼本身的錯誤重試多少次都不會成功
- 預期的例外:測試本身就是要驗證例外情況
- 效能測試:重試會影響效能測量的準確性
Timeout 控制:長時間測試管理
[Test]
[Timeout(5000)] // 5 秒超時
[Property("Category", "Performance")]
public async Task LongRunningOperation_應在時限內完成()
{
await Task.Delay(1000); // 1 秒操作,應該在 5 秒限制內
await Assert.That(true).IsTrue();
}
[Test]
[Timeout(1000)] // 確保不會超過 1 秒
[Property("Category", "Performance")]
[Property("Baseline", "true")]
public async Task SearchFunction_效能基準_應符合SLA要求()
{
var stopwatch = Stopwatch.StartNew();
var searchResults = await PerformSearch("test query");
stopwatch.Stop();
await Assert.That(searchResults).IsNotNull();
await Assert.That(searchResults.Count()).IsGreaterThan(0);
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(500);
}
DisplayName:自訂測試名稱
[Test]
[DisplayName("自訂測試名稱:驗證使用者註冊流程")]
public async Task UserRegistration_CustomDisplayName_測試名稱更易讀()
{
await Assert.That("user@example.com").Contains("@");
}
// 參數化測試的動態顯示名稱
[Test]
[Arguments("valid@email.com", true)]
[Arguments("invalid-email", false)]
[Arguments("", false)]
[DisplayName("電子郵件驗證:{0} 應為 {1}")]
public async Task EmailValidation_參數化顯示名稱(string email, bool expectedValid)
{
var isValid = !string.IsNullOrEmpty(email) && email.Contains("@") && email.Contains(".");
await Assert.That(isValid).IsEqualTo(expectedValid);
}
// 業務場景驅動的顯示名稱
[Test]
[Arguments(CustomerLevel.一般會員, 1000, 0)]
[Arguments(CustomerLevel.VIP會員, 1000, 50)]
[Arguments(CustomerLevel.白金會員, 1000, 100)]
[DisplayName("會員等級 {0} 購買 ${1} 應獲得 ${2} 折扣")]
public async Task MemberDiscount_根據會員等級_計算正確折扣(
CustomerLevel level, decimal amount, decimal expectedDiscount)
{
var calculator = new DiscountCalculator();
var discount = await calculator.CalculateDiscountAsync(amount, level);
await Assert.That(discount).IsEqualTo(expectedDiscount);
}
ASP.NET Core 整合測試
WebApplicationFactory 與 TUnit 的整合
public class WebApiIntegrationTests : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public WebApiIntegrationTests()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddLogging();
});
});
_client = _factory.CreateClient();
}
[Test]
public async Task WeatherForecast_Get_應回傳正確格式的資料()
{
var response = await _client.GetAsync("/weatherforecast");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
var content = await response.Content.ReadAsStringAsync();
await Assert.That(content).IsNotNull();
await Assert.That(content.Length).IsGreaterThan(0);
}
[Test]
[Property("Category", "Integration")]
public async Task WeatherForecast_ResponseHeaders_應包含ContentType標頭()
{
var response = await _client.GetAsync("/weatherforecast");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
var contentType = response.Content.Headers.ContentType?.MediaType;
await Assert.That(contentType).IsEqualTo("application/json");
}
public void Dispose()
{
_client?.Dispose();
_factory?.Dispose();
}
}
效能測試與負載測試
[Test]
[Property("Category", "Performance")]
[Timeout(10000)]
public async Task WeatherForecast_ResponseTime_應在合理範圍內()
{
var stopwatch = Stopwatch.StartNew();
var response = await _client.GetAsync("/weatherforecast");
stopwatch.Stop();
await Assert.That(response.IsSuccessStatusCode).IsTrue();
await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(5000);
}
[Test]
[Property("Category", "Load")]
[Timeout(30000)]
public async Task WeatherForecast_並行請求_應能正確處理()
{
const int concurrentRequests = 50;
var tasks = new List<Task<HttpResponseMessage>>();
for (int i = 0; i < concurrentRequests; i++)
{
tasks.Add(_client.GetAsync("/weatherforecast"));
}
var responses = await Task.WhenAll(tasks);
await Assert.That(responses.Length).IsEqualTo(concurrentRequests);
await Assert.That(responses.All(r => r.IsSuccessStatusCode)).IsTrue();
foreach (var response in responses)
{
response.Dispose();
}
}
TUnit + Testcontainers 基礎設施編排
使用 [Before(Assembly)] 和 [After(Assembly)] 管理容器
public static class GlobalTestInfrastructureSetup
{
public static PostgreSqlContainer? PostgreSqlContainer { get; private set; }
public static RedisContainer? RedisContainer { get; private set; }
public static KafkaContainer? KafkaContainer { get; private set; }
public static INetwork? Network { get; private set; }
[Before(Assembly)]
public static async Task SetupGlobalInfrastructure()
{
Console.WriteLine("=== 開始設置全域測試基礎設施 ===");
// 建立網路
Network = new NetworkBuilder()
.WithName("global-test-network")
.Build();
await Network.CreateAsync();
// 建立 PostgreSQL 容器
PostgreSqlContainer = new PostgreSqlBuilder()
.WithDatabase("test_db")
.WithUsername("test_user")
.WithPassword("test_password")
.WithNetwork(Network)
.WithCleanUp(true)
.Build();
await PostgreSqlContainer.StartAsync();
// 建立 Redis 容器
RedisContainer = new RedisBuilder()
.WithNetwork(Network)
.WithCleanUp(true)
.Build();
await RedisContainer.StartAsync();
// 建立 Kafka 容器
KafkaContainer = new KafkaBuilder()
.WithNetwork(Network)
.WithCleanUp(true)
.Build();
await KafkaContainer.StartAsync();
Console.WriteLine("=== 全域測試基礎設施設置完成 ===");
}
[After(Assembly)]
public static async Task TeardownGlobalInfrastructure()
{
Console.WriteLine("=== 開始清理全域測試基礎設施 ===");
if (KafkaContainer != null)
await KafkaContainer.DisposeAsync();
if (RedisContainer != null)
await RedisContainer.DisposeAsync();
if (PostgreSqlContainer != null)
await PostgreSqlContainer.DisposeAsync();
if (Network != null)
await Network.DeleteAsync();
Console.WriteLine("=== 全域測試基礎設施清理完成 ===");
}
}
使用全域容器進行測試
public class ComplexInfrastructureTests
{
[Test]
[Property("Category", "Integration")]
[Property("Infrastructure", "Complex")]
[DisplayName("多服務協作:PostgreSQL + Redis + Kafka 完整測試")]
public async Task CompleteWorkflow_多服務協作_應正確執行()
{
var dbConnectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();
var redisConnectionString = GlobalTestInfrastructureSetup.RedisContainer!.GetConnectionString();
var kafkaBootstrapServers = GlobalTestInfrastructureSetup.KafkaContainer!.GetBootstrapAddress();
await Assert.That(dbConnectionString).IsNotNull();
await Assert.That(dbConnectionString).Contains("test_db");
await Assert.That(redisConnectionString).IsNotNull();
await Assert.That(redisConnectionString).Contains("127.0.0.1");
await Assert.That(kafkaBootstrapServers).IsNotNull();
await Assert.That(kafkaBootstrapServers).Contains("127.0.0.1");
}
[Test]
[Property("Category", "Database")]
[DisplayName("PostgreSQL 資料庫連線驗證")]
public async Task PostgreSqlDatabase_連線驗證_應成功建立連線()
{
var connectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();
await Assert.That(connectionString).Contains("test_db");
await Assert.That(connectionString).Contains("test_user");
}
}
Assembly 級別容器共享的好處:
- 大幅減少啟動時間:容器只在 Assembly 開始時啟動一次
- 顯著降低資源消耗:避免每個測試類別重複建立容器
- 提升測試穩定性:減少容器啟動失敗的風險
- 保持測試隔離:測試間仍然可以獨立清理資料
TUnit Engine Modes
Source Generation Mode(預設模式)
████████╗██╗ ██╗███╗ ██╗██╗████████╗
╚══██╔══╝██║ ██║████╗ ██║██║╚══██╔══╝
██║ ██║ ██║██╔██╗ ██║██║ ██║
██║ ██║ ██║██║╚██╗██║██║ ██║
██║ ╚██████╔╝██║ ╚████║██║ ██║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝
Engine Mode: SourceGenerated
特色與優勢:
- 編譯時期產生:所有測試發現邏輯在編譯時產生,不需要執行時反射
- 效能優異:比反射模式快數倍
- 型別安全:編譯時期驗證測試配置和資料來源
- AOT 相容:完全支援 Native AOT 編譯
Reflection Mode(反射模式)
# 啟用反射模式
dotnet run -- --reflection
# 或設定環境變數
$env:TUNIT_EXECUTION_MODE = "reflection"
dotnet run
適用場景:
- 動態測試發現
- F# 和 VB.NET 專案(自動使用)
- 某些依賴反射的測試模式
Native AOT 支援
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -c Release
常見問題與疑難排解
測試統計顯示異常問題
問題現象: 測試摘要: 總計: 0, 失敗: 0, 成功: 0
解決步驟:
- 確保專案檔設定正確:
<PropertyGroup>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
- 確保 GlobalUsings.cs 正確:
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading.Tasks;
global using TUnit.Core;
global using TUnit.Assertions;
global using TUnit.Assertions.Extensions;
- 整合測試的特殊設定:
// 在 WebApi 專案的 Program.cs 最後加上
public partial class Program { } // 讓整合測試可以存取
- 清理和重建:
dotnet clean; dotnet build
dotnet test --verbosity normal
Source Generator 相關問題
問題:測試類別無法被發現
- 解決:確保專案完全重建 (
dotnet clean; dotnet build)
問題:編譯時出現奇怪錯誤
- 解決:檢查是否有其他 Source Generator 套件,考慮更新到相容版本
診斷選項
# .editorconfig
tunit.enable_verbose_diagnostics = true
<PropertyGroup>
<TUnitEnableVerboseDiagnostics>true</TUnitEnableVerboseDiagnostics>
</PropertyGroup>
實務建議
資料驅動測試的選擇策略
- MethodDataSource:適合動態資料、複雜物件、外部檔案載入
- ClassDataSource:適合共享資料、AutoFixture 整合、跨測試類別重用
- Matrix Tests:適合組合測試,但要注意參數數量避免爆炸性增長
執行控制最佳實踐
- Retry:只用於真正不穩定的外部依賴測試
- Timeout:為效能敏感的測試設定合理限制
- DisplayName:讓測試報告更符合業務語言
整合測試策略
- 使用 WebApplicationFactory 進行完整的 Web API 測試
- 運用 TUnit + Testcontainers 建立複雜多服務測試環境
- 透過屬性注入系統管理複雜的依賴關係
- 只測試實際存在的功能,避免測試不存在的端點
範本檔案
| 檔案名稱 | 說明 |
|---|---|
| data-source-examples.cs | MethodDataSource、ClassDataSource 範例 |
| matrix-tests-examples.cs | Matrix Tests 組合測試範例 |
| lifecycle-di-examples.cs | 生命週期管理與依賴注入範例 |
| execution-control-examples.cs | Retry、Timeout、DisplayName 範例 |
| aspnet-integration-tests.cs | ASP.NET Core 整合測試範例 |
| testcontainers-examples.cs | Testcontainers 基礎設施編排範例 |
參考資源
原始文章
本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
-
Day 29 - TUnit 進階應用:資料驅動測試與依賴注入深度實戰
-
Day 30 - TUnit 進階應用 - 執行控制與測試品質和 ASP.NET Core 整合測試實戰
TUnit 官方資源
進階功能文件
- TUnit Method Data Source 文件
- TUnit Class Data Source 文件
- TUnit Matrix Tests 文件
- TUnit Properties 文件
- TUnit Dependency Injection 文件
- TUnit Retrying 文件
- TUnit Timeouts 文件
- TUnit Engine Modes 文件
- TUnit ASP.NET Core 文件
- TUnit Complex Test Infrastructure
Testcontainers 相關資源
Weekly Installs
6
Repository
kevintsengtw/dotnet-testing-agent-skillsInstalled on
claude-code5
antigravity4
gemini-cli4
windsurf3
opencode3
codex3