iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Software Development

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

Day 25 – .NET Aspire 整合測試實戰:從 Testcontainers 到 .NET Aspire Testing

  • 分享至 

  • xImage
  •  

前言

Day23 我們用 Testcontainers for .NET 建立了完整的產品管理 WebAPI 整合測試專案,展示了如何使用 PostgreSQL 和 Redis 容器測試真實的資料庫操作和快取機制。

Day24 認識了 .NET Aspire Testing 的基礎概念和基本用法,了解它如何簡化分散式應用的測試設定。

今天要將這兩個概念結合:把 Day23 的 Testcontainers 實作改寫為 .NET Aspire Testing。這不只是技術工具的替換,更是測試思維的轉變。

透過這個實戰練習,你將:

  • 體驗從 Testcontainers 遷移到 .NET Aspire Testing 的完整過程
  • 比較兩種方法在實際應用中的差異
  • 掌握 .NET Aspire Testing 在複雜整合測試中的實用技巧
  • 學會在實務專案中做出明智的技術選擇

本篇學習內容

  • 專案架構重構:將 Day23 的專案結構調整為 Aspire 架構
  • 測試基礎設施改寫:從 WebApplicationFactory + Testcontainers 遷移到 DistributedApplicationTestingBuilder
  • 核心測試案例移植:保持相同的測試覆蓋率和品質
  • 實作差異分析:深入比較兩種方法的優缺點

專案架構重構

Day23 原有架構回顧

回顧一下 Day23 的專案結構:

Day23/ (Testcontainers)
├── src/
│   ├── Day23.Api/                    # WebApi 層
│   ├── Day23.Application/            # 應用服務層  
│   ├── Day23.Domain/                 # 領域模型
│   └── Day23.Infrastructure/         # 基礎設施層
└── tests/
    └── Day23.Tests.Integration/      # Testcontainers 整合測試

這個架構的核心特點:

  • Clean Architecture 設計:清楚的層級分離
  • Testcontainers 管理:手動管理 PostgreSQL 和 Redis 容器
  • WebApplicationFactory 測試:傳統的 ASP.NET Core 測試模式

Day25 重構後架構

現在要將它重構為 .NET Aspire 架構:

Day25/ (.NET Aspire)
├── src/
│   ├── Day25.Api/                    # WebApi 層 (調整服務註冊)
│   ├── Day25.Application/            # 應用服務層 (相同)
│   ├── Day25.Domain/                 # 領域模型 (相同)
│   └── Day25.Infrastructure/         # 基礎設施層 (相同)
├── Day25.AppHost/                    # Aspire 編排專案
└── tests/
    └── Day25.Tests.Integration/      # Aspire Testing 整合測試

主要變化:

  • 新增 AppHost 專案:定義完整的應用架構和容器編排
  • API 專案調整:整合 Aspire 服務註冊
  • 測試基礎設施重寫:使用 .NET Aspire Testing 框架

技術架構組合保持一致

重要的是,我們保持相同的技術架構組合:

  • API: ASP.NET Core 9 WebApi (Controllers)
  • 資料庫: PostgreSQL + Dapper
  • 快取: Redis
  • 驗證: FluentValidation
  • 測試: xUnit + .NET Aspire Testing + Flurl + AwesomeAssertions

唯一的變化是將 Testcontainers 替換為 .NET Aspire Testing

核心實體與服務介面移植

由於我們要保持相同的測試覆蓋率,所有的領域模型和服務介面都保持不變。

Product 實體

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

ProductService 實作與時間可測試性

ProductService 需要注入 TimeProvider 來處理時間相關的邏輯,這讓測試更容易控制時間:

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    private readonly ICacheService _cacheService;
    private readonly ILogger<ProductService> _logger;
    private readonly TimeProvider _timeProvider;

    public ProductService(
        IProductRepository productRepository,
        ICacheService cacheService,
        ILogger<ProductService> logger,
        TimeProvider timeProvider)
    {
        _productRepository = productRepository;
        _cacheService = cacheService;
        _logger = logger;
        _timeProvider = timeProvider;
    }

    /// <summary>
    /// 建立產品
    /// </summary>
    public async Task<ProductResponse> CreateAsync(ProductCreateRequest request, CancellationToken cancellationToken = default)
    {
        var now = _timeProvider.GetUtcNow();
        var product = new Product
        {
            Id = Guid.NewGuid(),
            Name = request.Name,
            Price = request.Price,
            CreatedAt = now,
            UpdatedAt = now
        };

        var createdProduct = await _productRepository.CreateAsync(product, cancellationToken);

        // 清除相關快取
        await _cacheService.RemoveByPatternAsync("products:query:*", cancellationToken);

        _logger.LogInformation("已建立產品 {ProductId}: {ProductName}", createdProduct.Id, createdProduct.Name);

        return new ProductResponse
        {
            Id = createdProduct.Id,
            Name = createdProduct.Name,
            Price = createdProduct.Price,
            CreatedAt = createdProduct.CreatedAt,
            UpdatedAt = createdProduct.UpdatedAt
        };
    }

    // 其他方法實作...
}

重點:使用 TimeProvider 而非 DateTimeOffset.UtcNow

這種設計可以在測試中注入 FakeTimeProvider 來控制時間,大幅提升可測試性。

Dapper 欄位映射解決方案

在實作過程中,我們遇到了 PostgreSQL 資料庫欄位命名(snake_case)與 C# 屬性命名(PascalCase)的映射問題。有兩種解決方案:

方案一:SQL 查詢中使用別名

public class ProductRepository : IProductRepository
{
    /// <summary>
    /// 建立產品
    /// </summary>
    public async Task<Product> CreateAsync(Product product, CancellationToken cancellationToken = default)
    {
        const string sql = @"
            INSERT INTO products (id, name, price, created_at, updated_at)
            VALUES (@Id, @Name, @Price, @CreatedAt, @UpdatedAt)
            RETURNING id, name, price, created_at AS CreatedAt, updated_at AS UpdatedAt";

        await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
        var result = await connection.QuerySingleAsync<Product>(sql, product);
        
        _logger.LogInformation("已建立產品 {ProductId}", result.Id);
        return result;
    }
}

優點:簡單直接,不需要額外配置
缺點:每個查詢都需要手動添加別名,容易遺漏

方案二:使用 Dapper TypeMap(推薦)

建立自訂的型別映射來處理命名轉換:

方法一:使用 CustomPropertyTypeMap(推薦)
namespace Day25.Infrastructure.Data;

/// <summary>
/// Dapper 型別映射自動註冊
/// </summary>
public static class DapperTypeMapping
{
    /// <summary>
    /// 初始化 Dapper 型別映射
    /// 自動為指定命名空間下的所有實體類別註冊 snake_case 映射
    /// </summary>
    public static void Initialize()
    {
        // 取得 Domain 組件
        var domainAssembly = typeof(Product).Assembly;

        // 篩選出 Day25.Domain 命名空間下的所有實體類別
        var entityTypes = domainAssembly.GetTypes()
                                        .Where(t => t is { IsClass: true, IsAbstract: false, Namespace: "Day25.Domain" } &&
                                                    !t.Name.EndsWith("Exception")); // 排除例外類別

        foreach (var type in entityTypes)
        {
            UseSnakeCaseMapping(type);
        }
    }

    /// <summary>
    /// 為指定型別啟用 snake_case 映射
    /// </summary>
    private static void UseSnakeCaseMapping(Type entityType)
    {
        var map = new CustomPropertyTypeMap(entityType, (type, columnName) => GetPropertyInfo(type, columnName));
        SqlMapper.SetTypeMap(entityType, map);
    }

    /// <summary>
    /// 根據欄位名稱尋找對應的屬性
    /// </summary>
    private static PropertyInfo? GetPropertyInfo(Type type, string columnName)
    {
        // 先嘗試直接匹配
        var property = type.GetProperty(columnName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
        if (property != null)
        {
            return property;
        }

        // 將 snake_case 轉換為 PascalCase
        var pascalCaseName = columnName.ToPascalCase();
        
        // 尋找對應的屬性
        return type.GetProperty(pascalCaseName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
    }
}

字串擴充方法:

namespace Day25.Infrastructure.Data.Mapping;

/// <summary>
/// 字串擴充方法
/// </summary>
public static class StringExtensions
{
    /// <summary>
    /// 將 snake_case 轉換為 PascalCase
    /// </summary>
    public static string ToPascalCase(this string input)
    {
        if (string.IsNullOrEmpty(input))
        {
            return input;
        }

        // 使用 StringSplitOptions.RemoveEmptyEntries 避免因連續底線產生空字串
        var parts = input.Split(new[] { '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
        var result = new StringBuilder();
        
        foreach (var part in parts)
        {
            if (!string.IsNullOrEmpty(part))
            {
                result.Append(char.ToUpper(part[0]));
                if (part.Length > 1)
                {
                    result.Append(part.Substring(1).ToLower()); // 建議將其餘部分轉為小寫,更符合標準
                }
            }
        }
        return result.ToString();
    }
}
方法二:傳統的 DefaultTypeMap 繼承方式
namespace Day25.Infrastructure.Data.Mapping;

/// <summary>
/// Snake_case 到 PascalCase 的 Dapper 型別映射
/// </summary>
public class SnakeCaseTypeMap : DefaultTypeMap
{
    public SnakeCaseTypeMap(Type type) : base(type) 
    {
    }

    public override PropertyInfo? GetProperty(string columnName)
    {
        // 先嘗試原始欄位名稱
        var property = base.GetProperty(columnName);
        if (property != null)
        {
            return property;
        }

        // 嘗試 snake_case 轉 PascalCase
        var pascalCaseName = columnName.ToPascalCase();
        return base.GetProperty(pascalCaseName);
    }
}

在應用程式啟動時註冊映射:

namespace Day25.Infrastructure.Data;

/// <summary>
/// Dapper 映射配置
/// </summary>
public static class DapperMappingConfiguration
{
    /// <summary>
    /// 配置 Dapper 型別映射
    /// </summary>
    public static void Configure()
    {
        // 方法一:使用自動註冊(推薦)
        DapperTypeMapping.Initialize();
        
        // 方法二:手動註冊個別型別
        // SqlMapper.SetTypeMap(typeof(Product), new SnakeCaseTypeMap(typeof(Product)));
    }
}

兩種 TypeMap 方法比較

特性 CustomPropertyTypeMap(方法一) DefaultTypeMap 繼承(方法二)
自動化程度 完全自動,反射掃描註冊 需要手動註冊每個類別
映射靈活性 高,可自訂任何映射邏輯 中等,需要覆寫方法
維護性 佳,新增實體無需修改配置 普通,需要手動添加新類別
效能 啟動時一次掃描,運行時高效 運行時效能略優於方法一
學習曲線 較陡,需要理解委派和反射 較平緩,傳統的 OOP 繼承
推薦場景 大型專案,多個實體類別 小型專案,實體數量固定

建議:對於新專案,推薦使用方法一(CustomPropertyTypeMap),因為它提供了更好的自動化和維護性。如果你的專案實體類別不多且相對穩定,方法二也是不錯的選擇。

在 Program.cs 中初始化:

// 配置 Dapper 映射 - 在其他服務註冊之前
DapperTypeMapping.Initialize();

// 加入 PostgreSQL
builder.AddNpgsqlDataSource("productdb");

使用 TypeMap 後,Repository 的 SQL 查詢變得更簡潔:

public async Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
    const string sql = @"
        SELECT id, name, price, created_at, updated_at
        FROM products 
        WHERE id = @Id";

    await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
    var result = await connection.QuerySingleOrDefaultAsync<Product>(sql, new { Id = id });
    
    return result;
}

優點

  • 自動化註冊:使用反射自動掃描並註冊所有 Domain 實體,新增實體類別時無需手動配置
  • 一次設定,處處生效:所有查詢都自動映射,不需要在每個 SQL 查詢中添加別名
  • SQL 查詢更乾淨:不需要 AS 別名,SQL 更簡潔易讀
  • 型別安全:編譯時檢查,降低執行時錯誤
  • 可擴展性:容易新增新的實體類別,系統會自動處理映射

缺點

  • 複雜度較高:需要理解反射和 Dapper 的 TypeMap 機制
  • 除錯困難:映射問題較難追蹤和除錯
  • 效能考量:反射掃描在應用程式啟動時會有輕微的效能影響
  • 團隊學習成本:對團隊成員來說可能需要額外的學習時間

歷史經驗分享
之前曾經使用過一個 NuGet Package: Dapper.FluentMap,可以透過 Map 設定檔的方式將類別的屬性對應到指定的資料庫欄位,但是作者在兩年前就封存了專案,也不再更新了。

現在推薦使用 Dapper 內建的 SqlMapper.SetTypeMap 方法搭配 CustomPropertyTypeMap,這是官方支援且更靈活的標準作法。相比 SqlMapper.AddTypeMapSetTypeMap 允許我們定義自訂的映射邏輯。

AppHost 專案建立

AppHost 專案是 .NET Aspire 的核心,它定義了整個應用的服務編排。與 Testcontainers 需要在測試程式碼中手動管理容器不同,AppHost 讓我們在一個地方集中定義所有服務的配置和相依關係:

Day25.AppHost.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <UserSecretsId>aspire-day25-apphost</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.1.0" />
    <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.1.0" />
    <PackageReference Include="Aspire.Hosting.Redis" Version="9.1.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\src\Day25.Api\Day25.Api.csproj" />
  </ItemGroup>

</Project>

Program.cs - 應用編排定義

using Aspire.Hosting;

var builder = DistributedApplication.CreateBuilder(args);

// 加入 PostgreSQL 資料庫 - 使用 Session 生命週期,測試結束後自動清理
var postgres = builder.AddPostgres("postgres")
                     .WithLifetime(ContainerLifetime.Session);

var postgresDb = postgres.AddDatabase("productdb");

// 加入 Redis 快取 - 使用 Session 生命週期,測試結束後自動清理
var redis = builder.AddRedis("redis")
                  .WithLifetime(ContainerLifetime.Session);

// 加入 API 服務 - 使用強型別專案參考
var apiProject = builder.AddProject<Projects.Day25_Api>("day25-api")
                       .WithReference(postgresDb)
                       .WithReference(redis);

builder.Build().Run();

這個設定定義了:

  • PostgreSQL 容器:命名為 "postgres",包含 "productdb" 資料庫
  • Redis 容器:命名為 "redis",用於快取
  • API 服務:參考資料庫和快取服務

容器生命週期管理

使用 ContainerLifetime.Session 的重要性:

  • 自動清理:測試會話結束後,容器會自動停止和移除
  • 資源管理:避免測試結束後容器持續佔用系統資源
  • 測試隔離:每次測試會話都使用全新的容器環境

注意:如果使用 ContainerLifetime.Persistent,容器會持續運行直到手動停止,這在測試環境中可能造成資源浪費和容器累積問題。相較之下,ContainerLifetime.Session 提供的特性:

  • 持續生命週期:容器在應用停止後保持運行(僅適用於 Persistent 模式)

API 專案調整

Day25.Api.csproj 調整

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>aspire-day25-api</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Npgsql" Version="9.1.0" />
    <PackageReference Include="Aspire.StackExchange.Redis" Version="9.1.0" />
    <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
    <PackageReference Include="Serilog.AspNetCore" Version="8.0.4" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Day25.Application\Day25.Application.csproj" />
    <ProjectReference Include="..\Day25.Infrastructure\Day25.Infrastructure.csproj" />
  </ItemGroup>

</Project>

Program.cs 調整

using Day25.Api.Configuration;
using Day25.Api.ExceptionHandlers;
using Day25.Application.Services;
using Day25.Infrastructure.Caching;
using Day25.Infrastructure.Data;
using Day25.Infrastructure.Validation;
using FluentValidation;
using Microsoft.Bcl.TimeProvider;

var builder = WebApplication.CreateBuilder(args);

// 加入 PostgreSQL
builder.AddNpgsqlDataSource("productdb");

// 加入 Redis
builder.AddRedisClient("redis");

// 加入 API 服務
builder.Services.AddControllers();

// 加入 API 探索
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// 加入問題詳細資訊
builder.Services.AddProblemDetails();

// 加入異常處理器 - 順序很重要!
builder.Services.AddExceptionHandler<FluentValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

// 加入應用服務
builder.Services.AddApplicationServices();

// 加入基礎設施服務
builder.Services.AddInfrastructureServices();

// 加入 FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<ProductCreateRequestValidator>();

// 加入時間提供者
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);

var app = builder.Build();

// 配置 HTTP 請求管線
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// 使用異常處理器
app.UseExceptionHandler();

app.UseHttpsRedirection();
app.UseAuthorization();

app.MapControllers();

app.Run();

/// <summary>
/// 用於測試的 Program 類別
/// </summary>
public partial class Program { }

主要變化:

  • 移除 Testcontainers 相關配置:不再需要手動設定連線字串
  • 加入 Aspire 服務註冊AddNpgsqlDataSource(), AddRedisClient()
  • 簡化服務發現:透過 Aspire 自動處理服務間通訊
  • 保持異常處理器:ExceptionHandler 機制完全不變

.NET Aspire Testing 基礎設施實作

現在是最關鍵的部分:將測試基礎設施從 Testcontainers 改為 .NET Aspire Testing。

Day25.Tests.Integration.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="Aspire.Hosting.Testing" Version="9.1.0" />
    <PackageReference Include="AwesomeAssertions" Version="9.1.0" />
    <PackageReference Include="FluentValidation" Version="11.11.0" />
    <PackageReference Include="Flurl.Http" Version="4.0.2" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="Npgsql" Version="9.0.2" />
    <PackageReference Include="Respawn" Version="6.2.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\Day25.Application\Day25.Application.csproj" />
    <ProjectReference Include="..\..\src\Day25.Domain\Day25.Domain.csproj" />
    <ProjectReference Include="..\..\Day25.AppHost\Day25.AppHost.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="SqlScripts\Tables\" />
  </ItemGroup>

  <ItemGroup>
    <None Update="SqlScripts\Tables\CreateProductsTable.sql">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

AspireAppFixture - 新的測試基礎設施

這是從 TestWebApplicationFactoryAspireAppFixture 的關鍵轉換:

namespace Day25.Tests.Integration.Infrastructure;

/// <summary>
/// Aspire 應用測試 Fixture
/// 使用 .NET Aspire Testing 框架管理分散式應用測試
/// </summary>
public class AspireAppFixture : IAsyncLifetime
{
    private DistributedApplication? _app;
    private HttpClient? _httpClient;

    /// <summary>
    /// 應用程式實例
    /// </summary>
    public DistributedApplication App => _app ?? throw new InvalidOperationException("應用程式尚未初始化");

    /// <summary>
    /// HTTP 客戶端
    /// </summary>
    public HttpClient HttpClient => _httpClient ?? throw new InvalidOperationException("HTTP 客戶端尚未初始化");

    /// <summary>
    /// 初始化 Aspire 測試應用
    /// </summary>
    public async Task InitializeAsync()
    {
        // 建立 Aspire Testing 主機
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Day25_AppHost>();

        // 建置並啟動應用
        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        // 確保 PostgreSQL 和 Redis 服務完全就緒
        await WaitForServicesReadyAsync();

        // 等待 API 服務就緒並建立 HTTP 客戶端
        _httpClient = _app.CreateHttpClient("day25-api", "http");
    }

    /// <summary>
    /// 等待所有服務完全就緒
    /// </summary>
    private async Task WaitForServicesReadyAsync()
    {
        // 等待 PostgreSQL 就緒
        await WaitForPostgreSqlReadyAsync();
        
        // 等待 Redis 就緒
        await WaitForRedisReadyAsync();
    }

    /// <summary>
    /// 等待 PostgreSQL 服務就緒
    /// </summary>
    private async Task WaitForPostgreSqlReadyAsync()
    {
        const int maxRetries = 30;
        const int delayMs = 1000;

        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                // 先連線到 postgres 預設資料庫檢查服務是否就緒
                var connectionString = await GetConnectionStringAsync();
                var builder = new Npgsql.NpgsqlConnectionStringBuilder(connectionString);
                builder.Database = "postgres"; // 使用預設資料庫
                var masterConnectionString = builder.ToString();
                
                await using var connection = new Npgsql.NpgsqlConnection(masterConnectionString);
                await connection.OpenAsync();
                await connection.CloseAsync();
                Console.WriteLine("PostgreSQL 服務已就緒");
                return;
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                Console.WriteLine($"等待 PostgreSQL 就緒,嘗試 {i + 1}/{maxRetries}: {ex.Message}");
                await Task.Delay(delayMs);
            }
        }
        
        throw new InvalidOperationException("PostgreSQL 服務未能在預期時間內就緒");
    }

    /// <summary>
    /// 等待 Redis 服務就緒
    /// </summary>
    private async Task WaitForRedisReadyAsync()
    {
        const int maxRetries = 30;
        const int delayMs = 1000;

        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                var connectionString = await GetRedisConnectionStringAsync();
                await using var connection = StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
                var database = connection.GetDatabase();
                await database.PingAsync();
                await connection.DisposeAsync();
                Console.WriteLine("Redis 服務已就緒");
                return;
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                Console.WriteLine($"等待 Redis 就緒,嘗試 {i + 1}/{maxRetries}: {ex.Message}");
                await Task.Delay(delayMs);
            }
        }
        
        throw new InvalidOperationException("Redis 服務未能在預期時間內就緒");
    }

    /// <summary>
    /// 清理資源
    /// </summary>
    public async Task DisposeAsync()
    {
        _httpClient?.Dispose();
        
        if (_app != null)
        {
            await _app.DisposeAsync();
        }
    }

    /// <summary>
    /// 取得 PostgreSQL 連線字串
    /// </summary>
    public async Task<string> GetConnectionStringAsync()
    {
        return await _app.GetConnectionStringAsync("productdb");
    }

    /// <summary>
    /// 取得 Redis 連線字串  
    /// </summary>
    public async Task<string> GetRedisConnectionStringAsync()
    {
        return await _app.GetConnectionStringAsync("redis");
    }
}

IntegrationTestCollection - Collection Fixture 設定

延續 Day23 的最佳實務,使用 Collection Fixture 共享昂貴的容器資源:

namespace Day25.Tests.Integration.Infrastructure;

/// <summary>
/// 整合測試集合定義
/// 使用 Collection Fixture 在所有測試類別間共享 AspireAppFixture
/// </summary>
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<AspireAppFixture>
{
    /// <summary>
    /// 測試集合名稱
    /// </summary>
    public const string Name = "Integration Tests";
    
    // 這個類別不需要實作任何程式碼
    // 它只是用來定義 Collection Fixture
}

IntegrationTestBase - 新的測試基底類別

namespace Day25.Tests.Integration.Infrastructure;

/// <summary>
/// 整合測試基底類別 - 使用 Aspire Testing 框架
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public abstract class IntegrationTestBase : IAsyncLifetime
{
    protected readonly AspireAppFixture Fixture;
    protected readonly HttpClient HttpClient;
    protected readonly DatabaseManager DatabaseManager;

    protected IntegrationTestBase(AspireAppFixture fixture)
    {
        Fixture = fixture;
        HttpClient = fixture.HttpClient;
        DatabaseManager = new DatabaseManager(() => fixture.GetConnectionStringAsync());
    }

    /// <summary>
    /// 每個測試執行前的初始化
    /// </summary>
    public async Task InitializeAsync()
    {
        await DatabaseManager.InitializeDatabaseAsync();
    }

    /// <summary>
    /// 每個測試執行後的清理
    /// </summary>
    public async Task DisposeAsync()
    {
        await DatabaseManager.CleanDatabaseAsync();
    }
}

DatabaseManager 調整

DatabaseManager 從 Testcontainers 移植到 Aspire Testing 需要做三個關鍵調整:

  1. 資料庫自動創建:確保測試資料庫存在
  2. Respawn PostgreSQL 適配器配置:指定正確的資料庫適配器
  3. 連線字串的取得方式:從 Aspire 應用中取得動態連線字串
using System.Data;
using Npgsql;
using Respawn;
using Respawn.Graph;

namespace Day25.Tests.Integration.Infrastructure;

/// <summary>
/// 資料庫管理員 - 使用 Aspire 提供的連線字串
/// </summary>
public class DatabaseManager
{
    private readonly Func<Task<string>> _getConnectionStringAsync;
    private Respawner? _respawner;

    public DatabaseManager(Func<Task<string>> getConnectionStringAsync)
    {
        _getConnectionStringAsync = getConnectionStringAsync;
    }

    /// <summary>
    /// 初始化資料庫結構
    /// </summary>
    public async Task InitializeDatabaseAsync()
    {
        var connectionString = await _getConnectionStringAsync();

        // 首先確保 productdb 資料庫存在
        await EnsureDatabaseExistsAsync(connectionString);

        // 連線到 productdb 並確保資料表存在
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();

        // 確保資料表存在
        await EnsureTablesExistAsync(connection);

        // 初始化 Respawner - 關鍵:指定 PostgreSQL 適配器
        if (_respawner == null)
        {
            _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
            {
                TablesToIgnore = new Table[] { "__EFMigrationsHistory" },
                SchemasToInclude = new[] { "public" },
                DbAdapter = DbAdapter.Postgres  // 重要:明確指定 PostgreSQL 適配器
            });
        }
    }

    /// <summary>
    /// 清理測試資料
    /// </summary>
    public async Task CleanDatabaseAsync()
    {
        if (_respawner == null) return;

        var connectionString = await _getConnectionStringAsync();
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();
        await _respawner.ResetAsync(connection);
    }

    /// <summary>
    /// 取得連線字串
    /// </summary>
    public async Task<string> GetConnectionStringAsync()
    {
        return await _getConnectionStringAsync();
    }

    /// <summary>
    /// 確保資料庫存在 - 包含重試機制等待 PostgreSQL 就緒
    /// </summary>
    private async Task EnsureDatabaseExistsAsync(string connectionString)
    {
        // 解析連線字串取得伺服器資訊
        var builder = new NpgsqlConnectionStringBuilder(connectionString);
        var databaseName = builder.Database;

        // 連線到 postgres 預設資料庫檢查並創建 productdb
        builder.Database = "postgres";
        var masterConnectionString = builder.ToString();

        await using var connection = new NpgsqlConnection(masterConnectionString);

        // 重試機制等待 PostgreSQL 就緒
        await WaitForDatabaseConnectionAsync(connection);

        // 檢查資料庫是否已存在
        var checkDbQuery = $"SELECT 1 FROM pg_database WHERE datname = '{databaseName}'";
        await using var checkCommand = new NpgsqlCommand(checkDbQuery, connection);
        var dbExists = await checkCommand.ExecuteScalarAsync();

        if (dbExists == null)
        {
            // 創建 productdb 資料庫
            var createDbQuery = $"CREATE DATABASE \"{databaseName}\"";
            await using var createCommand = new NpgsqlCommand(createDbQuery, connection);
            await createCommand.ExecuteNonQueryAsync();
        }
    }

    /// <summary>
    /// 等待資料庫連線就緒的重試機制
    /// </summary>
    private async Task WaitForDatabaseConnectionAsync(NpgsqlConnection connection)
    {
        const int maxRetries = 30;
        const int delayMs = 1000;

        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                await connection.OpenAsync();
                return; // 連線成功
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                Console.WriteLine($"資料庫連線嘗試 {i + 1} 失敗: {ex.Message}");
                await Task.Delay(delayMs);

                if (connection.State != ConnectionState.Closed)
                {
                    await connection.CloseAsync();
                }
            }
        }

        // 最後一次嘗試,如果失敗就拋出例外
        await connection.OpenAsync();
    }

    /// <summary>
    /// 確保必要的資料表存在
    /// </summary>
    private async Task EnsureTablesExistAsync(NpgsqlConnection connection)
    {
        var createProductTableSql = """
            CREATE TABLE IF NOT EXISTS products (
                id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                name VARCHAR(100) NOT NULL,
                price DECIMAL(10,2) NOT NULL,
                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
                updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
            );
            """;

        await using var command = new NpgsqlCommand(createProductTableSql, connection);
        await command.ExecuteNonQueryAsync();
    }
}

核心測試案例移植

現在將 Day23 的核心測試案例完整移植過來。測試邏輯完全不變,只是測試基礎設施從 Testcontainers 改為 .NET Aspire Testing。

ProductsControllerTests - 產品 CRUD 測試

namespace Day25.Tests.Integration.Controllers;

/// <summary>
/// ProductsController 整合測試 - 使用 Aspire Testing
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class ProductsControllerTests : IntegrationTestBase
{
    public ProductsControllerTests(AspireAppFixture fixture) : base(fixture)
    {
    }

    #region GET /products 測試

    [Fact]
    public async Task GetProducts_當沒有產品時_應回傳空的分頁結果()
    {
        // Arrange
        // (資料庫應為空)

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

        // Assert
        response.Should().Be200Ok()
                .And.Satisfy<PagedResult<ProductResponse>>(result =>
                {
                    result.Total.Should().Be(0);
                    result.PageSize.Should().Be(10);
                    result.Page.Should().Be(1);
                    result.Items.Should().BeEmpty();
                });
    }

    [Fact]
    public async Task GetProducts_使用分頁參數_應回傳正確的分頁結果()
    {
        // Arrange
        await TestHelpers.SeedProductsAsync(DatabaseManager, 15);

        // Act - 使用 Flurl 建構 QueryString
        var url = "/products"
                  .SetQueryParam("pageSize", 5)
                  .SetQueryParam("page", 2);

        var response = await HttpClient.GetAsync(url);

        // Assert
        response.Should().Be200Ok()
                .And.Satisfy<PagedResult<ProductResponse>>(result =>
                {
                    result.Total.Should().Be(15);
                    result.PageSize.Should().Be(5);
                    result.Page.Should().Be(2);
                    result.PageCount.Should().Be(3);
                    result.Items.Should().HaveCount(5);
                    result.Items.Should().AllSatisfy(product =>
                    {
                        product.Id.Should().NotBeEmpty();
                        product.Name.Should().NotBeNullOrEmpty();
                        product.Price.Should().BeGreaterThan(0);
                    });
                });
    }

    [Fact]
    public async Task GetProducts_使用搜尋參數_應回傳符合條件的產品()
    {
        // Arrange
        await TestHelpers.SeedProductsAsync(DatabaseManager, 5);
        await TestHelpers.SeedSpecificProductAsync(DatabaseManager, "特殊產品", 199.99m);

        // Act - 使用 Flurl 建構 QueryString
        var url = "/products"
                  .SetQueryParam("keyword", "特殊")
                  .SetQueryParam("pageSize", 10);

        var response = await HttpClient.GetAsync(url);

        // Assert
        response.Should().Be200Ok()
                .And.Satisfy<PagedResult<ProductResponse>>(result =>
                {
                    result.Total.Should().Be(1);
                    result.Items.Should().HaveCount(1);

                    var product = result.Items.First();
                    product.Name.Should().Be("特殊產品");
                    product.Price.Should().Be(199.99m);
                });
    }

    #endregion
    
    // 其他測試案例請看範例專案原始碼
}

HealthControllerTests - 健康檢查測試

namespace Day25.Tests.Integration.Controllers;

/// <summary>
/// HealthController 整合測試 - 使用 Aspire Testing
/// </summary>
[Collection(IntegrationTestCollection.Name)]
public class HealthControllerTests : IntegrationTestBase
{
    public HealthControllerTests(AspireAppFixture fixture) : base(fixture)
    {
    }

    [Fact]
    public async Task GetHealth_應回傳200與健康狀態()
    {
        // Act
        var response = await HttpClient.GetAsync("/health");

        // Assert
        response.Should().Be200Ok();

        var content = await response.Content.ReadAsStringAsync();
        content.Should().Be("Healthy");
    }

    [Fact]
    public async Task GetAlive_應回傳200與存活狀態()
    {
        // Act
        var response = await HttpClient.GetAsync("/alive");

        // Assert
        response.Should().Be200Ok();

        var content = await response.Content.ReadAsStringAsync();
        content.Should().Be("Healthy");
    }
}

TestHelpers - 測試輔助方法

namespace Day25.Tests.Integration.Infrastructure;

/// <summary>
/// 測試輔助方法
/// </summary>
public static class TestHelpers
{
    /// <summary>
    /// 建立產品建立請求
    /// </summary>
    public static ProductCreateRequest CreateProductRequest(string name, decimal price)
    {
        return new ProductCreateRequest
        {
            Name = name,
            Price = price
        };
    }

    /// <summary>
    /// 批量建立測試產品
    /// </summary>
    public static async Task SeedProductsAsync(DatabaseManager databaseManager, int count)
    {
        var connectionString = await databaseManager.GetConnectionStringAsync();
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();

        for (int i = 1; i <= count; i++)
        {
            var sql = @"
                INSERT INTO products (id, name, price, created_at, updated_at)
                VALUES (@id, @name, @price, @createdAt, @updatedAt)";

            await using var command = new NpgsqlCommand(sql, connection);
            command.Parameters.AddWithValue("id", Guid.NewGuid());
            command.Parameters.AddWithValue("name", $"測試產品 {i}");
            command.Parameters.AddWithValue("price", 100.00m + i);
            command.Parameters.AddWithValue("createdAt", DateTimeOffset.UtcNow);
            command.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);

            await command.ExecuteNonQueryAsync();
        }
    }

    /// <summary>
    /// 建立指定的測試產品
    /// </summary>
    public static async Task<Guid> SeedSpecificProductAsync(DatabaseManager databaseManager, string name, decimal price)
    {
        var connectionString = await databaseManager.GetConnectionStringAsync();
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();

        var productId = Guid.NewGuid();
        var sql = @"
            INSERT INTO products (id, name, price, created_at, updated_at)
            VALUES (@id, @name, @price, @createdAt, @updatedAt)";

        await using var command = new NpgsqlCommand(sql, connection);
        command.Parameters.AddWithValue("id", productId);
        command.Parameters.AddWithValue("name", name);
        command.Parameters.AddWithValue("price", price);
        command.Parameters.AddWithValue("createdAt", DateTimeOffset.UtcNow);
        command.Parameters.AddWithValue("updatedAt", DateTimeOffset.UtcNow);

        await command.ExecuteNonQueryAsync();
        return productId;
    }

    /// <summary>
    /// 取得連線字串 (DatabaseManager 無法直接公開,透過反射取得)
    /// </summary>
    internal static async Task<string> GetConnectionStringAsync(this DatabaseManager databaseManager)
    {
        var field = typeof(DatabaseManager).GetField("_getConnectionStringAsync", 
            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        
        if (field?.GetValue(databaseManager) is Func<Task<string>> getConnectionString)
        {
            return await getConnectionString();
        }
        
        throw new InvalidOperationException("無法取得連線字串");
    }
}

VerifyAspireContainers - Aspire 容器驗證測試

實作過程中,為了確保 Aspire Testing 真的用真實容器而不是模擬服務,我們建立了專門的驗證測試類別:

namespace Day25.Tests.Integration;

/// <summary>
/// 驗證 Aspire 容器使用情況的簡單測試
/// </summary>
public class VerifyAspireContainers : IAsyncLifetime
{
    private DistributedApplication? _app;

    public async Task InitializeAsync()
    {
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Day25_AppHost>();

        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        // 等待 PostgreSQL 和 Redis 服務完全就緒
        await WaitForPostgreSqlReadyAsync();
        await WaitForRedisReadyAsync();
    }

    /// <summary>
    /// 等待 PostgreSQL 服務就緒
    /// </summary>
    private async Task WaitForPostgreSqlReadyAsync()
    {
        const int maxRetries = 30;
        const int delayMs = 1000;

        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                var connectionString = await _app!.GetConnectionStringAsync("productdb");
                var builder = new Npgsql.NpgsqlConnectionStringBuilder(connectionString);
                builder.Database = "postgres"; // 使用預設資料庫
                var masterConnectionString = builder.ToString();
                
                await using var connection = new Npgsql.NpgsqlConnection(masterConnectionString);
                await connection.OpenAsync();
                await connection.CloseAsync();
                Console.WriteLine("PostgreSQL 服務已就緒");
                return;
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                Console.WriteLine($"等待 PostgreSQL 就緒,嘗試 {i + 1}/{maxRetries}: {ex.Message}");
                await Task.Delay(delayMs);
            }
        }
        
        throw new InvalidOperationException("PostgreSQL 服務未能在預期時間內就緒");
    }

    /// <summary>
    /// 等待 Redis 服務就緒
    /// </summary>
    private async Task WaitForRedisReadyAsync()
    {
        const int maxRetries = 30;
        const int delayMs = 1000;

        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                var connectionString = await _app!.GetConnectionStringAsync("redis");
                await using var connection = StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
                var database = connection.GetDatabase();
                await database.PingAsync();
                await connection.DisposeAsync();
                Console.WriteLine("Redis 服務已就緒");
                return;
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                Console.WriteLine($"等待 Redis 就緒,嘗試 {i + 1}/{maxRetries}: {ex.Message}");
                await Task.Delay(delayMs);
            }
        }
        
        throw new InvalidOperationException("Redis 服務未能在預期時間內就緒");
    }

    public async Task DisposeAsync()
    {
        if (_app != null)
        {
            await _app.DisposeAsync();
        }
    }

    [Fact]
    public async Task 驗證_Aspire_容器連線字串()
    {
        // Arrange & Act
        var postgresConnectionString = await _app!.GetConnectionStringAsync("productdb");
        var redisConnectionString = await _app!.GetConnectionStringAsync("redis");

        // Assert
        Console.WriteLine($"PostgreSQL 連線字串: {postgresConnectionString}");
        Console.WriteLine($"Redis 連線字串: {redisConnectionString}");

        // 驗證 PostgreSQL 連線字串格式和動態埠
        postgresConnectionString.Should().Contain("Host=localhost");
        postgresConnectionString.Should().Contain("Port=");
        postgresConnectionString.Should().NotContain("Port=5432"); // 不是預設埠
        
        // 驗證 Redis 連線字串格式和動態埠
        redisConnectionString.Should().Contain("localhost:");
        redisConnectionString.Should().NotContain(":6379"); // 不是預設埠
    }

    [Fact]
    public async Task 驗證_可以實際連線到PostgreSQL()
    {
        // Arrange
        var connectionString = await _app!.GetConnectionStringAsync("productdb");

        // 首先連到 postgres 預設資料庫
        var builder = new Npgsql.NpgsqlConnectionStringBuilder(connectionString);
        builder.Database = "postgres";
        var masterConnectionString = builder.ToString();

        // Act & Assert
        await using var connection = new Npgsql.NpgsqlConnection(masterConnectionString);
        await connection.OpenAsync();

        var command = connection.CreateCommand();
        command.CommandText = "SELECT version()";
        var version = await command.ExecuteScalarAsync();

        Console.WriteLine($"PostgreSQL 版本: {version}");
        version.Should().NotBeNull();
        version.ToString().Should().Contain("PostgreSQL");
    }
}

為什麼需要這個驗證測試

  1. 證明真實容器使用:檢查連線字串的動態埠(非預設的 5432 和 6379),證明 Aspire 用的是真實容器而非模擬服務

  2. 顯示容器資訊:測試輸出會顯示實際的連線字串:

    • PostgreSQL 使用動態埠如 64689 和自動產生的密碼
    • Redis 使用動態埠如 64690
    • 每次測試都用全新的容器實例
  3. 驗證服務連線能力:實際連線到 PostgreSQL 並執行查詢,確保容器服務可用

  4. 等待機制展示:展示如何正確等待容器服務就緒,這是分散式測試的重要技巧

執行結果範例

PostgreSQL 連線字串: Host=localhost;Port=64689;Username=postgres;Password=}){uvsUJj)d)3U31w~54kK;Database=productdb
Redis 連線字串: localhost:64690
PostgreSQL 版本: PostgreSQL 17.2 (Debian 17.2-1.pgdg120+1) on x86_64-pc-linux-gnu...

這個驗證測試成功解答了對 Aspire Testing 的疑慮:它確實使用真實的容器,並且每次執行都會建立全新的容器實例

實作差異深度分析

在實際實作過程中,我們遇到了多個關鍵問題,這些問題的解決過程展示了 Aspire Testing 與 Testcontainers 的重要差異。

關鍵問題解決歷程

問題 1:Aspire 端點配置衝突

錯誤訊息

System.InvalidOperationException: Endpoint with name 'http' already exists.

原因:在 AppHost 中手動配置了 WithHttpEndpoint(),但 Aspire 已經自動處理端點配置。

解決方案

// 錯誤的做法(會造成衝突)
builder.AddProject<Projects.Day25_Api>("day25-api")
       .WithHttpEndpoint(port: 8080, name: "http")  // 移除這行
       .WithReference(postgresDb)
       .WithReference(redis);

// 正確的做法(讓 Aspire 自動處理)
builder.AddProject<Projects.Day25_Api>("day25-api")
       .WithReference(postgresDb)
       .WithReference(redis);

學習重點:Aspire 的服務發現機制比 Testcontainers 更智慧,但需要避免手動干預。

問題 2:PostgreSQL 資料庫連線與建立

錯誤訊息

Npgsql.NpgsqlException: Connection refused (0x0000274D/10061)
Npgsql.PostgresException: database "productdb" does not exist

原因:Aspire 會啟動 PostgreSQL 容器,但不會自動建立指定的應用程式資料庫。連線被拒絕通常是容器還在啟動中,但 Aspire Testing 會自動等待服務就緒。

解決方案:在 DatabaseManager 中實作資料庫自動建立邏輯:

/// <summary>
/// 確保資料庫存在 - 此時 PostgreSQL 服務應該已經就緒
/// </summary>
private async Task EnsureDatabaseExistsAsync(string connectionString)
{
    var builder = new NpgsqlConnectionStringBuilder(connectionString);
    var databaseName = builder.Database;
    
    // 連線到 postgres 預設資料庫檢查並建立 productdb
    builder.Database = "postgres";
    var masterConnectionString = builder.ToString();
    
    await using var connection = new NpgsqlConnection(masterConnectionString);
    await connection.OpenAsync();
    
    // 檢查資料庫是否已存在
    var checkDbQuery = $"SELECT 1 FROM pg_database WHERE datname = '{databaseName}'";
    await using var checkCommand = new NpgsqlCommand(checkDbQuery, connection);
    var dbExists = await checkCommand.ExecuteScalarAsync();
    
    if (dbExists == null)
    {
        // 建立 productdb 資料庫
        var createDbQuery = $"CREATE DATABASE \"{databaseName}\"";
        await using var createCommand = new NpgsqlCommand(createDbQuery, connection);
        await createCommand.ExecuteNonQueryAsync();
    }
}

學習重點:.NET Aspire Testing 會處理容器啟動等待,但應用程式層級的資料庫初始化仍需自己處理。

問題 3:Respawn 資料庫適配器錯誤

錯誤訊息

System.InvalidOperationException: The RESEED IDENTITY command is not supported

原因:Respawn 預設使用 SQL Server 語法,但我們使用的是 PostgreSQL。

解決方案:明確指定 PostgreSQL 適配器:

_respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
{
    TablesToIgnore = new Table[] { "__EFMigrationsHistory" },
    SchemasToInclude = new[] { "public" },
    DbAdapter = DbAdapter.Postgres  // 關鍵:明確指定適配器
});

學習重點:跨資料庫工具的預設設定可能不適用,需要明確配置。

問題 4:Dapper 欄位映射失敗

錯誤訊息

System.InvalidOperationException: The member CreatedAt of type Product cannot be used as a parameter value

原因:PostgreSQL 使用 snake_case 欄位命名,C# 使用 PascalCase 屬性命名,Dapper 無法自動映射。

解決方案:如前面「Dapper 欄位映射解決方案」章節所述,在 SQL 查詢中使用別名來解決命名慣例差異。

學習重點:這是解決測試失敗的最後關鍵,正確的欄位映射至關重要。

問題 5:時間依賴難以測試

問題:使用 DateTimeOffset.UtcNow 導致時間相關測試不穩定。

解決方案:注入 TimeProvider 抽象化時間依賴:

// 服務實作中注入 TimeProvider
public ProductService(
    IProductRepository productRepository,
    ICacheService cacheService,
    ILogger<ProductService> logger,
    TimeProvider timeProvider)  // 注入時間提供者
{
    _timeProvider = timeProvider;
}

// 使用注入的時間提供者
var now = _timeProvider.GetUtcNow();
var product = new Product
{
    CreatedAt = now,  // 而非 DateTimeOffset.UtcNow
    UpdatedAt = now
};

學習重點:現代 .NET Application 應該使用 TimeProvider 來提升可測試性。

測試成功的關鍵因素

經過這些修改,測試從完全失敗變成 100% 成功,關鍵在於:

  1. 正確的 Aspire 配置:讓框架自動處理而不手動干預
  2. 完整的資料庫管理:確保測試環境的資料庫正確創建
  3. 適當的工具配置:為不同資料庫選擇正確的適配器
  4. 精確的欄位映射:解決命名慣例差異
  5. 可測試的設計:使用依賴注入提升可測試性

1. 容器管理方式對比

Testcontainers 方式 (Day23)

public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private PostgreSqlContainer? _postgresContainer;
    private RedisContainer? _redisContainer;

    public async Task InitializeAsync()
    {
        // 手動建立和配置容器
        _postgresContainer = new PostgreSqlBuilder()
            .WithImage("postgres:15")
            .WithDatabase("productdb")
            .WithUsername("postgres")
            .WithPassword("postgres123")
            .WithCleanUp(true)
            .Build();

        _redisContainer = new RedisBuilder()
            .WithImage("redis:7")
            .WithCleanUp(true)
            .Build();

        // 手動啟動容器
        await _postgresContainer.StartAsync();
        await _redisContainer.StartAsync();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // 手動注入連線字串
        builder.ConfigureServices(services =>
        {
            // 移除原有的資料庫配置
            var dbContextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
            if (dbContextDescriptor != null)
                services.Remove(dbContextDescriptor);

            // 使用容器的連線字串
            services.AddDbContext<ApplicationDbContext>(options =>
            {
                options.UseNpgsql(_postgresContainer.GetConnectionString());
            });
        });
    }
}

.NET Aspire Testing 方式 (Day25)

public class AspireAppFixture : IAsyncLifetime
{
    public async Task InitializeAsync()
    {
        // 使用 AppHost 定義的架構
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Day25_AppHost>();

        // 自動啟動所有定義的資源
        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        // 自動建立 HTTP 客戶端
        _httpClient = _app.CreateHttpClient("day25-api");
    }
}

分析

  • Testcontainers:需要手動管理每個容器的生命週期,靈活但繁瑣
  • Aspire Testing:透過 AppHost 宣告式定義,自動管理所有資源

2. 設定檔管理對比

Testcontainers 設定注入

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.ConfigureAppConfiguration((context, config) =>
    {
        config.AddInMemoryCollection(new[]
        {
            new KeyValuePair<string, string>("ConnectionStrings:DefaultConnection", 
                _postgresContainer.GetConnectionString()),
            new KeyValuePair<string, string>("ConnectionStrings:Redis", 
                _redisContainer.GetConnectionString())
        });
    });
}

.NET Aspire Testing 設定管理

// AppHost 中的宣告式定義
var postgres = builder.AddPostgreSQL("postgres");
var postgresDb = postgres.AddDatabase("productdb");
var redis = builder.AddRedis("redis");

builder.AddProject<Projects.Day25_Api>("day25-api")
       .WithReference(postgresDb)
       .WithReference(redis);

分析

  • Testcontainers:需要手動處理配置注入和服務替換
  • .NET Aspire Testing:透過 AppHost 統一管理容器編排和服務配置

3. 測試執行效能比較

基於實際測試的執行時間觀察:

啟動時間

  • Testcontainers:約 15-20 秒 (容器冷啟動)
  • .NET Aspire Testing:約 20-25 秒 (包含 Aspire 基礎設施)

記憶體使用

  • Testcontainers:相對較低,只啟動必要的容器
  • .NET Aspire Testing:稍高,包含 Aspire 執行環境

測試隔離性

  • Testcontainers:透過 Collection Fixture 共享容器
  • .NET Aspire Testing:透過 Collection Fixture 共享整個分散式應用

4. 開發體驗差異

程式碼複雜度

// Testcontainers - 需要較多樣板程式碼
public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    // 容器宣告
    // 生命週期管理  
    // 服務配置覆寫
    // 連線字串注入
    // 約 80-100 行程式碼
}

// Aspire Testing - 更簡潔
public class AspireAppFixture : IAsyncLifetime
{
    // 使用 AppHost 定義
    // 自動資源管理
    // 約 30-40 行程式碼
}

除錯能力

  • Testcontainers:可以直接連線到容器進行除錯
  • .NET Aspire Testing:整合 Aspire Dashboard,提供更豐富的觀測能力

IDE 整合

  • Testcontainers:傳統的測試運行體驗
  • .NET Aspire Testing:與 Visual Studio 的 Aspire 工具整合

實務選擇建議

何時選擇 Testcontainers

  1. 非 .NET Aspire 專案

    // 傳統的 Web API + EF Core 專案
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseNpgsql(connectionString));
        }
    }
    
  2. 需要複雜容器配置

    var postgres = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .WithEnvironment("POSTGRES_INITDB_ARGS", "--auth-host=scram-sha-256")
        .WithBindMount(hostPath, "/docker-entrypoint-initdb.d")
        .WithCommand("postgres", "-c", "max_connections=200")
        .Build();
    
  3. 與其他技術架構整合

    • 需要測試 Java、Python 等其他語言的服務
    • 使用非 .NET 的資料庫或中介軟體

何時選擇 .NET Aspire Testing

  1. 已採用 .NET Aspire 的專案

    // 專案已有 AppHost 定義
    var builder = DistributedApplication.CreateBuilder(args);
    
    var database = builder.AddPostgreSQL("postgres")
                          .AddDatabase("productdb");
    
    var api = builder.AddProject<Projects.ProductApi>("product-api")
                    .WithReference(database);
    
  2. 雲原生應用開發

    • 微服務架構
    • 需要服務發現和編排
    • 重視可觀測性
  3. 團隊統一工具鏈

    • 開發和測試使用相同的工具
    • 降低學習成本
    • 提升維護效率

遷移策略建議

段階式遷移方法

  1. 第一階段:建立 AppHost

    • 建立最小可行的 AppHost 專案
    • 定義現有的外部依賴
  2. 第二階段:調整 API 專案

    • 加入 Aspire 套件參考
    • 調整服務註冊方式
  3. 第三階段:改寫測試

    • 建立 AspireAppFixture
    • 逐步遷移測試案例
  4. 第四階段:驗證與優化

    • 確保測試覆蓋率不降低
    • 優化測試執行效能

風險控制策略

// 可以在同一個專案中並存兩種測試方式
[Collection("Testcontainers Tests")]
public class LegacyProductTests : TestcontainersTestBase
{
    // 保留原有的測試
}

[Collection("Aspire Tests")]  
public class NewProductTests : IntegrationTestBase
{
    // 新的 Aspire 測試
}

團隊學習曲線考量

Testcontainers 學習曲線

  • 學習成本:中等,需要了解容器概念
  • 上手時間:1-2 週
  • 適合團隊:有容器經驗的團隊

.NET Aspire Testing 學習曲線

  • 學習成本:較高,需要了解 Aspire 生態系
  • 上手時間:2-4 週
  • 適合團隊:已使用或計劃使用 .NET Aspire 的團隊

總結與展望

透過這次完整的實作比較,我們可以得出以下結論:

技術成熟度

  • Testcontainers:相對成熟,社群支援完善
  • .NET Aspire Testing:較新的技術,持續演進中

適用場景

  • Testcontainers:適合多樣化的專案需求
  • .NET Aspire Testing:專為雲原生 .NET 應用設計

長期投資價值

考慮到微軟對 .NET Aspire 的重點投資,如果你的專案符合以下條件,建議採用 Aspire Testing:

  1. 新專案或大型重構
  2. 雲原生架構設計
  3. 團隊願意投資學習新技術
  4. 需要豐富的可觀測性

實務建議

不要為了技術而技術:選擇測試框架的首要考量應該是解決實際問題,而不是追求最新技術。

漸進式採用:可以在新功能或新模組中試用 Aspire Testing,累積經驗後再決定是否全面遷移。

保持測試品質:無論選擇哪種技術,都要確保測試的可靠性、可維護性和執行效率。

實作過程學到的經驗

這次完整的遷移實作,有幾個重要收穫:

  1. 測試失敗很有價值:每個錯誤都指向框架間的重要差異
  2. 逐步解決問題:從基礎設施到業務邏輯,一層一層解決
  3. 文件與實作的差距:實際專案會遇到文件沒提到的細節問題
  4. 命名慣例很重要:不同技術架構間的命名差異要特別注意
  5. 依賴注入很實用:適當的抽象化能大幅提升可測試性

這個實作過程證明了 .NET Aspire Testing 是 Testcontainers 的可行替代方案,特別是在 .NET Aspire 生態系統中。

今天學會了 .NET Aspire Testing 的具體使用方法,也掌握了實務專案中的技術選擇思考框架。這種分析比較的能力,在面對未來的技術選擇時會很有用。

參考資料

.NET Aspire Testing

Testcontainers vs Aspire

整合測試最佳實踐

範例程式碼:


這是「重啟挑戰:老派軟體工程師的測試修練」的第二十五天。明天會介紹 Day 26 – xUnit 升級指南:從 2.9.x 到 3.x 的轉換。


上一篇
Day 24 - .NET Aspire Testing 入門基礎介紹
下一篇
Day 26 – xUnit 升級指南:從 2.9.x 到 3.x 的轉換
系列文
重啟挑戰:老派軟體工程師的測試修練27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言