dotnet-testing-advanced-tunit-fundamentals
npx skills add https://github.com/kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-advanced-tunit-fundamentals
Agent 安装分布
Skill 文档
TUnit æ°ä¸ä»£æ¸¬è©¦æ¡æ¶å ¥éåºç¤
æè½æ¦è¿°
æ¬æè½æ¶µè TUnit æ°ä¸ä»£ .NET æ¸¬è©¦æ¡æ¶çå ¥éåºç¤ï¼å¾æ¡æ¶ç¹è²å°å¯¦éå°æ¡å»ºç«è測試æ°å¯«ã
æ ¸å¿ä¸»é¡ï¼
- TUnit æ¡æ¶ç¹è²èè¨è¨ç念
- Source Generator é© åçæ¸¬è©¦ç¼ç¾
- AOT (Ahead-of-Time) ç·¨è¯æ¯æ´
- æµæ¢å¼é忥æ·è¨ç³»çµ±
- å°æ¡å»ºç«èå¥ä»¶é ç½®
- è xUnit çèªæ³å·®ç°æ¯è¼
TUnit æ¡æ¶æ ¸å¿ç¹è²
1. Source Generator é© åçæ¸¬è©¦ç¼ç¾
TUnit èå³çµ±æ¸¬è©¦æ¡æ¶æå¤§çå·®ç°å¨æ¼ä½¿ç¨ Source Generator å¨ç·¨è¯ææå®ææ¸¬è©¦ç¼ç¾ï¼
å³çµ±æ¡æ¶çæ¹å¼ï¼xUnitï¼ï¼
// xUnit å¨å·è¡ææééåå°æææææ¹æ³
public class TraditionalTests
{
[Fact] // å·è¡æææè¢«ç¼ç¾
public void TestMethod() { }
}
TUnit ç嵿°åæ³ï¼
// TUnit å¨ç·¨è¯ææå°±éé Source Generator ç¢ç測試註åç¨å¼ç¢¼
public class ModernTests
{
[Test] // ç·¨è¯ææå°±è¢«èçåæä½³å
public async Task TestMethod()
{
await Assert.That(true).IsTrue();
}
}
åªå¢ï¼
- é¿å åå°ææ¬ï¼æææ¸¬è©¦ç¼ç¾å¨ç·¨è¯ææå®æ
- AOT ç¸å®¹ï¼å®å ¨æ¯æ´ Native AOT ç·¨è¯
- æ´å¿«çååæéï¼ç¹å¥æ¯å¨å¤§åæ¸¬è©¦å°æ¡ä¸
2. AOT (Ahead-of-Time) ç·¨è¯æ¯æ´
JIT vs AOT ç·¨è¯æµç¨ï¼
å³çµ± JITï¼C# åå§ç¢¼ â IL ä¸é碼 â å·è¡ææ JIT ç·¨è¯ â æ©å¨ç¢¼ â å·è¡
AOTï¼ C# åå§ç¢¼ â ç·¨è¯ææç´æ¥ç¢ç â æ©å¨ç¢¼ â ç´æ¥å·è¡
AOT ç·¨è¯çåªå¢ï¼
- è¶ å¿«ååæéï¼ç¡éçå¾ JIT ç·¨è¯ï¼
- æ´å°çè¨æ¶é«å ç¨
- å¯é 測çæè½
- æ´é©å容å¨åé¨ç½²
åç¨ AOT æ¯æ´ï¼
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
實éæè½å·®ç°ï¼
å³çµ± JIT ç·¨è¯æ¸¬è©¦ååæéï¼ç´ 1-2 ç§
TUnit AOT ç·¨è¯æ¸¬è©¦ååæéï¼ç´ 50-100 毫ç§
ï¼å¤§åå°æ¡å¯é 10-30 åååæéæ¹åï¼
3. Microsoft.Testing.Platform æ¡ç¨
TUnit 建æ§å¨å¾®è»ææ°ç Microsoft.Testing.Platform ä¹ä¸ï¼èéå³çµ±ç VSTest å¹³å°ï¼
- æ´è¼éçæ¸¬è©¦å·è¡å¨
- æ´å¥½çä¸¦è¡æ§å¶æ©å¶
- åçæ¯æ´ææ°ç IDE æ´å
éè¦æ³¨æäºé
ï¼
TUnit å°æ¡ä¸éè¦ä¹ä¸æè©²å®è£ Microsoft.NET.Test.Sdk å¥ä»¶ã
4. é è¨ä¸¦è¡å·è¡
TUnit å°ä¸¦è¡å·è¡è¨çºé è¨ï¼ä¸¦æä¾ç²¾ç´°çæ§å¶ï¼
// é è¨æææ¸¬è©¦é½æä¸¦è¡å·è¡
[Test]
public async Task ParallelTest1() { }
[Test]
public async Task ParallelTest2() { }
// éè¦æå¯ä»¥æ§å¶ä¸¦è¡è¡çº
[Test]
[NotInParallel("DatabaseTests")]
public async Task DatabaseTest() { }
TUnit å°æ¡å»ºç«
æ¹å¼ä¸ï¼æå建ç«ï¼çè§£åºå±¤æ¶æ§ï¼
# 建ç«å°æ¡ç®é
mkdir TUnitDemo
cd TUnitDemo
# 建ç«è§£æ±ºæ¹æ¡
dotnet new sln -n MyApp
# 建ç«ä¸»å°æ¡
dotnet new classlib -n MyApp.Core -o src/MyApp.Core
# å»ºç«æ¸¬è©¦å°æ¡ï¼ä½¿ç¨ console 模æ¿ï¼
dotnet new console -n MyApp.Tests -o tests/MyApp.Tests
# å å
¥è§£æ±ºæ¹æ¡
dotnet sln add src/MyApp.Core/MyApp.Core.csproj
dotnet sln add tests/MyApp.Tests/MyApp.Tests.csproj
# å å
¥å°æ¡åè
dotnet add tests/MyApp.Tests/MyApp.Tests.csproj reference src/MyApp.Core/MyApp.Core.csproj
æ¹å¼äºï¼ä½¿ç¨ TUnit Templateï¼æ¨è¦ï¼
# å®è£ TUnit å°æ¡æ¨¡æ¿
dotnet new install TUnit.Templates
# ä½¿ç¨ TUnit template å»ºç«æ¸¬è©¦å°æ¡
dotnet new tunit -n MyApp.Tests -o tests/MyApp.Tests
æ¸¬è©¦å°æ¡ csproj è¨å®
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<!-- TUnit æ ¸å¿å¥ä»¶ -->
<PackageReference Include="TUnit" Version="0.57.24" />
<!-- ç¨å¼ç¢¼è¦èçæ¯æ´ -->
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.12.4" />
<!-- TRX å ±åæ¯æ´ -->
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="1.4.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyApp.Core\MyApp.Core.csproj" />
</ItemGroup>
</Project>
GlobalUsings è¨å®
// GlobalUsings.cs
global using TUnit.Core;
global using TUnit.Assertions;
global using MyApp.Core;
éåæ¥æ¸¬è©¦æ¹æ³ï¼å¿ è¦ï¼
TUnit çæææ¸¬è©¦æ¹æ³é½å¿ é æ¯é忥çï¼éæ¯æ¡æ¶çæè¡è¦æ±ï¼
// â é¯èª¤ï¼ç¡æ³ç·¨è¯
[Test]
public void WrongTest()
{
Assert.That(1 + 1).IsEqualTo(2);
}
// â
æ£ç¢ºï¼ä½¿ç¨ async Task
[Test]
public async Task CorrectTest()
{
await Assert.That(1 + 1).IsEqualTo(2);
}
測試屬æ§è忏å
åºæ¬æ¸¬è©¦ [Test]
TUnit çµ±ä¸ä½¿ç¨ [Test] 屬æ§ï¼ä¸å xUnit åå [Fact] å [Theory]ï¼
// TUnitï¼çµ±ä¸ä½¿ç¨ [Test]
[Test]
public async Task Add_輸å
¥1å2_æåå³3()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
await Assert.That(result).IsEqualTo(3);
}
忏忏¬è©¦ [Arguments]
// TUnitï¼ä½¿ç¨ [Arguments]ï¼ç¸ç¶æ¼ xUnit ç [InlineData]ï¼
[Test]
[Arguments(1, 2, 3)]
[Arguments(-1, 1, 0)]
[Arguments(0, 0, 0)]
[Arguments(100, -50, 50)]
public async Task Add_å¤çµè¼¸å
¥_æå峿£ç¢ºçµæ(int a, int b, int expected)
{
var calculator = new Calculator();
var result = calculator.Add(a, b);
await Assert.That(result).IsEqualTo(expected);
}
TUnit.Assertions æ·è¨ç³»çµ±
TUnit æ¡ç¨æµæ¢å¼ï¼Fluentï¼æ·è¨è¨è¨ï¼æææ·è¨é½æ¯é忥çï¼
åºæ¬ç¸çæ§æ·è¨
[Test]
public async Task åºæ¬ç¸çæ§æ·è¨ç¯ä¾()
{
var expected = 42;
var actual = 40 + 2;
await Assert.That(actual).IsEqualTo(expected);
await Assert.That(actual).IsNotEqualTo(43);
// Null 檢æ¥
string? nullValue = null;
await Assert.That(nullValue).IsNull();
await Assert.That("test").IsNotNull();
}
叿弿·è¨
[Test]
public async Task 叿弿·è¨ç¯ä¾()
{
var condition = 1 + 1 == 2;
await Assert.That(condition).IsTrue();
await Assert.That(1 + 1 == 3).IsFalse();
var number = 10;
await Assert.That(number > 5).IsTrue();
}
æ¸å¼æ¯è¼æ·è¨
[Test]
public async Task æ¸å¼æ¯è¼æ·è¨ç¯ä¾()
{
var actual = 10;
await Assert.That(actual).IsGreaterThan(5);
await Assert.That(actual).IsGreaterThanOrEqualTo(10);
await Assert.That(actual).IsLessThan(15);
await Assert.That(actual).IsBetween(5, 15);
}
[Test]
[Arguments(3.14159, 3.14, 0.01)]
public async Task æµ®é»æ¸ç²¾ç¢ºåº¦æ§å¶(double actual, double expected, double tolerance)
{
await Assert.That(actual)
.IsEqualTo(expected)
.Within(tolerance);
}
å串æ·è¨
[Test]
public async Task å串æ·è¨ç¯ä¾()
{
var email = "user@example.com";
await Assert.That(email).Contains("@");
await Assert.That(email).StartsWith("user");
await Assert.That(email).EndsWith(".com");
await Assert.That(email).DoesNotContain(" ");
await Assert.That("").IsEmpty();
await Assert.That(email).IsNotEmpty();
}
éåæ·è¨
[Test]
public async Task éåæ·è¨ç¯ä¾()
{
var numbers = new List<int> { 1, 2, 3, 4, 5 };
await Assert.That(numbers).HasCount(5);
await Assert.That(numbers).IsNotEmpty();
await Assert.That(numbers).Contains(3);
await Assert.That(numbers).DoesNotContain(10);
await Assert.That(numbers.First()).IsEqualTo(1);
await Assert.That(numbers.Last()).IsEqualTo(5);
}
ä¾å¤æ·è¨
[Test]
public async Task ä¾å¤æ·è¨ç¯ä¾()
{
var calculator = new Calculator();
// 檢æ¥ç¹å®ä¾å¤é¡å
await Assert.That(() => calculator.Divide(10, 0))
.Throws<DivideByZeroException>();
// 檢æ¥ä¾å¤è¨æ¯
await Assert.That(() => calculator.Divide(10, 0))
.Throws<DivideByZeroException>()
.WithMessage("餿¸ä¸è½çºé¶");
// 檢æ¥ä¸æåºä¾å¤
await Assert.That(() => calculator.Add(1, 2))
.DoesNotThrow();
}
And / Or æ¢ä»¶çµå
[Test]
public async Task æ¢ä»¶çµåç¯ä¾()
{
var number = 10;
// Andï¼æææ¢ä»¶é½å¿
é æç«
await Assert.That(number)
.IsGreaterThan(5)
.And.IsLessThan(15)
.And.IsEqualTo(10);
// Orï¼ä»»ä¸æ¢ä»¶æç«å³å¯
await Assert.That(number)
.IsEqualTo(5)
.Or.IsEqualTo(10)
.Or.IsEqualTo(15);
}
測試çå½é±æç®¡ç
建æ§å¼è Dispose 模å¼
public class BasicLifecycleTests : IDisposable
{
private readonly Calculator _calculator;
public BasicLifecycleTests()
{
_calculator = new Calculator();
}
[Test]
public async Task Add_åºæ¬æ¸¬è©¦()
{
await Assert.That(_calculator.Add(1, 2)).IsEqualTo(3);
}
public void Dispose()
{
// æ¸
çè³æº
}
}
Before / After 屬æ§
TUnit æä¾æ´ç´°ç·»ççå½é±ææ§å¶ï¼
public class LifecycleTests
{
private static TestDatabase? _database;
// é¡å¥å±¤ç´ï¼æææ¸¬è©¦å·è¡ååªå·è¡ä¸æ¬¡
[Before(Class)]
public static async Task ClassSetup()
{
_database = new TestDatabase();
await _database.InitializeAsync();
}
// 測試層ç´ï¼æ¯å測試å·è¡å齿å·è¡
[Before(Test)]
public async Task TestSetup()
{
await _database!.ClearDataAsync();
}
[Test]
public async Task 測試使ç¨è
建ç«()
{
var userService = new UserService(_database!);
var user = await userService.CreateUserAsync("test@example.com");
await Assert.That(user.Id).IsNotEqualTo(Guid.Empty);
}
// 測試層ç´ï¼æ¯å測試å·è¡å¾é½æå·è¡
[After(Test)]
public async Task TestTearDown()
{
// è¨éæ¸¬è©¦çµæ
}
// é¡å¥å±¤ç´ï¼æææ¸¬è©¦å·è¡å¾åªå·è¡ä¸æ¬¡
[After(Class)]
public static async Task ClassTearDown()
{
if (_database != null)
{
await _database.DisposeAsync();
}
}
}
çå½é±æå±¬æ§ç¨®é¡
| å±¬æ§ | é¡å | 說æ |
|---|---|---|
[Before(Test)] |
坦便¹æ³ | æ¯å測試å·è¡å |
[Before(Class)] |
éæ æ¹æ³ | é¡å¥ä¸ç¬¬ä¸å測試å·è¡å |
[Before(Assembly)] |
éæ æ¹æ³ | çµä»¶ä¸ç¬¬ä¸å測試å·è¡å |
[After(Test)] |
坦便¹æ³ | æ¯å測試å·è¡å¾ |
[After(Class)] |
éæ æ¹æ³ | é¡å¥ä¸æå¾ä¸å測試å·è¡å¾ |
[After(Assembly)] |
éæ æ¹æ³ | çµä»¶ä¸æå¾ä¸å測試å·è¡å¾ |
å·è¡é åº
1. Before(Class)
2. 建æ§å¼
3. Before(Test)
4. æ¸¬è©¦æ¹æ³
5. After(Test)
6. Dispose
7. After(Class)
並è¡å·è¡æ§å¶
NotInParallel 屬æ§
// é è¨ä¸¦è¡å·è¡
[Test]
public async Task ä¸¦è¡æ¸¬è©¦1() { }
[Test]
public async Task ä¸¦è¡æ¸¬è©¦2() { }
// æ§å¶ç¹å®æ¸¬è©¦ä¸è¦ä¸¦è¡
[Test]
[NotInParallel("DatabaseTests")]
public async Task è³æåº«æ¸¬è©¦1_ä¸ä¸¦è¡å·è¡()
{
// é忏¬è©¦ä¸æèå
¶ä» "DatabaseTests" 群çµä¸¦è¡å·è¡
}
[Test]
[NotInParallel("DatabaseTests")]
public async Task è³æåº«æ¸¬è©¦2_ä¸ä¸¦è¡å·è¡()
{
// èè³æåº«æ¸¬è©¦1 ä¾åºå·è¡
}
xUnit è TUnit èªæ³å°ç §
| åè½ | xUnit | TUnit |
|---|---|---|
| åºæ¬æ¸¬è©¦ | [Fact] |
[Test] |
| 忏忏¬è©¦ | [Theory] + [InlineData] |
[Test] + [Arguments] |
| åºæ¬æ·è¨ | Assert.Equal(expected, actual) |
await Assert.That(actual).IsEqualTo(expected) |
| 叿æ·è¨ | Assert.True(condition) |
await Assert.That(condition).IsTrue() |
| ä¾å¤æ¸¬è©¦ | Assert.Throws<T>(() => action()) |
await Assert.That(() => action()).Throws<T>() |
| Null æª¢æ¥ | Assert.Null(value) |
await Assert.That(value).IsNull() |
| åä¸²æª¢æ¥ | Assert.Contains("text", fullString) |
await Assert.That(fullString).Contains("text") |
é·ç§»ç¯ä¾
xUnit åå§ç¨å¼ç¢¼ï¼
[Theory]
[InlineData("test@example.com", true)]
[InlineData("invalid", false)]
public void IsValidEmail_å種輸å
¥_æå峿£ç¢ºé©èçµæ(string email, bool expected)
{
var result = _validator.IsValidEmail(email);
Assert.Equal(expected, result);
}
TUnit è½æå¾ï¼
[Test]
[Arguments("test@example.com", true)]
[Arguments("invalid", false)]
public async Task IsValidEmail_å種輸å
¥_æå峿£ç¢ºé©èçµæ(string email, bool expected)
{
var result = _validator.IsValidEmail(email);
await Assert.That(result).IsEqualTo(expected);
}
主è¦è®æ´ï¼
[Theory]â[Test][InlineData]â[Arguments]- æ¹æ³æ¹çº
async Task - æææ·è¨å ä¸
await - æµæ¢å¼æ·è¨èªæ³
å·è¡èåµé¯
CLI å·è¡
# å»ºç½®å°æ¡
dotnet build
# å·è¡æææ¸¬è©¦
dotnet test
# 詳細輸åº
dotnet test --verbosity normal
# ç¢çè¦èçå ±å
dotnet test --coverage
# éæ¿¾ç¹å®æ¸¬è©¦
dotnet test --filter "ClassName=CalculatorTests"
dotnet test --filter "TestName~Add"
AOT ç·¨è¯å·è¡
# ç¼ä½çº AOT ç·¨è¯çæ¬
dotnet publish -c Release -p:PublishAot=true
# å·è¡ AOT ç·¨è¯ç測試
./bin/Release/net9.0/publish/MyApp.Tests.exe
IDE æ´å
Visual Studio 2022ï¼
- çæ¬é 17.13+
- åç¨ “Use testing platform server mode”
VS Codeï¼
- å®è£ C# Dev Kit æ´å å¥ä»¶
- åç¨ “Use Testing Platform Protocol”
JetBrains Riderï¼
- åç¨ “Testing Platform support”
æè½æ¯è¼
| å ´æ¯ | xUnit | TUnit | TUnit AOT | æè½æå |
|---|---|---|---|---|
| ç°¡å®æ¸¬è©¦å·è¡ | 1,400ms | 1,000ms | 60ms | 23x (AOT) |
| éåæ¥æ¸¬è©¦ | 1,400ms | 930ms | 26ms | 54x (AOT) |
| ä¸¦è¡æ¸¬è©¦ | 1,425ms | 999ms | 54ms | 26x (AOT) |
常è¦åé¡èè§£æ±ºæ¹æ¡
åé¡ 1ï¼å¥ä»¶ç¸å®¹æ§
é¯èª¤ï¼ å®è£äº Microsoft.NET.Test.Sdk å°è´æ¸¬è©¦ç¡æ³ç¼ç¾
è§£æ±ºæ¹æ¡ï¼ ç§»é¤ Microsoft.NET.Test.Sdkï¼TUnit ä½¿ç¨æ°ç測試平å°
åé¡ 2ï¼IDE æ´ååé¡
ççï¼ æ¸¬è©¦å¨ IDE ä¸ç¡æ³é¡¯ç¤ºæå·è¡
è§£æ±ºæ¹æ¡ï¼
- ç¢ºèª IDE çæ¬æ¯æ´ Microsoft.Testing.Platform
- åç¨ç¸éé 覽åè½
- éæ°è¼å ¥å°æ¡æéå IDE
åé¡ 3ï¼é忥æ·è¨éºå¿
ççï¼ ç·¨è¯é¯èª¤ææ·è¨ç¡æ³æ£å¸¸å·è¡
è§£æ±ºæ¹æ¡ï¼ æææ·è¨é½éè¦ awaitï¼æ¸¬è©¦æ¹æ³å¿
é æ¯ async Task
é©ç¨å ´æ¯è©ä¼°
é©åä½¿ç¨ TUnit
- å ¨æ°å°æ¡ï¼æ²ææ·å²å 袱
- æè½è¦æ±é«ï¼å¤§å測試å¥ä»¶ï¼1000+ 測試ï¼
- æè¡æ£§å é²ï¼ä½¿ç¨ .NET 8+ï¼è¨åæ¡ç¨ AOT
- CI/CD é度使ç¨ï¼æ¸¬è©¦å·è¡æéç´æ¥å½±é¿é¨ç½²é »ç
- 容å¨åé¨ç½²ï¼å¿«éååæéå¾éè¦
æ«æä¸å»ºè°
- Legacy å°æ¡ï¼å·²æå¤§é xUnit 測試
- ä¿å®åéï¼éè¦ç©©å®æ§åé嵿°æ§
- è¤éæ¸¬è©¦çæ ï¼å¤§éä½¿ç¨ xUnit ç¹å®å¥ä»¶
- èç .NETï¼éå¨ .NET 6/7
åèè³æº
åå§æç«
æ¬æè½å §å®¹æç èªãèæ´¾è»é«å·¥ç¨å¸«ç測試修練 – 30 å¤©ææ°ãç³»åæç« ï¼
- Day 28 – TUnit å
¥é – ä¸ä¸ä»£ .NET æ¸¬è©¦æ¡æ¶æ¢ç´¢
- éµäººè³½æç« ï¼https://ithelp.ithome.com.tw/articles/10377828
- ç¯ä¾ç¨å¼ç¢¼ï¼https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day28