Day23 我們用 Testcontainers for .NET 建立了完整的產品管理 WebAPI 整合測試專案,展示了如何使用 PostgreSQL 和 Redis 容器測試真實的資料庫操作和快取機制。
Day24 認識了 .NET Aspire Testing 的基礎概念和基本用法,了解它如何簡化分散式應用的測試設定。
今天要將這兩個概念結合:把 Day23 的 Testcontainers 實作改寫為 .NET Aspire Testing。這不只是技術工具的替換,更是測試思維的轉變。
透過這個實戰練習,你將:
回顧一下 Day23 的專案結構:
Day23/ (Testcontainers)
├── src/
│ ├── Day23.Api/ # WebApi 層
│ ├── Day23.Application/ # 應用服務層
│ ├── Day23.Domain/ # 領域模型
│ └── Day23.Infrastructure/ # 基礎設施層
└── tests/
└── Day23.Tests.Integration/ # Testcontainers 整合測試
這個架構的核心特點:
現在要將它重構為 .NET Aspire 架構:
Day25/ (.NET Aspire)
├── src/
│ ├── Day25.Api/ # WebApi 層 (調整服務註冊)
│ ├── Day25.Application/ # 應用服務層 (相同)
│ ├── Day25.Domain/ # 領域模型 (相同)
│ └── Day25.Infrastructure/ # 基礎設施層 (相同)
├── Day25.AppHost/ # Aspire 編排專案
└── tests/
└── Day25.Tests.Integration/ # Aspire Testing 整合測試
主要變化:
重要的是,我們保持相同的技術架構組合:
唯一的變化是將 Testcontainers 替換為 .NET Aspire Testing。
由於我們要保持相同的測試覆蓋率,所有的領域模型和服務介面都保持不變。
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 需要注入 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
來控制時間,大幅提升可測試性。
在實作過程中,我們遇到了 PostgreSQL 資料庫欄位命名(snake_case)與 C# 屬性命名(PascalCase)的映射問題。有兩種解決方案:
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;
}
}
優點:簡單直接,不需要額外配置
缺點:每個查詢都需要手動添加別名,容易遺漏
建立自訂的型別映射來處理命名轉換:
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();
}
}
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)));
}
}
特性 | 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;
}
優點:
缺點:
歷史經驗分享
之前曾經使用過一個 NuGet Package: Dapper.FluentMap,可以透過 Map 設定檔的方式將類別的屬性對應到指定的資料庫欄位,但是作者在兩年前就封存了專案,也不再更新了。現在推薦使用 Dapper 內建的
SqlMapper.SetTypeMap
方法搭配CustomPropertyTypeMap
,這是官方支援且更靈活的標準作法。相比SqlMapper.AddTypeMap
,SetTypeMap
允許我們定義自訂的映射邏輯。
AppHost 專案是 .NET Aspire 的核心,它定義了整個應用的服務編排。與 Testcontainers 需要在測試程式碼中手動管理容器不同,AppHost 讓我們在一個地方集中定義所有服務的配置和相依關係:
<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>
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();
這個設定定義了:
使用 ContainerLifetime.Session
的重要性:
注意:如果使用
ContainerLifetime.Persistent
,容器會持續運行直到手動停止,這在測試環境中可能造成資源浪費和容器累積問題。相較之下,ContainerLifetime.Session
提供的特性:
- 持續生命週期:容器在應用停止後保持運行(僅適用於 Persistent 模式)
<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>
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 { }
主要變化:
AddNpgsqlDataSource()
, AddRedisClient()
現在是最關鍵的部分:將測試基礎設施從 Testcontainers 改為 .NET Aspire Testing。
<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>
這是從 TestWebApplicationFactory
到 AspireAppFixture
的關鍵轉換:
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");
}
}
延續 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
}
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
從 Testcontainers 移植到 Aspire Testing 需要做三個關鍵調整:
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。
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
// 其他測試案例請看範例專案原始碼
}
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");
}
}
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("無法取得連線字串");
}
}
實作過程中,為了確保 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");
}
}
為什麼需要這個驗證測試:
證明真實容器使用:檢查連線字串的動態埠(非預設的 5432 和 6379),證明 Aspire 用的是真實容器而非模擬服務
顯示容器資訊:測試輸出會顯示實際的連線字串:
驗證服務連線能力:實際連線到 PostgreSQL 並執行查詢,確保容器服務可用
等待機制展示:展示如何正確等待容器服務就緒,這是分散式測試的重要技巧
執行結果範例:
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 的重要差異。
錯誤訊息:
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 更智慧,但需要避免手動干預。
錯誤訊息:
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 會處理容器啟動等待,但應用程式層級的資料庫初始化仍需自己處理。
錯誤訊息:
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 // 關鍵:明確指定適配器
});
學習重點:跨資料庫工具的預設設定可能不適用,需要明確配置。
錯誤訊息:
System.InvalidOperationException: The member CreatedAt of type Product cannot be used as a parameter value
原因:PostgreSQL 使用 snake_case
欄位命名,C# 使用 PascalCase
屬性命名,Dapper 無法自動映射。
解決方案:如前面「Dapper 欄位映射解決方案」章節所述,在 SQL 查詢中使用別名來解決命名慣例差異。
學習重點:這是解決測試失敗的最後關鍵,正確的欄位映射至關重要。
問題:使用 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% 成功,關鍵在於:
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());
});
});
}
}
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");
}
}
分析:
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())
});
});
}
// 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 - 需要較多樣板程式碼
public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
// 容器宣告
// 生命週期管理
// 服務配置覆寫
// 連線字串注入
// 約 80-100 行程式碼
}
// Aspire Testing - 更簡潔
public class AspireAppFixture : IAsyncLifetime
{
// 使用 AppHost 定義
// 自動資源管理
// 約 30-40 行程式碼
}
非 .NET Aspire 專案
// 傳統的 Web API + EF Core 專案
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
}
}
需要複雜容器配置
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();
與其他技術架構整合
已採用 .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);
雲原生應用開發
團隊統一工具鏈
第一階段:建立 AppHost
第二階段:調整 API 專案
第三階段:改寫測試
第四階段:驗證與優化
// 可以在同一個專案中並存兩種測試方式
[Collection("Testcontainers Tests")]
public class LegacyProductTests : TestcontainersTestBase
{
// 保留原有的測試
}
[Collection("Aspire Tests")]
public class NewProductTests : IntegrationTestBase
{
// 新的 Aspire 測試
}
透過這次完整的實作比較,我們可以得出以下結論:
考慮到微軟對 .NET Aspire 的重點投資,如果你的專案符合以下條件,建議採用 Aspire Testing:
不要為了技術而技術:選擇測試框架的首要考量應該是解決實際問題,而不是追求最新技術。
漸進式採用:可以在新功能或新模組中試用 Aspire Testing,累積經驗後再決定是否全面遷移。
保持測試品質:無論選擇哪種技術,都要確保測試的可靠性、可維護性和執行效率。
這次完整的遷移實作,有幾個重要收穫:
這個實作過程證明了 .NET Aspire Testing 是 Testcontainers 的可行替代方案,特別是在 .NET Aspire 生態系統中。
今天學會了 .NET Aspire Testing 的具體使用方法,也掌握了實務專案中的技術選擇思考框架。這種分析比較的能力,在面對未來的技術選擇時會很有用。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十五天。明天會介紹 Day 26 – xUnit 升級指南:從 2.9.x 到 3.x 的轉換。