iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Software Development

重啟挑戰:老派軟體工程師的測試修練系列 第 28

Day 28 - TUnit 入門 - 下世代 .NET 測試框架探索

  • 分享至 

  • xImage
  •  

前言:新世代測試框架的崛起

在前面的文章中,我們深入探討了 xUnit 的各種功能和最佳實踐。從 Day 02 的框架基礎,到後續章節的進階技巧,xUnit 一直是我們可靠的測試夥伴。

但軟體開發的世界從不停歇。隨著 .NET 生態系統的持續演進,特別是 Source Generator、AOT 編譯等現代技術的成熟,測試框架也開始迎來新的變革。

今天要介紹的 TUnit,就是在這樣的背景下誕生的新世代測試框架。它不只是另一個「功能類似」的選擇,而是從根本上重新思考測試框架應該如何運作的創新嘗試。

為什麼要關注 TUnit?

身為一個從 MSTest 轉向 xUnit 的老派工程師,我對於「換測試框架」這件事其實相當謹慎。畢竟,測試程式碼的價值在於穩定性和可靠性,不是嗎?

但 TUnit 的出現確實讓我重新思考這個問題:

  • 效能突破:透過 Source Generator,在編譯時期就完成測試發現,大幅提升執行速度
  • 現代化設計:原生支援 AOT 編譯(Ahead-of-Time,預先編譯),讓測試啟動速度提升數十倍
  • 直覺語法:流暢式斷言讓測試程式碼更容易閱讀和撰寫

重點是,TUnit 採用了現代軟體開發的兩個核心方向:編譯時期最佳化效能優先


今日目標

  • 認識 TUnit 測試框架的核心特色與設計理念
  • 掌握 TUnit 的基本語法和測試撰寫方式
  • 了解 Source Generator 在測試框架中的實際應用
  • 建立第一個完整的 TUnit 測試專案
  • 比較 TUnit 與 xUnit 的語法差異和適用場景
  • 評估 TUnit 在實際專案中的可行性

TUnit 框架特色深度介紹

Source Generator 驅動的測試發現

傳統測試框架(包括 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();
    }
}

這樣的設計帶來幾個顯著優勢:

  1. 避免反射成本:所有測試發現在編譯時期完成
  2. AOT 相容:完全支援 Native AOT 編譯
  3. 更快的啟動時間:特別是在大型測試專案中

什麼是 AOT?為什麼重要?

AOT(Ahead-of-Time)編譯是相對於 JIT(Just-in-Time)編譯的概念。讓我們用老派工程師的角度來理解這個差異:

傳統 .NET 的 JIT 編譯流程:

C# 原始碼 → IL 中間碼 → 執行時期 JIT 編譯 → 機器碼 → 執行

AOT 編譯流程:

C# 原始碼 → 編譯時期直接產生 → 機器碼 → 直接執行

AOT 編譯的關鍵優勢:

  1. 超快啟動時間:不需要等待 JIT 編譯,程式立即執行
  2. 更小的記憶體占用:不需要載入 .NET Runtime 和 JIT 編譯器
  3. 可預測的效能:沒有 JIT 編譯的延遲和記憶體壓力
  4. 更適合容器化部署:映像檔更小,啟動更快

實務應用場景:

  • 微服務:快速啟動對 Kubernetes 等容器環境至關重要
  • Serverless Functions:冷啟動時間直接影響使用者體驗
  • CLI 工具:使用者期望工具立即回應
  • 測試執行:在 CI/CD 中,測試啟動時間累積起來很可觀

為什麼傳統測試框架無法支援 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 支援

理論說完了,來看實際操作。要讓 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 編譯的注意事項:

  • 編譯時間較長:第一次 AOT 編譯會比 JIT 慢,但執行快很多
  • 檔案大小:AOT 編譯的執行檔會比較大,因為包含了所有必要的運行時元件
  • 平台相依:AOT 編譯的執行檔只能在目標平台執行
  • 偵錯差異:AOT 版本的偵錯體驗與 JIT 版本略有不同

實際效能差異範例:

假設有一個包含 100 個測試的專案:

傳統 JIT 編譯測試啟動時間:約 1-2 秒
TUnit AOT 編譯測試啟動時間:約 50-100 毫秒

在 CI/CD 環境中,這個差異會隨著測試數量增加而更加明顯,大型專案可能有 10-30 倍的啟動時間改善。

Microsoft.Testing.Platform 的採用

TUnit 建構在微軟最新的 Microsoft.Testing.Platform 之上,而非傳統的 VSTest 平台。這個轉換帶來了:

現代化的測試執行體驗:

  • 更輕量的測試執行器
  • 更好的並行控制機制
  • 原生支援最新的 IDE 整合

重要注意事項:
與傳統測試框架不同,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>();

這種設計的優點包括:

  1. 語意清晰:斷言讀起來像英文句子,容易理解
  2. 參數順序直覺:先指定要檢查的值,再指定期望的結果
  3. IDE 智慧提示:流暢介面讓 IDE 能提供更好的自動完成功能
  4. 錯誤訊息清楚:失敗時的錯誤訊息更容易理解

注意所有 TUnit 的斷言都是非同步的,需要使用 await 關鍵字。


TUnit 基本語法與功能實戰

非同步測試方法的必要性

TUnit 與傳統測試框架最顯著的差異之一,就是所有測試方法都必須是非同步的。這不是設計上的選擇,而是技術上的必然:

為什麼 TUnit 測試必須是 async?

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 等待斷言完成
}

async/await 模式的優勢

這種設計帶來幾個重要好處:

  1. 真正的非同步並行:每個斷言都可以並行執行,不會阻塞執行緒
  2. 統一的錯誤處理:所有例外都透過 Task 正確傳播
  3. 更好的效能監控:可以追蹤每個斷言的執行時間
[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);
    
    // 所有斷言都會等待完成後才結束測試
}

測試屬性與標記

[Test] vs [Fact] 的概念差異

在 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.Assertions 斷言系統

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();
}

And 條件組合

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");
}

Or 條件組合

除了 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 條件的實用場景:

  • 檢查多個有效的狀態或值
  • 驗證不同格式的輸入都能被接受
  • 測試容錯機制或備用邏輯

時間相依性測試與 TimeProvider

在測試涉及時間的程式碼時,我們需要能夠控制時間來確保測試的可預測性。TUnit 與 .NET 的 TimeProvider 完美整合,提供強大的時間測試能力。

為什麼需要 TimeProvider?

任何涉及時間的程式碼都是測試的噩夢。直接使用 DateTime.NowDateTimeOffset.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; // 這怎麼測試?只能等到下班時間?
    }
}

實務問題:

  • 測試在不同時間點執行會有不同結果
  • 無法測試特定時間點的業務邏輯
  • 跨時區的程式碼更是災難
  • CI/CD 環境中的時間不一致性

使用 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;
    }
}

FakeTimeProvider 的威力

在測試中,我們可以使用 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 類似,但提供更多控制選項。

建構式與 Dispose 模式

最基本的生命週期管理,與 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:清理資源");
        // 進行必要的清理工作
    }
}

Before 和 After 屬性

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 的生命週期方法執行順序如下:

  1. Before(Class) (類別層級,只執行一次)
  2. 建構式 (每個測試)
  3. Before(Test) (每個測試)
  4. 測試方法 (實際測試)
  5. After(Test) (每個測試)
  6. Dispose (每個測試,如果實作 IDisposable)
  7. After(Class) (類別層級,只執行一次)
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 測試專案

專案建立與套件安裝

來實際建立一個 TUnit 測試專案。這裡我們會建立標準的解決方案結構,就像真實專案一樣:

1-1. 建立解決方案和專案結構

# 建立專案目錄
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 mstestdotnet new xunit 模板,而是從空白的 console 專案開始設定。

1-2. 使用 TUnit 官方 Template(更簡便的方式)

除了手動使用 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 的優勢:

  • 自動設定正確的專案結構和相依性
  • 包含基本的 TUnit 套件參考
  • 預設啟用 Microsoft.Testing.Platform
  • 包含基本的 GlobalUsings 設定

Template vs 手動設定的選擇:

  • 使用 Template:適合快速開始新的 TUnit 專案
  • 手動設定:適合理解底層架構或將現有專案轉換為 TUnit

由於我們要深入了解 TUnit 的架構,接下來仍使用手動設定的方式進行說明。

2. 設定測試專案

修改 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
  • TUnit 使用新的 Microsoft.Testing.Platform,與舊的 VSTest 不相容

3. 設定 GlobalUsings

在測試專案中建立 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 可以看到測試的詳細執行過程
  • TUnit 的並行執行讓測試跑得很快,通常不需要額外的過濾
  • 覆蓋率報告會產生在 TestResults 資料夾中

TUnit 特有的執行輸出

TUnit 有自己獨特的執行畫面,第一次看到可能會覺得很獨特:

████████╗██╗   ██╗███╗   ██╗██╗████████╗
╚══██╔══╝██║   ██║████╗  ██║██║╚══██╔══╝
   ██║   ██║   ██║██╔██╗ ██║██║   ██║   
   ██║   ██║   ██║██║╚██╗██║██║   ██║   
   ██║   ╚██████╔╝██║ ╚████║██║   ██║   
   ╚═╝    ╚═════╝ ╚═╝  ╚═══╝╚═╝   ╚═╝   

TUnit v0.57.24 | 64-bit | Microsoft Windows | .NET 9.0.0

測試摘要: 總計: 32, 失敗: 0, 成功: 32, 已跳過: 0, 持續時間: 1.1 秒

注意執行時間通常比 xUnit 要快不少,特別是在大型測試專案中。

IDE 整合設定

Visual Studio Code:

  1. 安裝 C# Dev Kit 擴充套件
  2. 在設定中啟用 "Use Testing Platform Protocol"

Visual Studio 2022:

  1. 確保使用 17.13+ 版本
  2. 在 Tools → Options → Environment → Preview Features 中啟用 "Use testing platform server mode"

JetBrains Rider:

  1. 在 Settings → Build, Execution, Deployment → Unit Testing → VSTest 中啟用 "Testing Platform support"

與 xUnit 的實務比較

語法對照表

以下是常用功能的語法對照:

功能 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>();
    }
}

主要變更:

  1. [Theory][Test]:TUnit 統一使用 [Test] 屬性
  2. [InlineData][Arguments]:參數化測試的語法更簡潔
  3. [Fact][Test]:所有測試都使用相同的屬性
  4. 所有測試方法改為 async Task:這是 TUnit 的強制要求,因為所有斷言都是非同步的
  5. 所有斷言前加上 await:TUnit 的斷言系統完全基於非同步設計
  6. 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 編譯帶來的效能提升是驚人的:

為什麼 AOT 版本快這麼多?

  1. 零啟動開銷

    • 傳統 .NET:載入 Runtime → JIT 編譯器 → 反射掃描 → JIT 編譯測試程式碼 → 執行
    • AOT 版本:直接執行機器碼
  2. 記憶體效率

    • 傳統版本需要 50-100MB 的 .NET Runtime
    • AOT 版本只需要幾 MB 的原生執行檔
  3. 預先最佳化

    • JIT 編譯器需要在執行時期做最佳化決策
    • AOT 編譯器有完整的程式碼視圖,可以做更深度的最佳化

實際專案中的意義:

假設一個中型專案有 500 個測試:
- xUnit:        1,400ms × 500 = 11.7 分鐘
- TUnit:        1,000ms × 500 = 8.3 分鐘
- TUnit AOT:    60ms × 500 = 30 秒

在 CI/CD 管道中,這個差異會讓開發流程變得完全不同。

特別適合的場景:

  • 容器化測試:在 Docker 容器中執行測試時,啟動時間至關重要
  • 微服務測試:每個服務都有自己的測試套件,累積效果驚人
  • 開發者本機測試:快速的回饋循環提升開發體驗

功能完整性評估

功能領域 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);
}

這種設計讓開發者可以靈活控制測試執行策略,在保證測試穩定性的同時最大化執行效率。


最佳實踐與常見陷阱

TUnit 特有的最佳實踐

1. 正確使用非同步斷言

// 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(); // 編譯錯誤
}

2. 測試命名規範

延續我們系列文章的命名規範:

// 好的命名:方法名稱_測試情境_預期行為
[Test]
public async Task Add_輸入兩個正整數_應回傳正確的和()

[Test]
[Arguments(-1)]
[Arguments(0)]
public async Task IsPositive_輸入非正數_應回傳False(int number)

3. 善用 Source Generator 特性

TUnit 的 Source Generator 在編譯時期會產生最佳化的程式碼。你可以在專案的 obj 資料夾中看到產生的檔案,這有助於理解框架的運作方式。

常見問題與解決方案

問題 1:套件相容性

錯誤: 安裝了 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" />

問題 2:IDE 整合問題

症狀: 測試在 IDE 中無法顯示或執行

解決方案:

  1. 確認 IDE 版本支援 Microsoft.Testing.Platform
  2. 啟用相關的預覽功能或實驗性功能
  3. 重新載入專案或重啟 IDE

問題 3:非同步斷言遺忘

症狀: 編譯錯誤或斷言無法正常執行

解決方案:

// 記住所有斷言都需要 await
await Assert.That(result).IsEqualTo(expected);

// 測試方法必須是 async Task
[Test]
public async Task MyTest() { }

客觀評估:TUnit 適合你嗎?

作為一個經歷過多次技術轉換的老派工程師,我會這樣評估 TUnit:

適合使用 TUnit 的場景

  1. 全新專案:沒有歷史包袱,可以從零開始
  2. 效能要求高:大型測試套件(1000+ 測試),執行時間是瓶頸
  3. 技術棧先進:已經使用 .NET 8+,計劃採用 AOT 編譯
  4. 團隊願意學習:有時間學習新語法,不怕踩坑
  5. CI/CD 重度使用:測試執行時間直接影響部署頻率

暫時不建議的場景

  1. legacy 專案:已有大量 xUnit 測試,遷移成本太高
  2. 保守團隊:需要穩定性勝過創新性的環境
  3. 複雜測試生態:大量使用 xUnit 特定套件(如 xUnit.DependencyInjection)
  4. 舊版 .NET:還在 .NET 6/7,升級 framework 不在近期計劃內
  5. 緊急專案:時程緊迫,沒時間學習新工具

實務決策建議

我會這樣做:

  1. 先做概念驗證:選一個小模組試驗,不要全面導入
  2. 測量真實效益:跑 benchmark,看看效能提升是否顯著
  3. 評估學習成本:團隊需要多久適應新語法?
  4. 考慮生態成熟度:TUnit 還很年輕,相關工具和文件還在發展中
  5. 評估 AOT 需求:如果專案需要容器化部署或極致效能,AOT 支援是重要考量
  6. 制定混合策略:新功能用 TUnit,既有測試保持 xUnit

決定因素的權重:

  • 效能提升:30%(不是所有專案都需要極致效能)
  • 學習成本:25%(團隊適應新工具的時間成本)
  • 生態完整性:20%(第三方套件、工具支援)
  • 技術債務:15%(既有測試的遷移成本)
  • 團隊意願:10%(工程師對新技術的接受度)

今日思考題

在今天的 TUnit 探索後,請思考:

  1. 效能與穩定性的權衡:TUnit 的效能優勢是否足以彌補其生態系統的不成熟?
  2. 語法偏好:你認為 TUnit 的流暢式斷言比 xUnit 的傳統斷言更好嗎?為什麼?
  3. 遷移時機:在什麼情況下,你會考慮將現有專案從 xUnit 遷移到 TUnit?

老派工程師的務實建議
TUnit 確實展現了令人印象深刻的技術創新,特別是在效能方面。但作為一個經歷過多次技術轉換的工程師,我建議:

  • 新專案可以大膽嘗試:特別是對效能有要求的場景,TUnit 的 Source Generator 技術能帶來實質的效能提升
  • 現有專案謹慎評估:遷移成本往往比預期高,需要考慮團隊學習曲線、生態系統成熟度等因素
  • 保持學習心態:即使暫時不用,了解新技術的設計思路也很有價值,特別是 Source Generator 的應用方式
  • 關注社群發展:TUnit 還很年輕,建議關注其社群發展和生態系統成熟度

今日重點回顧

  1. TUnit 核心特色:Source Generator 驅動、AOT 支援、現代化設計
  2. 基本語法:統一的 [Test] 屬性、流暢式斷言、非同步設計
  3. 專案建立:避免 VSTest SDK、正確的套件配置、IDE 整合設定
  4. 與 xUnit 比較:語法差異、效能優勢、功能對照
  5. 最佳實踐:非同步斷言、命名規範、常見陷阱避免
  6. 適用場景:新專案、效能要求、現代技術棧

明日預告

明天我們將探討測試金字塔實戰:從單元到整合測試的完整策略,包括:

  • 重新檢視測試金字塔的現代意義
  • 單元測試、整合測試、端對端測試的界線劃分
  • 在微服務架構下的測試策略調整
  • 測試環境的建置與管理實務
  • 如何在真實專案中平衡不同層級的測試投入

參考資源

TUnit 官方資源

技術文章

Microsoft 官方文件

Youtube 影片

範例程式碼


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十八天。明天會介紹 Day 29 – TUnit 進階應用:資料驅動測試與依賴注入深度實戰。


上一篇
Day 27 – GitHub Copilot 測試實戰:AI 輔助測試開發指南
下一篇
Day 29 – TUnit 進階應用:資料驅動測試與依賴注入深度實戰
系列文
重啟挑戰:老派軟體工程師的測試修練30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言