在前面的文章中,我們深入探討了 xUnit 的各種功能和最佳實踐。從 Day 02 的框架基礎,到後續章節的進階技巧,xUnit 一直是我們可靠的測試夥伴。
但軟體開發的世界從不停歇。隨著 .NET 生態系統的持續演進,特別是 Source Generator、AOT 編譯等現代技術的成熟,測試框架也開始迎來新的變革。
今天要介紹的 TUnit,就是在這樣的背景下誕生的新世代測試框架。它不只是另一個「功能類似」的選擇,而是從根本上重新思考測試框架應該如何運作的創新嘗試。
為什麼要關注 TUnit?
身為一個從 MSTest 轉向 xUnit 的老派工程師,我對於「換測試框架」這件事其實相當謹慎。畢竟,測試程式碼的價值在於穩定性和可靠性,不是嗎?
但 TUnit 的出現確實讓我重新思考這個問題:
重點是,TUnit 採用了現代軟體開發的兩個核心方向:編譯時期最佳化和效能優先。
傳統測試框架(包括 xUnit)在執行時都需要透過反射來發現測試方法。這個過程雖然靈活,但也帶來了效能負擔:
傳統方式的限制:
// xUnit 在執行時期透過反射掃描所有方法
// 尋找標記 [Fact] 或 [Theory] 的方法
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(Ahead-of-Time)編譯是相對於 JIT(Just-in-Time)編譯的概念。讓我們用老派工程師的角度來理解這個差異:
傳統 .NET 的 JIT 編譯流程:
C# 原始碼 → IL 中間碼 → 執行時期 JIT 編譯 → 機器碼 → 執行
AOT 編譯流程:
C# 原始碼 → 編譯時期直接產生 → 機器碼 → 直接執行
AOT 編譯的關鍵優勢:
實務應用場景:
為什麼傳統測試框架無法支援 AOT?
傳統測試框架大量使用反射來發現測試:
// 這種程式碼在 AOT 中會失敗
var methods = typeof(TestClass).GetMethods()
.Where(m => m.GetCustomAttribute<FactAttribute>() != null);
AOT 編譯器無法預知哪些型別會被反射使用,因此無法正確產生機器碼。
TUnit 如何解決這個問題?
透過 Source Generator 在編譯時期產生所有必要的程式碼:
// TUnit 會在編譯時期產生類似這樣的程式碼
public static void RegisterTests()
{
TestRegistry.Add(new TestInfo("MyTest", typeof(MyTestClass), ...));
}
這樣 AOT 編譯器就能看到所有需要的型別和方法,正確產生最佳化的機器碼。
相關連結:
理論說完了,來看實際操作。要讓 TUnit 測試專案支援 AOT 編譯,需要在專案檔中添加相關設定:
修改測試專案的 .csproj 檔案:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- 啟用 AOT 支援 -->
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" Version="0.57.24" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.12.4" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="1.4.3" />
</ItemGroup>
</Project>
執行 AOT 編譯和測試:
# 一般建置(JIT 模式)
dotnet build
# 發佈為 AOT 編譯版本
dotnet publish -c Release
# 執行 AOT 編譯的測試(更快的啟動時間)
./bin/Release/net9.0/publish/TUnit.Demo.Tests.exe
# 或者直接發佈並執行 AOT 測試
dotnet publish -c Release -p:PublishAot=true
AOT 編譯的注意事項:
實際效能差異範例:
假設有一個包含 100 個測試的專案:
傳統 JIT 編譯測試啟動時間:約 1-2 秒
TUnit AOT 編譯測試啟動時間:約 50-100 毫秒
在 CI/CD 環境中,這個差異會隨著測試數量增加而更加明顯,大型專案可能有 10-30 倍的啟動時間改善。
TUnit 建構在微軟最新的 Microsoft.Testing.Platform 之上,而非傳統的 VSTest 平台。這個轉換帶來了:
現代化的測試執行體驗:
重要注意事項:
與傳統測試框架不同,TUnit 專案不需要也不應該安裝 Microsoft.NET.Test.Sdk
套件。這是因為 TUnit 使用新的測試平台,舊的 VSTest SDK 反而會造成衝突。
xUnit 雖然支援並行執行,但需要額外設定。TUnit 則將並行執行設為預設,並提供更精細的控制:
// 預設所有測試都會並行執行
[Test]
public async Task ParallelTest1() { }
[Test]
public async Task ParallelTest2() { }
// 需要時可以控制並行行為
[Test]
[NotInParallel("DatabaseTests")]
public async Task DatabaseTest() { }
TUnit.Assertions 提供了比 xUnit 更直覺的斷言語法。這種流暢介面設計讓斷言讀起來更像自然語言,降低了認知負擔:
// xUnit 的斷言方式 - 需要記住參數順序
Assert.Equal(expected, actual); // 哪個是期望值?哪個是實際值?
Assert.True(condition);
Assert.Throws<Exception>(() => action());
// TUnit 的流暢式斷言 - 語意清楚,參數順序直覺
await Assert.That(actual).IsEqualTo(expected); // 清楚表達:實際值應該等於期望值
await Assert.That(condition).IsTrue();
await Assert.That(() => action()).Throws<Exception>();
這種設計的優點包括:
注意所有 TUnit 的斷言都是非同步的,需要使用 await
關鍵字。
TUnit 與傳統測試框架最顯著的差異之一,就是所有測試方法都必須是非同步的。這不是設計上的選擇,而是技術上的必然:
TUnit 的所有斷言方法都回傳 Task
,這是因為框架內部採用非同步設計來提供更好的效能和並行控制。這意味著:
// X 這樣寫會編譯錯誤
[Test]
public void WrongTest()
{
Assert.That(1 + 1).IsEqualTo(2); // 錯誤:無法等待非 Task 的方法
}
// O 正確的寫法
[Test]
public async Task CorrectTest()
{
await Assert.That(1 + 1).IsEqualTo(2); // 正確:使用 await 等待斷言完成
}
這種設計帶來幾個重要好處:
Task
正確傳播[Test]
public async Task 展示非同步斷言的優勢()
{
var startTime = DateTime.Now;
// 多個斷言可以併發執行
await Assert.That(1 + 1).IsEqualTo(2);
await Assert.That("Hello".Length).IsEqualTo(5);
await Assert.That(DateTime.Now).IsGreaterThan(startTime);
// 所有斷言都會等待完成後才結束測試
}
在 xUnit 中,我們區分 [Fact]
(固定測試)和 [Theory]
(參數化測試)。TUnit 簡化了這個概念:
// xUnit 的方式
[Fact]
public void FixedTest() { }
[Theory]
[InlineData(1, 2, 3)]
public void ParameterizedTest(int a, int b, int expected) { }
// TUnit 的方式 - 統一使用 [Test]
[Test]
public async Task FixedTest()
{
await Assert.That(1 + 1).IsEqualTo(2);
}
[Test]
[Arguments(1, 2, 3)]
public async Task ParameterizedTest(int a, int b, int expected)
{
await Assert.That(a + b).IsEqualTo(expected);
}
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)
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(a, b);
// Assert
await Assert.That(result).IsEqualTo(expected);
}
TUnit 的斷言系統採用流暢介面設計,所有斷言都是非同步的,需要使用 await
關鍵字。讓我們按照不同的斷言類型來深入了解:
最常用的斷言類型,用於檢查值是否符合預期:
[Test]
public async Task 基本相等性斷言範例()
{
// 數值相等檢查
var expected = 42;
var actual = 40 + 2;
await Assert.That(actual).IsEqualTo(expected);
await Assert.That(actual).IsNotEqualTo(43);
// 物件相等檢查
var obj1 = new object();
var obj2 = obj1;
await Assert.That(obj2).IsEqualTo(obj1);
// Null 值檢查
string? nullValue = null;
string notNullValue = "test";
await Assert.That(nullValue).IsNull();
await Assert.That(notNullValue).IsNotNull();
}
用於驗證條件式結果:
[Test]
public async Task 布林值斷言範例()
{
// 基本布林檢查
var condition1 = 1 + 1 == 2;
var condition2 = 1 + 1 == 3;
await Assert.That(condition1).IsTrue();
await Assert.That(condition2).IsFalse();
// 條件式布林檢查
var number = 10;
await Assert.That(number > 5).IsTrue();
await Assert.That(number < 5).IsFalse();
}
提供豐富的數值比較功能,包括範圍檢查和精確度控制:
[Test]
public async Task 數值比較斷言範例()
{
var actualValue = 5 + 5; // 10
var compareValue = 3 + 2; // 5
var equalValue = 4 + 6; // 10
// 基本數值比較
await Assert.That(actualValue).IsGreaterThan(compareValue);
await Assert.That(actualValue).IsGreaterThanOrEqualTo(equalValue);
await Assert.That(compareValue).IsLessThan(actualValue);
await Assert.That(compareValue).IsLessThanOrEqualTo(compareValue);
// 數值範圍檢查
await Assert.That(actualValue).IsBetween(5, 15);
}
[Test]
[Arguments(3.14159, 3.14, 0.01)]
[Arguments(1.0001, 1.0, 0.001)]
[Arguments(99.999, 100.0, 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";
var emptyString = "";
// 包含檢查
await Assert.That(email).Contains("@");
await Assert.That(email).Contains("example");
await Assert.That(email).DoesNotContain(" ");
// 開始/結束檢查
await Assert.That(email).StartsWith("user");
await Assert.That(email).EndsWith(".com");
// 空字串檢查
await Assert.That(emptyString).IsEmpty();
await Assert.That(email).IsNotEmpty();
// 字串長度檢查
await Assert.That(email.Length).IsGreaterThan(5);
await Assert.That(email.Length).IsEqualTo(16);
}
處理陣列、List 等集合類型的驗證:
[Test]
public async Task 集合斷言範例()
{
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var emptyList = new List<string>();
// 集合計數檢查
await Assert.That(numbers).HasCount(5);
await Assert.That(emptyList).IsEmpty();
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);
await Assert.That(numbers[2]).IsEqualTo(3);
// 集合全部檢查
await Assert.That(numbers.All(x => x > 0)).IsTrue();
await Assert.That(numbers.Any(x => x > 3)).IsTrue();
}
驗證程式碼是否按預期拋出例外:
[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();
}
TUnit 支援使用 And
將多個斷言組合在一起:
[Test]
public async Task And條件組合範例()
{
var number = 10;
// 組合多個條件
await Assert.That(number)
.IsGreaterThan(5)
.And.IsLessThan(15)
.And.IsEqualTo(10);
var email = "test@example.com";
await Assert.That(email)
.Contains("@")
.And.EndsWith(".com")
.And.StartsWith("test");
}
除了 And
條件,TUnit 也支援使用 Or
來組合多個條件,當任一條件成立時測試就會通過:
[Test]
public async Task Or條件組合範例()
{
var number = 15;
// 任一條件成立即可通過
await Assert.That(number)
.IsEqualTo(10)
.Or.IsEqualTo(15)
.Or.IsEqualTo(20);
var text = "Hello World";
await Assert.That(text)
.StartsWith("Hi")
.Or.StartsWith("Hello")
.Or.StartsWith("Hey");
}
[Test]
public async Task Or條件實務範例()
{
var email = "admin@company.com";
// 檢查是否為管理員或測試帳號
await Assert.That(email)
.StartsWith("admin@")
.Or.StartsWith("test@")
.Or.Contains("@localhost");
var httpStatusCode = 200;
// 檢查是否為成功的 HTTP 狀態碼
await Assert.That(httpStatusCode)
.IsEqualTo(200) // OK
.Or.IsEqualTo(201) // Created
.Or.IsEqualTo(204); // No Content
}
Or 條件的實用場景:
在測試涉及時間的程式碼時,我們需要能夠控制時間來確保測試的可預測性。TUnit 與 .NET 的 TimeProvider
完美整合,提供強大的時間測試能力。
任何涉及時間的程式碼都是測試的噩夢。直接使用 DateTime.Now
或 DateTimeOffset.Now
會讓測試變得不可預測,因為時間總是在變化:
// 難以測試的程式碼 - 老派工程師踩過的坑
public class BadTimeService
{
public User CreateUser(string email)
{
return new User
{
Email = email,
CreatedAt = DateTime.Now // 每次執行結果都不同,測試根本無法穩定
};
}
public bool IsWorkingHours()
{
var now = DateTime.Now;
return now.Hour >= 9 && now.Hour < 17; // 這怎麼測試?只能等到下班時間?
}
}
實務問題:
使用 TimeProvider
可以讓我們在測試中完全控制時間:
// 可測試的程式碼 - 依賴注入解決時間問題
public class TimeService
{
private readonly TimeProvider _timeProvider;
public TimeService(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public User CreateUser(string email)
{
return new User
{
Email = email,
CreatedAt = _timeProvider.GetLocalNow().DateTime
};
}
public bool IsWorkingHours()
{
var now = _timeProvider.GetLocalNow();
return now.Hour >= 9 && now.Hour < 17;
}
}
在測試中,我們可以使用 Microsoft.Extensions.TimeProvider.Testing
套件提供的 FakeTimeProvider
:
[Test]
public async Task CreateUser_應設定正確的時間戳記()
{
// Arrange
var fakeTime = new DateTimeOffset(2024, 12, 25, 10, 30, 0, TimeSpan.Zero);
var fakeTimeProvider = new FakeTimeProvider(fakeTime);
var timeService = new TimeService(fakeTimeProvider);
var email = "test@example.com";
// Act
var user = timeService.CreateUser(email);
// Assert
await Assert.That(user.CreatedAt).IsEqualTo(fakeTime.DateTime);
await Assert.That(user.Email).IsEqualTo(email);
await Assert.That(user.Id).IsNotEqualTo(Guid.Empty);
}
[Test]
public async Task CalculateElapsed_應計算正確的時間差()
{
// Arrange
var fakeTimeProvider = new FakeTimeProvider();
var timeService = new TimeService(fakeTimeProvider);
var startTime = fakeTimeProvider.GetLocalNow().DateTime;
// Act - 前進 2 小時
fakeTimeProvider.Advance(TimeSpan.FromHours(2));
var elapsed = timeService.CalculateElapsed(startTime);
// Assert
await Assert.That(elapsed).IsEqualTo(TimeSpan.FromHours(2));
}
[Test]
[Arguments(9, true)] // 9:00 AM - 營業時間
[Arguments(12, true)] // 12:00 PM - 營業時間
[Arguments(16, true)] // 4:00 PM - 營業時間
[Arguments(8, false)] // 8:00 AM - 非營業時間
[Arguments(17, false)] // 5:00 PM - 非營業時間
[Arguments(22, false)] // 10:00 PM - 非營業時間
public async Task IsBusinessHours_各時段檢查_應回傳正確結果(int hour, bool expected)
{
// Arrange
var testTime = new DateTimeOffset(2024, 6, 15, hour, 0, 0, TimeSpan.Zero);
var fakeTimeProvider = new FakeTimeProvider(testTime);
var timeService = new TimeService(fakeTimeProvider);
// Act
var result = timeService.IsBusinessHours();
// Assert
await Assert.That(result).IsEqualTo(expected);
}
[Test]
public async Task GetTimeBasedDiscount_週五_應回傳週五優惠()
{
// Arrange - 設定為週五
var friday = new DateTimeOffset(2024, 6, 14, 12, 0, 0, TimeSpan.Zero); // 2024/6/14 是週五
var fakeTimeProvider = new FakeTimeProvider(friday);
var timeService = new TimeService(fakeTimeProvider);
// Act
var discount = timeService.GetTimeBasedDiscount();
// Assert
await Assert.That(discount).IsEqualTo("週五快樂:九折優惠");
}
[Test]
public async Task GetTimeBasedDiscount_聖誕節_應回傳聖誕優惠()
{
// Arrange - 設定為聖誕節
var christmas = new DateTimeOffset(2024, 12, 25, 12, 0, 0, TimeSpan.Zero);
var fakeTimeProvider = new FakeTimeProvider(christmas);
var timeService = new TimeService(fakeTimeProvider);
// Act
var discount = timeService.GetTimeBasedDiscount();
// Assert
await Assert.That(discount).IsEqualTo("聖誕特惠:八折優惠");
}
TUnit 提供靈活的測試生命週期管理機制,支援多種設定和清理方式。與 xUnit 類似,但提供更多控制選項。
最基本的生命週期管理,與 xUnit 完全相容:
public class BasicLifecycleTests : IDisposable
{
private readonly Calculator _calculator;
// 每個測試執行前都會呼叫建構式
public BasicLifecycleTests()
{
_calculator = new Calculator();
Console.WriteLine("建構式:建立 Calculator 實例");
}
[Test]
public async Task Add_基本測試()
{
Console.WriteLine("執行測試:Add_基本測試");
await Assert.That(_calculator.Add(1, 2)).IsEqualTo(3);
}
[Test]
public async Task Multiply_基本測試()
{
Console.WriteLine("執行測試:Multiply_基本測試");
await Assert.That(_calculator.Multiply(3, 4)).IsEqualTo(12);
}
// 每個測試執行後都會呼叫 Dispose
public void Dispose()
{
Console.WriteLine("Dispose:清理資源");
// 進行必要的清理工作
}
}
TUnit 使用 [Before(...)]
和 [After(...)]
屬性來管理更複雜的生命週期:
public class DatabaseLifecycleTests
{
private static TestDatabase? _database;
// 所有測試執行前只執行一次
[Before(Class)]
public static async Task ClassSetup()
{
_database = new TestDatabase();
await _database.InitializeAsync();
Console.WriteLine("資料庫初始化完成");
}
// 每個測試執行前都會執行
[Before(Test)]
public async Task TestSetup()
{
Console.WriteLine("測試準備:清理資料庫狀態");
await _database!.ClearDataAsync();
}
[Test]
public async Task 測試使用者建立()
{
// Arrange
var userService = new UserService(_database!);
// Act
var user = await userService.CreateUserAsync("test@example.com");
// Assert
await Assert.That(user.Id).IsNotEqualTo(Guid.Empty);
await Assert.That(user.Email).IsEqualTo("test@example.com");
}
[Test]
public async Task 測試使用者查詢()
{
// Arrange
var userService = new UserService(_database!);
await userService.CreateUserAsync("query@example.com");
// Act
var user = await userService.GetUserByEmailAsync("query@example.com");
// Assert
await Assert.That(user).IsNotNull();
await Assert.That(user!.Email).IsEqualTo("query@example.com");
}
// 每個測試執行後都會執行
[After(Test)]
public async Task TestTearDown()
{
Console.WriteLine("測試清理:記錄測試結果");
// 可以在這裡記錄測試執行資訊
}
// 所有測試執行後只執行一次
[After(Class)]
public static async Task ClassTearDown()
{
if (_database != null)
{
await _database.DisposeAsync();
Console.WriteLine("資料庫連線關閉");
}
}
}
TUnit 提供多層級的生命週期控制:
屬性 | 類型 | 說明 |
---|---|---|
[Before(Test)] |
實例方法 | 每個測試執行前 |
[Before(Class)] |
靜態方法 | 類別中第一個測試執行前 |
[Before(Assembly)] |
靜態方法 | 組件中第一個測試執行前 |
[After(Test)] |
實例方法 | 每個測試執行後 |
[After(Class)] |
靜態方法 | 類別中最後一個測試執行後 |
[After(Assembly)] |
靜態方法 | 組件中最後一個測試執行後 |
TUnit 的生命週期方法全都支援非同步:
public class AsyncLifecycleTests
{
private HttpClient? _httpClient;
[Before(Test)]
public async Task SetupAsync()
{
// 非同步設定
_httpClient = new HttpClient();
await Task.Delay(100); // 模擬非同步初始化
}
[Test]
public async Task Http請求測試()
{
// 使用已設定的 HttpClient
var response = await _httpClient!.GetAsync("https://httpbin.org/status/200");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
[After(Test)]
public async Task TearDownAsync()
{
// 非同步清理
if (_httpClient != null)
{
_httpClient.Dispose();
}
await Task.CompletedTask;
}
}
TUnit 的生命週期方法執行順序如下:
public class LifecycleOrderDemoTests : IDisposable
{
public LifecycleOrderDemoTests()
{
Console.WriteLine("2. 建構式執行");
}
[Before(Class)]
public static void ClassSetup()
{
Console.WriteLine("1. Before(Class) 執行");
}
[Before(Test)]
public async Task TestSetup()
{
Console.WriteLine("3. Before(Test) 執行");
await Task.CompletedTask;
}
[Test]
public async Task 示範測試()
{
Console.WriteLine("4. 測試方法執行");
await Assert.That(true).IsTrue();
}
[After(Test)]
public async Task TestTearDown()
{
Console.WriteLine("5. After(Test) 執行");
await Task.CompletedTask;
}
public void Dispose()
{
Console.WriteLine("6. Dispose 執行");
}
[After(Class)]
public static void ClassTearDown()
{
Console.WriteLine("7. After(Class) 執行");
}
}
重要的是,與 xUnit 相同,TUnit 也會為每個測試方法建立新的測試類別實例,確保測試之間的隔離性。
來實際建立一個 TUnit 測試專案。這裡我們會建立標準的解決方案結構,就像真實專案一樣:
# 建立專案目錄
mkdir TUnitDemo
cd TUnitDemo
# 建立解決方案
dotnet new sln -n Day28.TUnit
# 建立主專案(核心業務邏輯)
dotnet new classlib -n TUnit.Demo.Core -o src/TUnit.Demo.Core
# 建立測試專案(注意:使用 console 模板,不是 mstest)
dotnet new console -n TUnit.Demo.Tests -o tests/TUnit.Demo.Tests
# 將專案加入解決方案
dotnet sln add src/TUnit.Demo.Core/TUnit.Demo.Core.csproj
dotnet sln add tests/TUnit.Demo.Tests/TUnit.Demo.Tests.csproj
# 建立測試專案對主專案的參考
dotnet add tests/TUnit.Demo.Tests/TUnit.Demo.Tests.csproj reference src/TUnit.Demo.Core/TUnit.Demo.Core.csproj
為什麼使用 console 模板?
TUnit 不是基於傳統的 VSTest 平台,而是使用 Microsoft.Testing.Platform。因此我們不能使用 dotnet new mstest
或 dotnet new xunit
模板,而是從空白的 console 專案開始設定。
除了手動使用 console 模板建立外,TUnit 現在也提供了官方的專案模板,可以更快速地建立測試專案:
安裝 TUnit Templates:
# 安裝 TUnit 專案模板
dotnet new install TUnit.Templates
# 查看可用的 TUnit 模板
dotnet new list tunit
使用 TUnit Template 建立專案:
# 建立專案目錄
mkdir TUnitDemo-WithTemplate
cd TUnitDemo-WithTemplate
# 使用 TUnit template 建立測試專案
dotnet new tunit -n TUnit.Demo.Tests -o tests/TUnit.Demo.Tests
# 建立主專案
dotnet new classlib -n TUnit.Demo.Core -o src/TUnit.Demo.Core
# 建立解決方案並加入專案
dotnet new sln -n Day28.TUnit
dotnet sln add src/TUnit.Demo.Core/TUnit.Demo.Core.csproj
dotnet sln add tests/TUnit.Demo.Tests/TUnit.Demo.Tests.csproj
# 建立測試專案對主專案的參考
dotnet add tests/TUnit.Demo.Tests/TUnit.Demo.Tests.csproj reference src/TUnit.Demo.Core/TUnit.Demo.Core.csproj
TUnit Template 的優勢:
Template vs 手動設定的選擇:
由於我們要深入了解 TUnit 的架構,接下來仍使用手動設定的方式進行說明。
修改 TUnit.Demo.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>
<PackageReference Include="TUnit" Version="0.57.24" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.12.4" />
<PackageReference Include="Microsoft.Testing.Extensions.TrxReport" Version="1.4.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\src\\TUnit.Demo.Core\\TUnit.Demo.Core.csproj" />
</ItemGroup>
</Project>
重要提醒:
Microsoft.NET.Test.Sdk
在測試專案中建立 GlobalUsings.cs
:
global using TUnit.Core;
global using TUnit.Assertions;
global using TUnit.Demo.Core;
在主專案中建立 Calculator.cs
:
namespace TUnit.Demo.Core;
/// <summary>
/// 基本計算器類別,提供數學運算功能
/// </summary>
public class Calculator
{
/// <summary>
/// 加法運算
/// </summary>
public int Add(int a, int b) => a + b;
/// <summary>
/// 除法運算
/// </summary>
public double Divide(int dividend, int divisor)
{
if (divisor == 0)
{
throw new DivideByZeroException("除數不能為零");
}
return (double)dividend / divisor;
}
/// <summary>
/// 判斷是否為正數
/// </summary>
public bool IsPositive(int number) => number > 0;
}
建立 TimeService.cs
來展示時間相關測試:
namespace TUnit.Demo.Core;
/// <summary>
/// 時間服務類別,展示時間相關的測試案例
/// </summary>
public class TimeService
{
/// <summary>
/// 建立使用者時設定時間戳記
/// </summary>
public User CreateUser(string email)
{
return new User
{
Email = email,
CreatedAt = DateTime.Now,
Id = Guid.NewGuid()
};
}
}
public class User
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
在測試專案中建立 CalculatorTests.cs
:
namespace TUnit.Demo.Tests;
/// <summary>
/// Calculator 類別的測試案例
/// </summary>
public class CalculatorTests
{
private readonly Calculator _calculator;
public CalculatorTests()
{
_calculator = new Calculator();
}
#region 基本測試
[Test]
public async Task Add_輸入1和2_應回傳3()
{
// Arrange
int a = 1;
int b = 2;
int expected = 3;
// Act
var result = _calculator.Add(a, b);
// Assert
await Assert.That(result).IsEqualTo(expected);
}
[Test]
public async Task Divide_輸入0作為除數_應拋出DivideByZeroException()
{
// Arrange
int dividend = 10;
int divisor = 0;
// Act & Assert
await Assert.That(() => _calculator.Divide(dividend, divisor))
.Throws<DivideByZeroException>();
}
#endregion
#region 參數化測試
[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)
{
// Act
var result = _calculator.Add(a, b);
// Assert
await Assert.That(result).IsEqualTo(expected);
}
[Test]
[Arguments(1, true)]
[Arguments(-1, false)]
[Arguments(0, false)]
[Arguments(100, true)]
public async Task IsPositive_各種數值_應回傳正確結果(int number, bool expected)
{
// Act
var result = _calculator.IsPositive(number);
// Assert
await Assert.That(result).IsEqualTo(expected);
}
#endregion
}
建立 TUnitAdvancedTests.cs
展示進階功能:
namespace TUnit.Demo.Tests;
/// <summary>
/// 展示 TUnit 進階功能的測試類別
/// </summary>
public class TUnitAdvancedTests
{
private readonly TimeService _timeService;
public TUnitAdvancedTests()
{
_timeService = new TimeService();
}
[Test]
public async Task CreateUser_應設定正確的時間戳記()
{
// Arrange
var email = "test@example.com";
var beforeCreate = DateTime.Now;
// Act
var user = _timeService.CreateUser(email);
// Assert - 展示時間範圍驗證
await Assert.That(user.CreatedAt)
.IsGreaterThanOrEqualTo(beforeCreate)
.And.IsLessThanOrEqualTo(DateTime.Now.AddSeconds(1));
await Assert.That(user.Email).IsEqualTo(email);
await Assert.That(user.Id).IsNotEqualTo(Guid.Empty);
}
[Test]
public async Task 集合斷言範例()
{
// Arrange
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Assert - 展示集合斷言
await Assert.That(numbers).HasCount(5);
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]
[Arguments(3.14159, 3.14, 0.01)]
[Arguments(1.0001, 1.0, 0.001)]
public async Task 浮點數比較_應允許誤差範圍(double actual, double expected, double tolerance)
{
// Assert - 展示浮點數精確度控制
await Assert.That(actual)
.IsEqualTo(expected)
.Within(tolerance);
}
[Test]
[NotInParallel("DatabaseTests")]
public async Task 資料庫測試_示範並行控制()
{
// 模擬資料庫操作
await Task.Delay(100);
var result = 1 + 1;
await Assert.That(result).IsEqualTo(2);
}
}
TUnit 支援標準的 dotnet CLI 指令,就像其他測試框架一樣:
# 建置專案(確認沒有編譯錯誤)
dotnet build
# 執行所有測試(簡潔輸出)
dotnet test
# 執行測試並顯示詳細輸出(推薦用於偵錯)
dotnet test --verbosity normal
# 產生覆蓋率報告(需要 CodeCoverage 套件)
dotnet test --coverage
# 指定特定測試類別或方法
dotnet test --filter "ClassName=CalculatorTests"
dotnet test --filter "TestName~Add"
實用技巧:
--verbosity normal
可以看到測試的詳細執行過程TestResults
資料夾中TUnit 有自己獨特的執行畫面,第一次看到可能會覺得很獨特:
████████╗██╗ ██╗███╗ ██╗██╗████████╗
╚══██╔══╝██║ ██║████╗ ██║██║╚══██╔══╝
██║ ██║ ██║██╔██╗ ██║██║ ██║
██║ ██║ ██║██║╚██╗██║██║ ██║
██║ ╚██████╔╝██║ ╚████║██║ ██║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝
TUnit v0.57.24 | 64-bit | Microsoft Windows | .NET 9.0.0
測試摘要: 總計: 32, 失敗: 0, 成功: 32, 已跳過: 0, 持續時間: 1.1 秒
注意執行時間通常比 xUnit 要快不少,特別是在大型測試專案中。
Visual Studio Code:
Visual Studio 2022:
JetBrains Rider:
以下是常用功能的語法對照:
功能 | 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 測試:
public class EmailValidatorTests
{
private readonly EmailValidator _validator;
public EmailValidatorTests()
{
_validator = new EmailValidator();
}
[Theory]
[InlineData("test@example.com", true)]
[InlineData("invalid-email", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsValidEmail_各種輸入_應回傳正確驗證結果(string email, bool expected)
{
var result = _validator.IsValidEmail(email);
Assert.Equal(expected, result);
}
[Fact]
public void GetDomain_輸入無效Email_應拋出ArgumentException()
{
var invalidEmail = "invalid-email";
Assert.Throws<ArgumentException>(() => _validator.GetDomain(invalidEmail));
}
}
轉換後的 TUnit 測試:
public class EmailValidatorTests
{
private readonly EmailValidator _validator;
public EmailValidatorTests()
{
_validator = new EmailValidator();
}
[Test]
[Arguments("test@example.com", true)]
[Arguments("invalid-email", false)]
[Arguments("", false)]
[Arguments(null, false)]
public async Task IsValidEmail_各種輸入_應回傳正確驗證結果(string email, bool expected)
{
var result = _validator.IsValidEmail(email);
await Assert.That(result).IsEqualTo(expected);
}
[Test]
public async Task GetDomain_輸入無效Email_應拋出ArgumentException()
{
var invalidEmail = "invalid-email";
await Assert.That(() => _validator.GetDomain(invalidEmail))
.Throws<ArgumentException>();
}
}
主要變更:
[Theory]
→ [Test]
:TUnit 統一使用 [Test]
屬性[InlineData]
→ [Arguments]
:參數化測試的語法更簡潔[Fact]
→ [Test]
:所有測試都使用相同的屬性async Task
:這是 TUnit 的強制要求,因為所有斷言都是非同步的await
:TUnit 的斷言系統完全基於非同步設計Assert.Equal(expected, actual)
→ await Assert.That(actual).IsEqualTo(expected)
:流暢式斷言語法更直覺根據 TUnit 官方提供的基準測試,在相同的測試案例下:
場景 | xUnit | TUnit | TUnit AOT | 效能提升 |
---|---|---|---|---|
簡單測試執行 | 1,400ms | 1,000ms | 60ms | 23x (AOT) |
非同步測試 | 1,400ms | 930ms | 26ms | 54x (AOT) |
並行測試 | 1,425ms | 999ms | 54ms | 26x (AOT) |
這些數據顯示 TUnit 在效能上有顯著優勢,特別是在啟用 AOT 編譯時。
從上面的數據可以看到,AOT 編譯帶來的效能提升是驚人的:
為什麼 AOT 版本快這麼多?
零啟動開銷:
記憶體效率:
預先最佳化:
實際專案中的意義:
假設一個中型專案有 500 個測試:
- xUnit: 1,400ms × 500 = 11.7 分鐘
- TUnit: 1,000ms × 500 = 8.3 分鐘
- TUnit AOT: 60ms × 500 = 30 秒
在 CI/CD 管道中,這個差異會讓開發流程變得完全不同。
特別適合的場景:
功能領域 | xUnit | TUnit | 備註 |
---|---|---|---|
基本測試功能 | 支援 | 支援 | 功能對等 |
參數化測試 | 支援 | 支援 | TUnit 語法更簡潔 |
測試隔離 | 支援 | 支援 | 同樣每測試新實例 |
並行執行 | 支援 | 支援 | TUnit 預設並行 |
生命週期 | 支援 | 支援 | 支援建構式/IDisposable |
IDE 支援 | 支援 | 支援 | 需要較新版本 |
生態系統 | 成熟 | 較新 | TUnit 生態較新 |
AOT 支援 | 不支援 | 支援 | TUnit 獨有優勢 |
TUnit 提供比 xUnit 更精細的並行控制,預設情況下所有測試都會並行執行:
// 預設情況下,所有測試都會並行執行
[Test]
public async Task 並行測試1()
{
var result = 1 + 1;
await Assert.That(result).IsEqualTo(2);
}
[Test]
public async Task 並行測試2()
{
var result = 2 + 2;
await Assert.That(result).IsEqualTo(4);
}
// 控制特定測試不要並行執行
[Test]
[NotInParallel("DatabaseTests")]
public async Task 資料庫測試1_不並行執行()
{
// 模擬資料庫操作,避免競爭條件
await Task.Delay(100);
var result = 1 + 1;
await Assert.That(result).IsEqualTo(2);
}
[Test]
[NotInParallel("DatabaseTests")]
public async Task 資料庫測試2_不並行執行()
{
// 這個測試不會與上面的資料庫測試並行執行
await Task.Delay(100);
var result = 2 + 2;
await Assert.That(result).IsEqualTo(4);
}
並行控制的實務應用:
[NotInParallel]
避免競爭條件TUnit 提供多種方式來組織和分類測試:
// 使用 NotInParallel 將相關測試分組
[Test]
[NotInParallel("IntegrationTests")]
public async Task 整合測試_設定資料庫()
{
// 設定測試資料庫
await Assert.That(true).IsTrue();
}
[Test]
[NotInParallel("IntegrationTests")]
public async Task 整合測試_執行業務邏輯()
{
// 執行需要資料庫的業務邏輯
await Assert.That(true).IsTrue();
}
// 一般單元測試可以並行執行
[Test]
public async Task 單元測試_計算功能()
{
var calculator = new Calculator();
var result = calculator.Add(1, 2);
await Assert.That(result).IsEqualTo(3);
}
這種設計讓開發者可以靈活控制測試執行策略,在保證測試穩定性的同時最大化執行效率。
// O 正確:所有斷言都使用 await
[Test]
public async Task 正確的斷言使用()
{
var result = await SomeAsyncMethod();
await Assert.That(result).IsNotNull();
await Assert.That(result.Value).IsGreaterThan(0);
}
// X 錯誤:忘記使用 await
[Test]
public async Task 錯誤的斷言使用()
{
var result = await SomeAsyncMethod();
Assert.That(result).IsNotNull(); // 編譯錯誤
}
延續我們系列文章的命名規範:
// 好的命名:方法名稱_測試情境_預期行為
[Test]
public async Task Add_輸入兩個正整數_應回傳正確的和()
[Test]
[Arguments(-1)]
[Arguments(0)]
public async Task IsPositive_輸入非正數_應回傳False(int number)
TUnit 的 Source Generator 在編譯時期會產生最佳化的程式碼。你可以在專案的 obj
資料夾中看到產生的檔案,這有助於理解框架的運作方式。
錯誤: 安裝了 Microsoft.NET.Test.Sdk
導致測試無法發現
解決方案:
<!-- 不要在 TUnit 專案中安裝這個套件 -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.x.x" />
<!-- TUnit 只需要這些套件 -->
<PackageReference Include="TUnit" Version="0.57.24" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="17.12.4" />
症狀: 測試在 IDE 中無法顯示或執行
解決方案:
症狀: 編譯錯誤或斷言無法正常執行
解決方案:
// 記住所有斷言都需要 await
await Assert.That(result).IsEqualTo(expected);
// 測試方法必須是 async Task
[Test]
public async Task MyTest() { }
作為一個經歷過多次技術轉換的老派工程師,我會這樣評估 TUnit:
我會這樣做:
決定因素的權重:
在今天的 TUnit 探索後,請思考:
老派工程師的務實建議:
TUnit 確實展現了令人印象深刻的技術創新,特別是在效能方面。但作為一個經歷過多次技術轉換的工程師,我建議:
[Test]
屬性、流暢式斷言、非同步設計明天我們將探討測試金字塔實戰:從單元到整合測試的完整策略,包括:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十八天。明天會介紹 Day 29 – TUnit 進階應用:資料驅動測試與依賴注入深度實戰。