iT邦幫忙

2025 iThome 鐵人賽

DAY 30
0
Software Development

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

Day 30 - TUnit 進階應用:執行控制與測試品質和 ASP.NET Core 整合測試實戰

  • 分享至 

  • xImage
  •  

前言

在 Day 29 中,我們深入探討了 TUnit 的資料驅動測試和依賴注入功能。從 MethodDataSource 的靈活應用,到 ClassDataSource 的重用策略,再到 Matrix Tests 的組合威力,我們建立了完整的測試資料基礎。同時,透過 TUnit 原生的依賴注入機制,我們學會了如何在測試中有效處理複雜的服務依賴關係。

但是,在正式的真實專案中,測試不僅僅是驗證單一功能的正確性。我們還需要面對更複雜的挑戰:

進階測試挑戰:

  • 測試品質管控:如何處理偶發性失敗的測試?如何設定合理的執行時限?
  • 系統整體驗證:如何測試完整的 Web API 流程,確保各層級間的協作無誤?
  • 效能與穩定性:如何在測試中驗證系統的效能表現和負載處理能力?
  • 正式環境準備:如何確保測試環境與正式環境的行為一致性?

今天我們要探討 TUnit 的執行控制機制和 ASP.NET Core 整合測試,這些功能讓我們能夠建立更可靠、更接近真實使用場景的測試套件。從智慧重試策略到完整的 Web 應用程式測試,我們將學習如何在實際的工作專案中確保軟體品質。

本篇內容

今天的內容有:

  • 執行控制與測試品質管理:Retry 機制、Timeout 控制、DisplayName 最佳實踐
  • ASP.NET Core 整合測試實作:從 WebApplicationFactory 到效能測試的完整方案
  • 複雜測試基礎設施編排:使用 TUnit + Testcontainers 建立多服務整合測試環境
  • 實戰疑難排解技巧:解決 TUnit 常見問題,提升開發效率
  • 專業測試策略:整合測試在真實專案中的應用模式

TUnit 的進階功能正是為了解決這些挑戰而設計的。今天我們要深入探討執行控制和整合測試等實務技巧,讓你能夠在實際的工作專案中有效運用這個框架。


執行控制與測試品質

在實際的工作專案中,測試的執行控制和品質管理相當重要。TUnit 提供了多種機制來幫助我們處理不穩定的測試、效能要求,以及提升測試報告的可讀性。

Retry 機制:智慧重試策略

在真實世界中,某些測試可能會因為外部因素(如網路延遲、資源競爭)而偶爾失敗。對於這類「不穩定的測試」,盲目地重新執行整個測試套件既浪費時間又不能解決根本問題。TUnit 的 Retry 機制提供了精確的重試控制。

基本 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 的適用場景與注意事項

適合使用 Retry 的情況:

  1. 外部服務呼叫:API 請求、資料庫連線可能因為網路問題暫時失敗
  2. 檔案系統操作:在 CI/CD 環境中,檔案鎖定可能導致暫時性失敗
  3. 並行測試競爭:多個測試同時存取共享資源時的競爭條件

不適合使用 Retry 的情況:

  1. 邏輯錯誤:程式碼本身的錯誤重試多少次都不會成功
  2. 預期的例外:測試本身就是要驗證例外情況
  3. 效能測試:重試會影響效能測量的準確性

實務最佳實踐

[Test]
[Retry(3)]
[Property("Category", "ExternalDependency")]
public async Task CallExternalApi_網路問題時重試_最終應成功()
{
    using var httpClient = new HttpClient();
    httpClient.Timeout = TimeSpan.FromSeconds(10);

    try
    {
        // 實際的外部 API 呼叫
        var response = await httpClient.GetAsync("https://jsonplaceholder.typicode.com/posts/1");
        
        await Assert.That(response.IsSuccessStatusCode).IsTrue();
        
        var content = await response.Content.ReadAsStringAsync();
        await Assert.That(content).IsNotNull();
    }
    catch (TaskCanceledException)
    {
        // 超時也算是暫時性錯誤,可以重試
        throw new HttpRequestException("請求超時,將重試");
    }
}

// 反例:不應該用 Retry 的情況
[Test]
// 不要對預期會失敗的測試使用 Retry
public async Task Divide_被零除_應拋出例外()
{
    await Assert.That(() => 10 / 0).Throws<DivideByZeroException>();
}

Timeout 控制與長時間測試管理

效能是現代應用程式的重要指標。TUnit 的 Timeout 功能讓我們能夠確保測試在合理時間內完成,避免無限期等待或效能回歸。

基本 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(30000)] // 30 秒超時,適合較複雜的操作
[Property("Category", "Integration")]
public async Task DatabaseMigration_大量資料處理_應在合理時間內完成()
{
    // 模擬資料庫遷移或大量資料處理
    var tasks = new List<Task>();
    
    for (int i = 0; i < 100; i++)
    {
        tasks.Add(ProcessDataBatch(i));
    }
    
    await Task.WhenAll(tasks);
    await Assert.That(tasks.All(t => t.IsCompletedSuccessfully)).IsTrue();
}

private static async Task ProcessDataBatch(int batchNumber)
{
    // 模擬批次處理
    await Task.Delay(50); // 每批次 50ms
}

效能基準測試

Timeout 結合效能測量,可以建立效能基準:

[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);
    
    // 效能驗證:99% 的查詢應在 500ms 內完成
    await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(500);
}

private static async Task<IEnumerable<string>> PerformSearch(string query)
{
    // 模擬搜尋邏輯
    await Task.Delay(100);
    return new[] { "result1", "result2", "result3" };
}

Display Names:自訂測試名稱最佳實踐

清楚的測試名稱對於測試報告的可讀性很重要。TUnit 的 DisplayName 功能讓我們能夠提供更友善的測試名稱,特別是在參數化測試中。

基本 DisplayName 使用

[Test]
[DisplayName("自訂測試名稱:驗證使用者註冊流程")]
public async Task UserRegistration_CustomDisplayName_測試名稱更易讀()
{
    // 使用自訂顯示名稱讓測試報告更容易理解
    await Assert.That("user@example.com").Contains("@");
}

參數化測試的動態顯示名稱

DisplayName 的主要優勢在於參數化測試,它可以自動替換參數值:

[Test]
[Arguments("valid@email.com", true)]
[Arguments("invalid-email", false)]
[Arguments("", false)]
[Arguments("test@domain.co.uk", true)]
[Arguments("user.name+tag@example.com", true)]
[DisplayName("電子郵件驗證:{0} 應為 {1}")]
public async Task EmailValidation_參數化顯示名稱(string email, bool expectedValid)
{
    // 顯示名稱會自動替換參數
    // 產生的名稱如:「電子郵件驗證:valid@email.com 應為 True」
    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)]
[Arguments(CustomerLevel.鑽石會員, 1000, 200)]
[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 整合測試實戰

整合測試是驗證應用程式各個元件協同工作的重要手段。與單元測試專注於個別元件不同,整合測試要確保整個系統能夠正確運作。在 Web 應用程式開發中,這意味著測試從 HTTP 請求到資料庫存取的完整流程。

TUnit 與 ASP.NET Core 的整合測試能力讓我們能夠測試完整的 Web 應用程式流程。這對於確保系統整體行為的正確性非常重要,特別是在微服務架構中,不同服務間的協作更需要透過整合測試來保證品質。

WebApplicationFactory 與 TUnit 的整合

WebApplicationFactory 是 ASP.NET Core 提供的測試基礎設施,它能夠在記憶體中啟動完整的 Web 應用程式進行測試。

基本整合測試設定

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_應回傳正確格式的資料()
    {
        // Act
        var response = await _client.GetAsync("/weatherforecast");

        // Assert
        await Assert.That(response.IsSuccessStatusCode).IsTrue();

        var content = await response.Content.ReadAsStringAsync();
        await Assert.That(content).IsNotNull();
        await Assert.That(content.Length).IsGreaterThan(0);
    }

    public void Dispose()
    {
        _client?.Dispose();
        _factory?.Dispose();
    }
}

測試 HTTP 回應的完整性

整合測試不僅要驗證功能,還要確保 HTTP 層面的正確性:

[Test]
[Property("Category", "Integration")]
public async Task WeatherForecast_ResponseHeaders_應包含ContentType標頭()
{
    // Act
    var response = await _client.GetAsync("/weatherforecast");

    // Assert
    await Assert.That(response.IsSuccessStatusCode).IsTrue();
    
    // 檢查實際會存在的 Content-Type 標頭
    var contentType = response.Content.Headers.ContentType?.MediaType;
    await Assert.That(contentType).IsEqualTo("application/json");
}

端點可用性與冒煙測試

在進行複雜的效能測試之前,我們需要先確保基本功能正常運作。冒煙測試是最基礎但也是最重要的整合測試類型:

[Test]
[Property("Category", "Smoke")]
public async Task WeatherForecast_端點可用性_應能正常回應()
{
    // 基本的冒煙測試:確保端點可用

    // Act
    var response = await _client.GetAsync("/weatherforecast");

    // Assert
    await Assert.That(response.IsSuccessStatusCode).IsTrue();

    var content = await response.Content.ReadAsStringAsync();
    await Assert.That(content).IsNotNull();
    await Assert.That(content.Length).IsGreaterThan(10); // 確保有實際內容
}

冒煙測試的價值:

  1. 快速回饋:在 CI/CD 流程中提供最快的基本功能驗證
  2. 早期發現:能夠在第一時間發現部署或設定問題
  3. 成本效益:執行快速,但能夠捕獲大部分基礎問題
  4. 信心建立:為後續的詳細測試建立基礎信心

效能測試與負載測試的實作

整合測試也是進行效能驗證的好時機:

回應時間驗證

[Test]
[Property("Category", "Performance")]
[Timeout(10000)] // 10 秒超時保護
public async Task WeatherForecast_ResponseTime_應在合理範圍內()
{
    // Arrange
    var stopwatch = Stopwatch.StartNew();

    // Act
    var response = await _client.GetAsync("/weatherforecast");
    stopwatch.Stop();

    // Assert
    await Assert.That(response.IsSuccessStatusCode).IsTrue();
    await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(5000); // 5秒內回應
}

並行負載測試

[Test]
[Property("Category", "Load")]
[Timeout(30000)]
public async Task WeatherForecast_並行請求_應能正確處理()
{
    // Arrange
    const int concurrentRequests = 50;
    var tasks = new List<Task<HttpResponseMessage>>();

    // Act
    for (int i = 0; i < concurrentRequests; i++)
    {
        tasks.Add(_client.GetAsync("/weatherforecast"));
    }

    var responses = await Task.WhenAll(tasks);

    // Assert
    await Assert.That(responses.Length).IsEqualTo(concurrentRequests);
    await Assert.That(responses.All(r => r.IsSuccessStatusCode)).IsTrue();

    // 清理
    foreach (var response in responses)
    {
        response.Dispose();
    }
}

健康檢查與監控測試

現代應用程式通常需要健康檢查端點供監控系統使用:

[Test]
[Property("Category", "Health")]
public async Task HealthCheck_應回傳健康狀態()
{
    // 測試應用程式的健康狀態
    // 這對於 Kubernetes 部署和監控很重要

    try
    {
        var response = await _client.GetAsync("/health");
        // 如果有 health endpoint 就測試,沒有就測試基本端點
        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }
    catch (HttpRequestException)
    {
        // 如果沒有 /health 端點,測試根路徑
        var response = await _client.GetAsync("/");
        await Assert.That((int)response.StatusCode).IsLessThan(500);
    }
}

複雜業務流程的端到端測試

整合測試的真正價值在於測試完整的業務流程:

public class OrderApiIntegrationTests : IDisposable
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public OrderApiIntegrationTests()
    {
        _factory = new WebApplicationFactory<Program>();
        _client = _factory.CreateClient();
    }

    [Test]
    [Property("Category", "E2E")]
    [DisplayName("完整訂單流程:建立 → 查詢 → 更新狀態")]
    public async Task CreateOrder_完整流程_應成功建立訂單()
    {
        // 這個測試展示完整的訂單建立流程
        // 由於範例 API 可能沒有實際的訂單端點,我們測試基本的 API 可用性

        // Act
        var response = await _client.GetAsync("/");

        // Assert - 確保 API 可以正常啟動和回應
        // 在真實專案中,這裡會測試實際的業務邏輯端點
        await Assert.That((int)response.StatusCode).IsLessThan(500); // 不是伺服器錯誤
    }

    public void Dispose()
    {
        _client?.Dispose();
        _factory?.Dispose();
    }
}

複雜測試基礎設施編排

在正式專案中,整合測試通常需要多個外部依賴服務的協調工作。TUnit 結合 Testcontainers.NET 提供了完整的基礎設施編排能力,讓我們能夠建立接近正式環境的測試場景。

使用 [Before(Assembly)] 和 [After(Assembly)] 管理容器

在正式專案中,最佳實踐是使用 [Before(Assembly)][After(Assembly)] 來管理容器生命週期,確保所有測試共享相同的基礎設施:

/// <summary>
/// 全域測試基礎設施設置
/// 專門處理 Assembly level 的容器管理
/// </summary>
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();
        Console.WriteLine($"測試網路已建立: {Network.Name}");

        // 建立 PostgreSQL 容器
        PostgreSqlContainer = new PostgreSqlBuilder()
            .WithDatabase("test_db")
            .WithUsername("test_user")
            .WithPassword("test_password")
            .WithNetwork(Network)
            .WithCleanUp(true)
            .Build();

        await PostgreSqlContainer.StartAsync();
        Console.WriteLine($"PostgreSQL 容器已啟動: {PostgreSqlContainer.GetConnectionString()}");

        // 建立 Redis 容器
        RedisContainer = new RedisBuilder()
            .WithNetwork(Network)
            .WithCleanUp(true)
            .Build();

        await RedisContainer.StartAsync();
        Console.WriteLine($"Redis 容器已啟動: {RedisContainer.GetConnectionString()}");

        // 建立 Kafka 容器
        KafkaContainer = new KafkaBuilder()
            .WithNetwork(Network)
            .WithCleanUp(true)
            .Build();

        await KafkaContainer.StartAsync();
        Console.WriteLine($"Kafka 容器已啟動: {KafkaContainer.GetBootstrapAddress()}");

        Console.WriteLine("=== 全域測試基礎設施設置完成 ===");
    }

    [After(Assembly)]
    public static async Task TeardownGlobalInfrastructure()
    {
        Console.WriteLine("=== 開始清理全域測試基礎設施 ===");

        if (KafkaContainer != null)
        {
            await KafkaContainer.DisposeAsync();
            Console.WriteLine("Kafka 容器已停止");
        }

        if (RedisContainer != null)
        {
            await RedisContainer.DisposeAsync();
            Console.WriteLine("Redis 容器已停止");
        }

        if (PostgreSqlContainer != null)
        {
            await PostgreSqlContainer.DisposeAsync();
            Console.WriteLine("PostgreSQL 容器已停止");
        }

        if (Network != null)
        {
            await Network.DeleteAsync();
            Console.WriteLine("測試網路已刪除");
        }

        Console.WriteLine("=== 全域測試基礎設施清理完成 ===");
    }
}

多服務容器編排範例

以下範例展示如何使用全域容器基礎設施進行基礎設施驗證測試。這些測試的目的是確保測試環境的容器服務正常運作,為後續的業務邏輯測試奠定基礎:

/// <summary>
/// 複雜測試基礎設施編排範例
/// 展示 TUnit 結合 Testcontainers.NET 的強大能力
/// 注意:這些是基礎設施驗證測試,用於確保測試環境正常運作
/// 實際的業務邏輯測試會建立在這些基礎設施之上
/// </summary>
public class ComplexInfrastructureTests
{
    [Test]
    [Property("Category", "Integration")]
    [Property("Infrastructure", "Complex")]
    [DisplayName("多服務協作:PostgreSQL + Redis + Kafka 完整測試")]
    public async Task CompleteWorkflow_多服務協作_應正確執行()
    {
        // Arrange & Act
        // 使用全域設置的容器
        var dbConnectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();
        var redisConnectionString = GlobalTestInfrastructureSetup.RedisContainer!.GetConnectionString();
        var kafkaBootstrapServers = GlobalTestInfrastructureSetup.KafkaContainer!.GetBootstrapAddress();

        // Assert
        await Assert.That(dbConnectionString).IsNotNull();
        await Assert.That(dbConnectionString).Contains("test_db");
        await Assert.That(dbConnectionString).Contains("test_user");

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

        // 模擬完整的業務流程
        Console.WriteLine("=== 多服務協作測試 ===");
        Console.WriteLine($"PostgreSQL: {dbConnectionString}");
        Console.WriteLine($"Redis: {redisConnectionString}");
        Console.WriteLine($"Kafka: {kafkaBootstrapServers}");
        Console.WriteLine("=====================");
    }

    [Test]
    [Property("Category", "Database")]
    [DisplayName("PostgreSQL 資料庫連線驗證")]
    public async Task PostgreSqlDatabase_連線驗證_應成功建立連線()
    {
        // Arrange
        var connectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();

        // Act & Assert
        await Assert.That(connectionString).Contains("test_db");
        await Assert.That(connectionString).Contains("test_user");
        await Assert.That(connectionString).Contains("test_password");

        Console.WriteLine($"Database connection verified: {connectionString}");
    }

    [Test]
    [Property("Category", "Cache")]
    [DisplayName("Redis 快取服務驗證")]
    public async Task RedisCache_快取服務_應正確啟動()
    {
        // Arrange
        var connectionString = GlobalTestInfrastructureSetup.RedisContainer!.GetConnectionString();

        // Act & Assert
        await Assert.That(connectionString).IsNotNull();
        await Assert.That(connectionString).Contains("127.0.0.1");

        Console.WriteLine($"Redis connection verified: {connectionString}");
    }

    [Test]
    [Property("Category", "MessageQueue")]
    [DisplayName("Kafka 訊息佇列服務驗證")]
    public async Task KafkaMessageQueue_訊息佇列_應正確啟動()
    {
        // Arrange
        var bootstrapServers = GlobalTestInfrastructureSetup.KafkaContainer!.GetBootstrapAddress();

        // Act & Assert
        await Assert.That(bootstrapServers).IsNotNull();
        await Assert.That(bootstrapServers).Contains("127.0.0.1");

        Console.WriteLine($"Kafka connection verified: {bootstrapServers}");
    }
}

進階依賴管理模式

在 Assembly 級別管理的基礎上,建立進階依賴測試。以下範例展示的是測試基礎設施健康檢查,確保各服務間的網路連通性和依賴關係正常運作:

/// <summary>
/// 進階依賴管理模式範例
/// 展示 TUnit 的複雜依賴鏈管理能力
/// 注意:這些是基礎設施健康檢查測試,確保測試環境的服務依賴正常
/// 真正的業務邏輯測試會使用這些已驗證的基礎設施進行實際功能測試
/// </summary>
public class AdvancedDependencyTests
{
    [Test]
    [Property("Category", "Network")]
    [DisplayName("網路基礎設施驗證")]
    public async Task NetworkInfrastructure_網路設定_應正確建立()
    {
        // Arrange & Act
        var networkName = GlobalTestInfrastructureSetup.Network!.Name;

        // Assert
        await Assert.That(networkName).IsEqualTo("global-test-network");

        Console.WriteLine($"Test network verified: {networkName}");
    }

    [Test]
    [Property("Category", "Database")]
    [DisplayName("網路化資料庫服務驗證")]
    public async Task NetworkedDatabase_資料庫網路_應正確設定()
    {
        // Arrange
        var connectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();

        // Act & Assert
        await Assert.That(connectionString).Contains("test_db");
        await Assert.That(connectionString).Contains("test_user");

        // 驗證資料庫容器在指定網路中
        await Assert.That(GlobalTestInfrastructureSetup.PostgreSqlContainer.State).IsEqualTo(TestcontainersStates.Running);

        Console.WriteLine($"Networked database verified: {connectionString}");
    }

    [Test]
    [Property("Category", "Integration")]
    [DisplayName("跨容器網路通訊測試")]
    public async Task CrossContainerCommunication_容器間通訊_應正常運作()
    {
        // Arrange
        var dbConnectionString = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();
        var networkName = GlobalTestInfrastructureSetup.Network!.Name;

        // Act & Assert
        await Assert.That(dbConnectionString).IsNotNull();
        await Assert.That(networkName).IsEqualTo("global-test-network");

        Console.WriteLine("Cross-container communication test ready");
        Console.WriteLine($"Network: {networkName}");
        Console.WriteLine($"Database: {dbConnectionString}");
    }
}
基礎設施驗證與業務邏輯測試的分層設計

以上兩個範例(ComplexInfrastructureTestsAdvancedDependencyTests)展示的是基礎設施驗證測試,目的是確保測試環境的容器服務正常運作。在實際專案中,你會在這些基礎設施之上建立真正的業務邏輯測試:

// 基礎設施驗證測試(如上方範例)
// ↓ 確保測試環境正常運作後
// ↓ 建立實際的業務邏輯測試

/// <summary>
/// 使用已驗證的基礎設施進行實際業務邏輯測試
/// </summary>
public class OrderServiceIntegrationTests
{
    [Test]
    public async Task CreateOrder_使用PostgreSQL和Redis_應正確處理訂單流程()
    {
        // 使用 GlobalTestInfrastructureSetup 提供的容器
        var dbConnection = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString();
        var redisConnection = GlobalTestInfrastructureSetup.RedisContainer!.GetConnectionString();
        
        // 建立實際的服務實例
        var orderService = new OrderService(dbConnection, redisConnection);
        
        // 執行真正的業務邏輯測試
        var order = await orderService.CreateOrderAsync(new CreateOrderRequest { ... });
        
        // 驗證業務邏輯結果
        await Assert.That(order.Id).IsNotNull();
        await Assert.That(order.Status).IsEqualTo(OrderStatus.Created);
    }
}

這樣的分層設計讓測試架構更清晰:基礎設施驗證業務邏輯測試端到端測試

效能最佳化策略

Assembly 級別的容器管理提供了最佳的效能優勢:

Assembly-level 容器共享的好處:

  1. 大幅減少啟動時間:容器只在 Assembly 開始時啟動一次
  2. 顯著降低資源消耗:避免每個測試類別重複建立容器
  3. 提升測試穩定性:減少容器啟動失敗的風險
  4. 保持測試隔離:測試間仍然可以獨立清理資料,容器狀態不會互相干擾
// Assembly 級別的效能最佳化範例
[Before(Assembly)]
public static async Task SetupGlobalInfrastructure()
{
    // 所有容器在整個測試會話開始時啟動一次
    PostgreSqlContainer = new PostgreSqlBuilder()
        .WithDatabase("test_db")
        .WithUsername("test_user")
        .WithPassword("test_password")
        .WithNetwork(Network)
        .WithCleanUp(true)  // 測試結束後自動清理
        .Build();

    await PostgreSqlContainer.StartAsync();
}

真實專案整合範例

在實際專案中,你可以建立專門的測試基礎設施管理器:

/// <summary>
/// 測試基礎設施管理器
/// 提供統一的容器管理和依賴注入
/// </summary>
public class TestInfrastructureManager
{
    /// <summary>
    /// 取得完整的應用程式設定
    /// </summary>
    private Dictionary<string, string> GetTestConfiguration()
    {
        return new Dictionary<string, string>
        {
            ["ConnectionStrings:DefaultConnection"] = GlobalTestInfrastructureSetup.PostgreSqlContainer!.GetConnectionString(),
            ["ConnectionStrings:Redis"] = GlobalTestInfrastructureSetup.RedisContainer!.GetConnectionString(),
            ["Environment"] = "Testing"
        };
    }

    [Test]
    [Property("Category", "Infrastructure")]
    [DisplayName("基礎設施管理器:設定產生驗證")]
    public async Task InfrastructureManager_設定產生_應提供完整設定()
    {
        // Act
        var configuration = GetTestConfiguration();

        // Assert
        await Assert.That(configuration).IsNotNull();
        await Assert.That(configuration.ContainsKey("ConnectionStrings:DefaultConnection")).IsTrue();
        await Assert.That(configuration.ContainsKey("ConnectionStrings:Redis")).IsTrue();
        await Assert.That(configuration.ContainsKey("Environment")).IsTrue();

        await Assert.That(configuration["Environment"]).IsEqualTo("Testing");
        await Assert.That(configuration["ConnectionStrings:DefaultConnection"]).Contains("test_db");
        await Assert.That(configuration["ConnectionStrings:Redis"]).Contains("127.0.0.1");

        // 輸出設定資訊以供檢視
        Console.WriteLine("Generated test configuration:");
        foreach (var kvp in configuration)
        {
            Console.WriteLine($"  {kvp.Key}: {kvp.Value}");
        }
    }

    [Test]
    [Property("Category", "Infrastructure")]
    [DisplayName("基礎設施管理器:容器健康狀態檢查")]
    public async Task InfrastructureManager_容器健康檢查_所有服務應正常運作()
    {
        // Act & Assert
        await Assert.That(GlobalTestInfrastructureSetup.PostgreSqlContainer!.State).IsEqualTo(TestcontainersStates.Running);
        await Assert.That(GlobalTestInfrastructureSetup.RedisContainer!.State).IsEqualTo(TestcontainersStates.Running);

        // 驗證連線字串有效性
        var dbConnection = GlobalTestInfrastructureSetup.PostgreSqlContainer.GetConnectionString();
        var redisConnection = GlobalTestInfrastructureSetup.RedisContainer.GetConnectionString();

        await Assert.That(dbConnection).IsNotNull();
        await Assert.That(redisConnection).IsNotNull();

        Console.WriteLine("Infrastructure health check passed:");
        Console.WriteLine($"  Database: {GlobalTestInfrastructureSetup.PostgreSqlContainer.State} - {dbConnection}");
        Console.WriteLine($"  Redis: {GlobalTestInfrastructureSetup.RedisContainer.State} - {redisConnection}");
    }
}

這種基礎設施編排方式讓我們能夠建立真正接近正式環境的測試場景,確保整合測試的可靠性和真實性。


TUnit Engine Modes:執行模式深度解析

在執行 TUnit 測試時,你可能注意到輸出中會顯示 Engine Mode: SourceGenerated。這是 TUnit 的一個重要特色,讓我們深入了解這個機制。

TUnit 的兩種執行模式

TUnit 支援兩種執行模式,各有其適用場景和性能特色:

1. Source Generation Mode(預設模式)

這是 TUnit 的預設模式,也是其核心競爭優勢所在:

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

   TUnit v0.57.24.0

   Engine Mode: SourceGenerated

特色與優勢:

  • 編譯時期產生:所有測試發現邏輯在編譯時產生,不需要執行時反射
  • 效能優異:比反射模式快數倍,特別是在大型測試套件中
  • 型別安全:編譯時期驗證測試配置和資料來源
  • AOT 相容:完全支援 Native AOT 編譯

Source Generator 如何運作:

TUnit 的 Source Generator 會在編譯時建立強型別的委派:

  • 測試調用:產生特定型別的委派,而非泛用的物件陣列
  • 資料來源:為每個資料來源建立專門的工廠方法
  • 屬性注入:產生具有依賴解析的屬性設定器
  • Hook 方法:建立具有適當非同步支援的強型別 Hook 委派

2. Reflection Mode(反射模式)

傳統的執行時反射模式,可透過命令列參數啟用:

# 啟用反射模式
dotnet run -- --reflection

# 或設定環境變數
$env:TUNIT_EXECUTION_MODE = "reflection"
dotnet run

適用場景:

  • 動態測試發現:需要在執行時發現測試的場景
  • 相容性需求:某些依賴反射的測試模式
  • 非 C# 專案:F# 和 VB.NET 專案自動使用反射模式

Native AOT 支援的額外優勢

當使用 Native AOT 發佈時,Source Generation 模式提供額外的效能提升:

專案設定

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>

發佈指令

dotnet publish -c Release

Native AOT 的優勢

  • 完全 AOT 相容:Source Generated 程式碼完全支援 Native AOT 編譯
  • 增強效能:比反射模式有更好的執行效能
  • 減少檔案大小:不需要反射中繼資料,產生更小的執行檔
  • 快速啟動:沒有執行時型別發現的負擔

語言相容性

不同程式語言的支援狀況:

  • C# 專案:完全支援 Source Generation,建議使用
  • F# 專案:自動退回到反射模式(Source Generation 不支援)
  • VB.NET 專案:自動退回到反射模式(Source Generation 不支援)

F#/VB.NET 的效能最佳化策略:

如果你的主要程式碼是 F# 或 VB.NET,但想享受 Source Generation 的效能優勢:

Solution/
├── MyLibrary.fsproj        # F# 主要邏輯
├── MyLibrary.Tests.csproj  # C# 測試專案(使用 Source Generation)
└── ...

這種架構讓你能夠:

  • 保持 F# 的函數式程式設計優勢
  • 享受 TUnit Source Generation 的效能提升
  • 獲得最佳的測試執行體驗

診斷與疑難排解設定

對於 Source Generation 相關問題,TUnit 提供診斷選項:

EditorConfig 設定

# .editorconfig
# 啟用詳細診斷(預設:false)
tunit.enable_verbose_diagnostics = true

MSBuild 屬性設定

<PropertyGroup>
    <TUnitEnableVerboseDiagnostics>true</TUnitEnableVerboseDiagnostics>
</PropertyGroup>

實際效能比較

在我們的測試專案中,可以觀察到 Source Generation 模式的優勢:

# Source Generation 模式(預設)
   Engine Mode: SourceGenerated
   total: 25, succeeded: 25, duration: 1s 105ms

# 如果切換到反射模式(僅示意)
# dotnet run -- --reflection
   Engine Mode: Reflection
   total: 25, succeeded: 25, duration: 2s 340ms

Source Generation 的效能優勢在於:

  1. 零反射負擔:編譯時期就確定所有測試結構
  2. 型別特化:為每個測試方法產生專門的調用路徑
  3. 記憶體效率:減少物件配置和垃圾回收壓力

TUnit 常見問題與疑難排解

在實際使用 TUnit 的過程中,你可能會遇到一些特有的問題。基於實際的開發經驗,以下是最常見的問題和解決方案:

測試統計顯示異常問題

問題現象

執行 dotnet test 時出現以下情況:

  • 測試顯示為 測試摘要: 總計: 0, 失敗: 0, 成功: 0
  • 明明有寫測試但卻顯示找不到測試
  • 測試輸出格式與傳統 xUnit 不同

根本原因分析

TUnit 使用的是 Microsoft.Testing.Platform,而不是傳統的 VSTest 平台。這個架構差異會導致幾個問題:

  1. 測試發現機制不同:依賴 Source Generator 進行測試發現
  2. 輸出格式差異:統計格式為 測試摘要: 總計: X, 失敗: Y, 成功: Z, 已跳過: W
  3. 依賴關係更嚴格:需要正確的專案參考和套件版本

解決方案步驟

1. 檢查 Program.cs 檔案清潔度

最常見的問題是測試專案的 Program.cs 包含了會干擾測試輸出的程式碼:

// 錯誤:會干擾測試統計顯示
Console.WriteLine("Starting application...");
var builder = WebApplication.CreateBuilder(args);
// ... 其他程式碼

// 正確:保持最簡潔的 Program.cs
var builder = WebApplication.CreateBuilder(args);
// 只包含必要的服務註冊和中介軟體設定
2. 確保專案檔設定正確

檢查 .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" />
    <!-- 整合測試需要額外套件,注意版本要與 .NET 9 相容 -->
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
  </ItemGroup>
</Project>
3. GlobalUsing.cs 的正確設定

確保 GlobalUsing.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;
global using Microsoft.Extensions.Logging;
// 整合測試專案還需要:
global using Microsoft.AspNetCore.Mvc.Testing;
global using System.Diagnostics;
4. 整合測試的特殊設定

針對 ASP.NET Core 整合測試,確保 Program 類別可以被測試存取:

// 在 WebApi 專案的 Program.cs 最後加上
public partial class Program { }  // 讓整合測試可以存取

驗證步驟

執行以下命令來驗證修正效果:

# 1. 清理和重建
dotnet clean; dotnet build

# 2. 執行測試並觀察輸出格式
dotnet test --verbosity normal

# 3. 正確的輸出應該類似:
# 測試摘要: 總計: 132, 失敗: 0, 成功: 132, 已跳過: 0

Source Generator 相關問題

問題:測試類別無法被發現

  • 原因:Source Generator 沒有正確執行
  • 解決:確保專案完全重建 (dotnet clean; dotnet build)

問題:編譯時出現奇怪錯誤

  • 原因:Source Generator 與其他程式碼產生器衝突
  • 解決:檢查是否有其他 Source Generator 套件,考慮更新到相容版本

效能問題排解

測試執行過慢

// 調整並行設定
[Test]
[Property("Parallel", "false")]  // 對於資源敏感的測試關閉並行
public async Task SlowTest_應在合理時間內完成()
{
    // 測試邏輯
}

記憶體使用過高

確保正確釋放資源:

public void Dispose()
{
    _client?.Dispose();
    _factory?.Dispose();
    // 釋放其他資源
}

實際專案修正案例

整合測試專案從零測試到全數通過的修正過程

在我們的範例專案中,整合測試專案曾經遇到典型的問題:

問題現象:

測試摘要: 總計: 0, 失敗: 0, 成功: 0, 已跳過: 0

解決步驟:

  1. 清理重複檔案:刪除多餘的 GlobalUsing.cs,保留標準的 GlobalUsings.cs
  2. 套件版本修正:將 Microsoft.AspNetCore.Mvc.Testing 從 8.0.11 升級到 9.0.0
  3. 測試原則修正:只測試實際存在的功能,避免測試不存在的端點

修正後結果:

測試摘要: 總計: 6, 失敗: 0, 成功: 6, 已跳過: 0

這個案例說明了測試設計的重要原則:測試應該驗證實際的業務需求,而不是測試框架的容錯能力。


總結與最佳實踐

在這篇文章中,我們探討了 TUnit 的執行控制和整合測試功能,從測試品質管理到完整的 Web 應用程式驗證。這些技術展現了 TUnit 的實用性,也體現了現代測試框架在實際專案應用中的價值。

關鍵收穫

執行控制最佳實踐:

  • Retry:只用於真正不穩定的外部依賴測試
  • Timeout:為效能敏感的測試設定合理限制
  • DisplayName:讓測試報告更符合業務語言

整合測試策略:

  • 使用 WebApplicationFactory 進行完整的 Web API 測試
  • 運用 TUnit + Testcontainers 建立複雜多服務測試環境
  • 透過屬性注入系統管理複雜的依賴關係
  • 結合效能測試確保系統品質
  • 只測試實際存在的功能,避免測試不存在的端點

複雜基礎設施編排:

  • 容器共享策略:使用 SharedType.PerTestSession 提升效能
  • 依賴管理:透過 TUnit 屬性注入處理複雜的服務依賴
  • 多服務協調:PostgreSQL、Redis、Kafka 等服務的統一管理
  • 真實環境模擬:建立接近正式環境的測試場景

Engine Modes 核心優勢:

  • Source Generation Mode:編譯時期最佳化,提供優異效能
  • Native AOT 支援:完全相容現代雲原生部署需求
  • 型別安全:編譯時期驗證,減少執行時錯誤
  • 跨語言策略:C# 測試專案可測試 F#/VB.NET 程式庫

TUnit vs xUnit:技術選擇考量

TUnit 在以下方面具有顯著優勢:

  1. 效能突破:Source Generator 驅動,測試啟動速度提升數十倍
  2. AOT 相容性:完全支援 Native AOT 編譯,適合現代雲原生應用
  3. 現代語法:流暢式斷言讓測試程式碼更直覺易讀
  4. 並行執行:預設並行,提供更精細的執行控制
  5. Engine Mode 靈活性:可根據需要切換 Source Generation 或 Reflection 模式

但也需要考慮:

  • 生態系統較新:TUnit 相對較新,社群資源和第三方工具支援還在發展中
  • 學習成本:需要適應新的語法和概念,特別是 Source Generator 相關知識
  • CI/CD 整合:某些 CI/CD 工具可能需要額外設定
  • 語言支援:Source Generation 目前僅支援 C#

遷移策略建議

如果你正在考慮從 xUnit 遷移到 TUnit:

1. 評估階段:

  • 專案是否真的需要 AOT 支援?
  • 測試執行效能是否是關鍵瓶頸?
  • 團隊是否願意投入學習成本?

2. 實施階段:

  • 從新功能的測試開始使用 TUnit
  • 保持 xUnit 和 TUnit 並存,漸進式遷移
  • 建立團隊的 TUnit 最佳實踐指南
  • 利用 TUnit 的 Code Fixers 工具輔助語法轉換

3. 驗證階段:

  • 確認 CI/CD 工具鍊的完整相容性
  • 驗證測試報告和覆蓋率工具的支援
  • 建立回滾計劃以防遇到不可解決的問題

實用資源:

  • TUnit 官方遷移指南:提供自動化遷移工具
  • 語法對照表:在 Day28、Day29 文章中已有詳細比較
  • 漸進式遷移:可在同一專案中並存 xUnit 和 TUnit

未來展望

TUnit 代表了測試框架的發展方向:編譯時期最佳化效能優先。特別是 Engine Modes 的設計概念,體現了現代軟體開發的幾個重要趨勢:

編譯時期最佳化趨勢:

  • Source Generation:從執行時反射轉向編譯時產生,提升效能和型別安全
  • Native AOT 支援:迎合雲原生部署對啟動速度和記憶體使用的嚴格要求
  • Zero-Runtime-Reflection:消除反射負擔,實現更可預測的效能表現

技術演進趨勢:

隨著 .NET 生態系統的發展,特別是雲原生和微服務架構的普及,TUnit 的設計理念變得更加重要:

  1. 容器化部署:Native AOT 支援讓應用程式在容器中有更好的資源利用率
  2. Serverless 計算:快速啟動時間對 Azure Functions 和 AWS Lambda 很重要
  3. 邊緣運算:在資源受限的環境中,效能最佳化更加關鍵

但無論選擇哪個測試框架,核心原則始終不變:寫出可讀、可維護、可靠的測試程式碼。技術只是工具,良好的測試設計思維才是根本。

明日預告

明天我們將探討 Day 31 – 測試執行與診斷實戰:命令列技巧與除錯策略,包括:

  • 測試問題的診斷與分析:建立系統性的問題識別和根因分析流程
  • 命令列診斷工具實戰:熟悉 .NET CLI 的進階測試功能和診斷技巧
  • 測試維護最佳實踐:持續改善測試品質和穩定性的策略
  • 實戰疑難排解案例:從不穩定測試到效能問題的完整解決方案
  • 團隊協作與測試文化:建立可持續發展的測試實踐

我們將從實際開發場景出發,學習如何診斷和解決各種測試問題,確保測試套件的穩定性和可靠性。

雖然連續 30 天發文已經完成挑戰,但還是有些內容想分享給大家,所以明天繼續。

參考資源

TUnit 官方資源

進階功能文件

Testcontainers 相關資源

遷移與轉換工具

Microsoft 官方文件

範例程式碼


這是「重啟挑戰:老派軟體工程師的測試修練」的第三十天。明天會介紹 Day 31 – 測試執行與診斷實戰:命令列技巧與除錯策略。


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

尚未有邦友留言

立即登入留言