dotnet-testing-advanced-tunit-advanced
npx skills add https://github.com/kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-advanced-tunit-advanced
Agent 安装分布
Skill 文档
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 é²éæç¨ï¼è³æé© 忏¬è©¦èä¾è³´æ³¨å ¥æ·±åº¦å¯¦æ°
- éµäººè³½æç« ï¼https://ithelp.ithome.com.tw/articles/10377970
- ç¯ä¾ç¨å¼ç¢¼ï¼https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day29
-
Day 30 – TUnit é²éæç¨ – å·è¡æ§å¶è測試å質å ASP.NET Core æ´å測試實æ°
- éµäººè³½æç« ï¼https://ithelp.ithome.com.tw/articles/10378176
- ç¯ä¾ç¨å¼ç¢¼ï¼https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day30
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