經過前 22 天的基礎學習,我們已經掌握了單元測試、xUnit 框架、測試替身、Mock 物件等核心概念。今天,我們要將這些知識整合起來,實作一個完整的 WebApi 整合測試。
整合測試不同於單元測試,它測試的是整個系統組件之間的協作,包括資料庫、快取、HTTP 請求等真實的外部相依性。這讓我們能夠驗證系統在真實環境下的行為。
本篇將示範完整的 WebApi 整合測試實作,內容涵蓋:
本次範例是一個簡單的產品管理 WebApi 服務,使用 Clean Architecture 設計:
Day23/
├── src/
│ ├── Day23.Api/ # WebApi 層
│ ├── Day23.Application/ # 應用服務層
│ ├── Day23.Domain/ # 領域模型
│ └── Day23.Infrastructure/ # 基礎設施層
└── tests/
└── Day23.Tests.Integration/ # 整合測試
我們的 Product 實體很簡潔,符合整潔架構的設計原則:
/// <summary>
/// 產品實體
/// </summary>
public class Product
{
/// <summary>
/// 產品唯一識別碼
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// 產品名稱
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 產品價格
/// </summary>
public decimal Price { get; set; }
/// <summary>
/// 建立時間
/// </summary>
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// 最後更新時間
/// </summary>
public DateTimeOffset UpdatedAt { get; set; }
}
應用層定義的服務介面設計得很實用:
/// <summary>
/// 產品服務介面
/// </summary>
public interface IProductService
{
/// <summary>
/// 建立產品
/// </summary>
Task<ProductResponse> CreateAsync(ProductCreateRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 根據 ID 取得產品
/// </summary>
Task<ProductResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// 查詢產品列表
/// </summary>
Task<PagedResult<ProductResponse>> QueryAsync(
string? keyword = null,
int page = 1,
int pageSize = 20,
string sort = "createdAt",
string direction = "desc",
CancellationToken cancellationToken = default);
/// <summary>
/// 更新產品
/// </summary>
Task UpdateAsync(Guid id, ProductUpdateRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// 刪除產品
/// </summary>
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
ASP.NET Core 8 引入的 IExceptionHandler
介面提供了比傳統 middleware 更優雅的錯誤處理方式:
統一的錯誤處理介面:所有異常處理器都實作同一個介面,提供一致的處理模式。
更好的可測試性:每個處理器都是獨立的服務,可以單獨進行單元測試。
型別安全:透過強型別的介面,避免了 middleware 中的型別轉換問題。
標準化回應格式:內建支援 ProblemDetails 標準,確保 API 錯誤回應的一致性。
傳統的異常處理 middleware 需要在一個方法中處理所有類型的異常,容易造成程式碼複雜且難以維護。IExceptionHandler
讓我們可以針對不同類型的異常建立專門的處理器:
// 傳統 middleware 方式 - 所有邏輯集中在一處
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
// 處理驗證異常
}
catch (KeyNotFoundException ex)
{
// 處理找不到資源異常
}
// ... 更多異常處理
}
// IExceptionHandler 方式 - 職責分離,更清晰
public class FluentValidationExceptionHandler : IExceptionHandler { }
public class GlobalExceptionHandler : IExceptionHandler { }
我們的 GlobalExceptionHandler
負責處理所有未被特定處理器處理的異常:
/// <summary>
/// 全域異常處理器
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "發生未處理的異常: {Message}", exception.Message);
var problemDetails = CreateProblemDetails(exception);
httpContext.Response.StatusCode = problemDetails.Status ?? (int)HttpStatusCode.InternalServerError;
httpContext.Response.ContentType = "application/problem+json";
var json = JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await httpContext.Response.WriteAsync(json, cancellationToken);
return true;
}
private static ProblemDetails CreateProblemDetails(Exception exception)
{
return exception switch
{
ArgumentException => new ProblemDetails
{
Type = "https://httpstatuses.com/400",
Title = "參數錯誤",
Status = (int)HttpStatusCode.BadRequest,
Detail = exception.Message
},
KeyNotFoundException => new ProblemDetails
{
Type = "https://httpstatuses.com/404",
Title = "資源不存在",
Status = (int)HttpStatusCode.NotFound,
Detail = exception.Message
},
UnauthorizedAccessException => new ProblemDetails
{
Type = "https://httpstatuses.com/401",
Title = "未授權",
Status = (int)HttpStatusCode.Unauthorized,
Detail = "您沒有權限執行此操作"
},
TimeoutException => new ProblemDetails
{
Type = "https://httpstatuses.com/408",
Title = "請求超時",
Status = (int)HttpStatusCode.RequestTimeout,
Detail = "操作執行超時,請稍後再試"
},
InvalidOperationException => new ProblemDetails
{
Type = "https://httpstatuses.com/422",
Title = "操作無效",
Status = (int)HttpStatusCode.UnprocessableEntity,
Detail = exception.Message
},
_ => new ProblemDetails
{
Type = "https://httpstatuses.com/500",
Title = "內部伺服器錯誤",
Status = (int)HttpStatusCode.InternalServerError,
Detail = "發生未預期的錯誤,請聯絡系統管理員"
}
};
}
}
在實作異常處理器之前,需要了解 ProblemDetails 類別。這個類別實作了 RFC 7807 標準,提供了統一的錯誤回應格式:
這種標準化的錯誤格式讓 API 消費者能夠一致地處理各種錯誤情況,提升了 API 的可用性和互操作性。
專門處理 FluentValidation 驗證錯誤的處理器,展示了如何針對特定異常類型進行處理:
/// <summary>
/// FluentValidation 專用異常處理器
/// </summary>
public class FluentValidationExceptionHandler : IExceptionHandler
{
private readonly ILogger<FluentValidationExceptionHandler> _logger;
public FluentValidationExceptionHandler(ILogger<FluentValidationExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not ValidationException validationException)
{
return false;
}
_logger.LogWarning(validationException, "驗證失敗: {Message}", validationException.Message);
var problemDetails = new ValidationProblemDetails
{
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
Title = "One or more validation errors occurred.",
Status = (int)HttpStatusCode.BadRequest,
Detail = "輸入的資料包含驗證錯誤,請檢查後重新提交。",
Instance = httpContext.Request.Path
};
foreach (var error in validationException.Errors)
{
var propertyName = error.PropertyName;
var errorMessage = error.ErrorMessage;
if (problemDetails.Errors.ContainsKey(propertyName))
{
var existingErrors = problemDetails.Errors[propertyName].ToList();
existingErrors.Add(errorMessage);
problemDetails.Errors[propertyName] = existingErrors.ToArray();
}
else
{
problemDetails.Errors.Add(propertyName, new[] { errorMessage });
}
}
httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
httpContext.Response.ContentType = "application/problem+json";
var json = JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await httpContext.Response.WriteAsync(json, cancellationToken);
return true;
}
}
在 Program.cs
中的正確配置方式,註冊順序決定了處理的優先順序:
// Add ProblemDetails
builder.Services.AddProblemDetails();
// Add Exception Handler - 順序很重要!
builder.Services.AddExceptionHandler<FluentValidationExceptionHandler>();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
為什麼 FluentValidationExceptionHandler 必須先註冊?
異常處理器會按照註冊順序依次嘗試處理異常。FluentValidationExceptionHandler
只處理 ValidationException
,如果無法處理就回傳 false
,讓下一個處理器接手。GlobalExceptionHandler
會處理所有異常並回傳 true
,因此必須放在最後作為後備處理器。
middleware pipeline 中的正確位置:
// Use Exception Handler
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
ValidationProblemDetails
是 ASP.NET Core 專門為驗證錯誤設計的標準化回應格式:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "輸入的資料包含驗證錯誤,請檢查後重新提交。",
"instance": "/products",
"errors": {
"Name": ["產品名稱不能為空"],
"Price": ["產品價格必須大於 0"]
}
}
這種格式的優勢:
延續 Day21、Day22 的經驗,我們使用 Collection Fixture 來共享昂貴的容器資源:
/// <summary>
/// 整合測試集合定義
/// </summary>
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
/// <summary>
/// 集合名稱常數
/// </summary>
public const string Name = "Integration Tests";
// 這個類別不需要任何實作
// 它只是用來定義 Collection Fixture
}
設計一個實用的基底類別,提供所有整合測試需要的功能:
/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient HttpClient;
protected readonly DatabaseManager DatabaseManager;
protected readonly IFlurlClient FlurlClient;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
HttpClient = factory.CreateClient();
DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());
// 設定 Flurl 用戶端
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
// 初始化資料庫結構
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
// 清理資料庫資料
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
/// <summary>
/// 重設時間為測試開始時間
/// </summary>
protected void ResetTime()
{
Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
/// <summary>
/// 前進時間
/// </summary>
protected void AdvanceTime(TimeSpan timeSpan)
{
Factory.TimeProvider.Advance(timeSpan);
}
/// <summary>
/// 設定特定時間
/// </summary>
protected void SetTime(DateTimeOffset time)
{
Factory.TimeProvider.SetUtcNow(time);
}
}
跟隨 Day21 的最佳實務,DatabaseManager 將 SQL 指令碼外部化管理:
/// <summary>
/// 確保資料表存在,使用外部 SQL 指令碼建立
/// 實作第 Day 21 介紹的 SQL 指令碼外部化策略
/// </summary>
private async Task EnsureTablesExistAsync(NpgsqlConnection connection)
{
var scriptDirectory = Path.Combine(AppContext.BaseDirectory, "SqlScripts");
if (!Directory.Exists(scriptDirectory))
{
throw new DirectoryNotFoundException($"SQL 指令碼目錄不存在: {scriptDirectory}");
}
// 按照依賴順序執行表格建立腳本
var orderedScripts = new[]
{
"Tables/CreateProductsTable.sql"
};
foreach (var scriptPath in orderedScripts)
{
var fullPath = Path.Combine(scriptDirectory, scriptPath);
if (File.Exists(fullPath))
{
var script = await File.ReadAllTextAsync(fullPath);
await using var command = new NpgsqlCommand(script, connection);
await command.ExecuteNonQueryAsync();
}
else
{
throw new FileNotFoundException($"SQL 指令碼檔案不存在: {fullPath}");
}
}
}
對應的 SQL 檔案會建立完整的資料表結構和觸發器:
-- Products 資料表
CREATE TABLE IF NOT EXISTS products
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 建立更新時間觸發器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 移除現有觸發器(如果存在)
DROP TRIGGER IF EXISTS update_products_updated_at ON products;
-- 建立觸發器
CREATE TRIGGER update_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
在 Clean Architecture 中,Application 層的服務是整合測試的核心焦點。我們的 IProductService
定義了標準的 CRUD 操作,但整合測試的價值在於驗證這些操作在真實環境下的運作方式。
整合測試中我們重點關注:
在整合測試中,我們需要確保測試環境能夠模擬真實的運行環境。專案使用 Testcontainers 來提供獨立的 PostgreSQL 和 Redis 實例:
public class IntegrationTestBase : IAsyncLifetime
{
protected readonly PostgreSqlContainer PostgreSqlContainer;
protected readonly RedisContainer RedisContainer;
protected HttpClient HttpClient = null!;
protected WebApplicationFactory<Program> Factory = null!;
public IntegrationTestBase()
{
PostgreSqlContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test123")
.Build();
RedisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
}
}
[Fact]
public async Task CreateProduct_使用有效資料_應成功建立產品()
{
// Arrange
var request = TestHelpers.CreateProductRequest("新產品", 299.99m);
// Act
var response = await HttpClient.PostAsJsonAsync("/products", request);
// Assert
response.Should().Be201Created()
.And.Satisfy<ProductResponse>(product =>
{
product.Id.Should().NotBeEmpty();
product.Name.Should().Be("新產品");
product.Price.Should().Be(299.99m);
product.CreatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
product.UpdatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
});
}
這個測試展現了整合測試的幾個重要特點:
[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest
{
Name = "",
Price = 100.00m
};
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
problem.Title.Should().Be("One or more validation errors occurred.");
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
});
}
[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.Items.Should().HaveCount(5);
});
}
整合測試的成功關鍵在於建立可靠且高效的測試基礎設施。專案使用 Collection Fixture 模式來共享 Testcontainers:
/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient HttpClient;
protected readonly DatabaseManager DatabaseManager;
protected readonly IFlurlClient FlurlClient;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
HttpClient = factory.CreateClient();
DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());
// 設定 Flurl 用戶端
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
// 初始化資料庫結構
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
// 清理資料庫資料
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
}
在整合測試中,AwesomeAssertions
提供了豐富的斷言方法,讓我們能夠精確驗證 HTTP 回應:
// 驗證成功回應和資料結構
response.Should().Be200Ok()
.And.Satisfy<PagedResult<ProductResponse>>(result =>
{
result.Total.Should().Be(15);
result.Items.Should().HaveCount(5);
result.Items.Should().AllSatisfy(product =>
{
product.Id.Should().NotBeEmpty();
product.Name.Should().NotBeNullOrEmpty();
product.Price.Should().BeGreaterThan(0);
});
});
// 驗證錯誤回應的詳細結構
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
});
整合測試需要有效的測試資料管理。專案使用 TestHelpers
提供一致的測試資料:
public static class TestHelpers
{
public static ProductCreateRequest CreateProductRequest(
string name = "測試產品",
decimal price = 100.00m)
{
return new ProductCreateRequest
{
Name = name,
Price = price
};
}
public static async Task SeedProductsAsync(DatabaseManager dbManager, int count)
{
var tasks = new List<Task>();
for (var i = 1; i <= count; i++)
{
tasks.Add(SeedSpecificProductAsync(dbManager, $"產品 {i:D2}", i * 10.0m));
}
await Task.WhenAll(tasks);
}
}
使用 Collection Fixture 模式有效管理 Testcontainers 的生命週期:
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
public const string Name = "Integration Tests";
}
這種設計確保:
public class IntegrationTestBase : IAsyncLifetime
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient HttpClient;
protected readonly DatabaseManager DatabaseManager;
protected readonly IFlurlClient FlurlClient;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
HttpClient = factory.CreateClient();
DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());
// 設定 Flurl 用戶端
FlurlClient = new FlurlClient(HttpClient);
}
}
// 每個測試前初始化資料庫結構
public async Task InitializeAsync()
{
await DatabaseManager.InitializeDatabaseAsync();
}
// 使用 TestHelpers 建立一致的測試資料
var request = TestHelpers.CreateProductRequest("測試產品", 199.99m);
整合測試必須驗證應用程式的錯誤處理機制:
這種整合測試方法確保了我們能夠在真實環境中驗證應用程式的行為,同時保持測試的可維護性和執行效率。
專案的 TestWebApplicationFactory
負責管理測試環境的容器和服務配置:
public class TestWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private PostgreSqlContainer? _postgresContainer;
private RedisContainer? _redisContainer;
private FakeTimeProvider? _timeProvider;
public PostgreSqlContainer PostgresContainer => _postgresContainer
?? throw new InvalidOperationException("PostgreSQL container 尚未初始化");
public RedisContainer RedisContainer => _redisContainer
?? throw new InvalidOperationException("Redis container 尚未初始化");
public FakeTimeProvider TimeProvider => _timeProvider
?? throw new InvalidOperationException("TimeProvider 尚未初始化");
public async Task InitializeAsync()
{
// 建立 PostgreSQL container
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("day23_test")
.WithUsername("testuser")
.WithPassword("testpass")
.WithCleanUp(true)
.Build();
// 建立 Redis container
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
// 建立 FakeTimeProvider
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
// 啟動容器
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration(config =>
{
// 移除現有的設定來源
config.Sources.Clear();
// 添加測試專用設定
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = PostgresContainer.GetConnectionString(),
["ConnectionStrings:Redis"] = RedisContainer.GetConnectionString(),
["Logging:LogLevel:Default"] = "Warning",
["Logging:LogLevel:System"] = "Warning",
["Logging:LogLevel:Microsoft"] = "Warning"
});
});
builder.ConfigureServices(services =>
{
// 替換 TimeProvider 為 FakeTimeProvider
services.Remove(services.Single(d => d.ServiceType == typeof(TimeProvider)));
services.AddSingleton<TimeProvider>(TimeProvider);
});
builder.UseEnvironment("Testing");
}
public new async Task DisposeAsync()
{
if (_postgresContainer != null)
{
await _postgresContainer.DisposeAsync();
}
if (_redisContainer != null)
{
await _redisContainer.DisposeAsync();
}
await base.DisposeAsync();
}
}
這個 TestWebApplicationFactory
的設計重點:
ConfigureWebHost
中完全控制測試環境的配置增強的測試基底類別提供更多功能:
/// <summary>
/// 整合測試基底類別 - 使用 Collection Fixture 共享容器
/// </summary>
[Collection("Integration Tests")]
public abstract class IntegrationTestBase : IAsyncLifetime
{
protected readonly TestWebApplicationFactory Factory;
protected readonly HttpClient HttpClient;
protected readonly DatabaseManager DatabaseManager;
protected readonly IFlurlClient FlurlClient;
protected IntegrationTestBase(TestWebApplicationFactory factory)
{
Factory = factory;
HttpClient = factory.CreateClient();
DatabaseManager = new DatabaseManager(factory.PostgresContainer.GetConnectionString());
// 設定 Flurl 用戶端
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
// 初始化資料庫結構
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
// 清理資料庫資料
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
/// <summary>
/// 重設時間為測試開始時間
/// </summary>
protected void ResetTime()
{
Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
/// <summary>
/// 前進時間
/// </summary>
protected void AdvanceTime(TimeSpan timeSpan)
{
Factory.TimeProvider.Advance(timeSpan);
}
/// <summary>
/// 設定特定時間
/// </summary>
protected void SetTime(DateTimeOffset time)
{
Factory.TimeProvider.SetUtcNow(time);
}
}
跟隨 Day21 建立的最佳實務,將 SQL 指令碼外部化:
tests/Day23.Tests.Integration/
└── SqlScripts/
└── Tables/
└── CreateProductsTable.sql
-- CreateProductsTable.sql
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 建立更新時間觸發器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS update_products_updated_at ON products;
CREATE TRIGGER update_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
DatabaseManager 會自動載入這些指令碼:
/// <summary>
/// 確保資料表存在,使用外部 SQL 指令碼建立
/// 實作第 Day 21 介紹的 SQL 指令碼外部化策略
/// </summary>
private async Task EnsureTablesExistAsync(NpgsqlConnection connection)
{
var scriptDirectory = Path.Combine(AppContext.BaseDirectory, "SqlScripts");
if (!Directory.Exists(scriptDirectory))
{
throw new DirectoryNotFoundException($"SQL 指令碼目錄不存在: {scriptDirectory}");
}
// 按照依賴順序執行表格建立腳本
var orderedScripts = new[]
{
"Tables/CreateProductsTable.sql"
};
foreach (var scriptPath in orderedScripts)
{
var fullPath = Path.Combine(scriptDirectory, scriptPath);
if (File.Exists(fullPath))
{
var script = await File.ReadAllTextAsync(fullPath);
await using var command = new NpgsqlCommand(script, connection);
await command.ExecuteNonQueryAsync();
}
else
{
throw new FileNotFoundException($"SQL 指令碼檔案不存在: {fullPath}");
}
}
}
Flurl 是一個強大的 HTTP 客戶端函式庫,在整合測試中特別有用。它提供了流暢的 API 來建構複雜的 URL 和查詢參數,讓測試程式碼更加簡潔和可讀。
傳統的查詢參數建構方式容易出錯,Flurl 提供了型別安全的方式:
[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);
});
}
[Fact]
public async Task GetProducts_使用搜尋參數_應回傳符合條件的產品()
{
// Arrange
await TestHelpers.SeedProductsAsync(DatabaseManager, 5);
await TestHelpers.SeedSpecificProductAsync(DatabaseManager, "特殊產品", 199.99m);
// Act - 使用 Flurl 建構複雜查詢
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);
});
}
整合測試的異常處理驗證需要涵蓋完整的錯誤處理流程,從異常拋出到最終的 HTTP 回應格式。我們的測試策略包括:
不同類型錯誤回應的驗證:確保各種異常都能正確轉換為對應的 HTTP 狀態碼和 ProblemDetails 結構。
ProblemDetails 結構完整性測試:驗證錯誤回應包含所有必要的標準欄位。
ValidationProblemDetails 多欄位錯誤驗證:測試複雜的驗證場景,確保所有驗證錯誤都能正確回傳。
使用實際專案中的測試案例,驗證 GlobalExceptionHandler
的行為:
[Fact]
public async Task GetById_當產品不存在_應回傳404且包含ProblemDetails()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var response = await HttpClient.GetAsync($"/Products/{nonExistentId}");
// Assert
response.Should().Be404NotFound()
.And.Satisfy<ProblemDetails>(problem =>
{
problem.Type.Should().Be("https://httpstatuses.com/404");
problem.Title.Should().Be("產品不存在");
problem.Status.Should().Be(404);
problem.Detail.Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
});
}
這個測試展示了幾個重要的驗證重點:
Be404NotFound()
確保回傳正確的狀態碼Satisfy<ProblemDetails>
驗證錯誤回應的完整結構另一個測試案例驗證刪除不存在產品的情境:
[Fact]
public async Task Delete_當產品不存在_應回傳404且包含ProblemDetails()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var response = await HttpClient.DeleteAsync($"/Products/{nonExistentId}");
// Assert
response.Should().Be404NotFound();
var content = await response.Content.ReadAsStringAsync();
var problemDetails = JsonSerializer.Deserialize<JsonElement>(content);
// 檢查 ProblemDetails 結構
problemDetails.GetProperty("type").GetString().Should().Be("https://httpstatuses.com/404");
problemDetails.GetProperty("title").GetString().Should().Be("產品不存在");
problemDetails.GetProperty("status").GetInt32().Should().Be(404);
problemDetails.GetProperty("detail").GetString().Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
}
驗證單一欄位的驗證失敗情境:
[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest
{
Name = "",
Price = 100.00m
};
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
problem.Title.Should().Be("One or more validation errors occurred.");
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
});
}
測試多個欄位同時發生驗證錯誤的情境:
[Fact]
public async Task CreateProduct_當產品名稱和價格都無效_應回傳400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest
{
Name = "",
Price = -10.00m
};
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors.Should().ContainKey("Price");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
problem.Errors["Price"].Should().Contain("產品價格必須大於 0");
});
}
測試產品名稱長度限制的情境:
[Fact]
public async Task CreateProduct_當產品名稱超過長度限制_應回傳400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest
{
Name = new string('A', 201), // 超過 200 字元限制
Price = 100.00m
};
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱長度不能超過 200 個字元");
});
}
清楚的測試命名:測試方法名稱要能清楚表達測試情境和期望結果
CreateProduct_當產品名稱為空_應回傳400BadRequest()
GetById_當產品不存在_應回傳404且包含ProblemDetails()
3A 模式的嚴格遵循:每個測試都要有清楚的 Arrange、Act、Assert 區段
// Arrange - 準備測試資料
var invalidRequest = new ProductCreateRequest { Name = "", Price = 100.00m };
// Act - 執行被測試的動作
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert - 驗證結果
response.Should().Be400BadRequest();
避免過度詳細的錯誤訊息:在 GlobalExceptionHandler 中要平衡資訊完整性和安全性
日誌記錄策略:確保異常處理器會記錄適當的日誌等級
_logger.LogError(exception, "發生未處理的異常: {Message}", exception.Message);
_logger.LogWarning(validationException, "驗證失敗: {Message}", validationException.Message);
回應時間監控:整合測試中要確保錯誤處理不會顯著影響回應時間
這種完整的異常處理測試策略確保了 API 在各種錯誤情境下都能提供一致、標準化的回應,提升了整個系統的穩定性和使用者體驗。
讓我們檢視專案中真實存在的整合測試:
以下是專案中的真實測試程式碼案例:
[Fact]
public async Task CreateProduct_使用有效資料_應成功建立產品()
{
// Arrange
var request = TestHelpers.CreateProductRequest("新產品", 299.99m);
// Act
var response = await HttpClient.PostAsJsonAsync("/products", request);
// Assert
response.Should().Be201Created()
.And.Satisfy<ProductResponse>(product =>
{
product.Id.Should().NotBeEmpty();
product.Name.Should().Be("新產品");
product.Price.Should().Be(299.99m);
product.CreatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
product.UpdatedAt.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
});
}
[Fact]
public async Task CreateProduct_當產品名稱為空_應回傳400BadRequest()
{
// Arrange
var invalidRequest = new ProductCreateRequest
{
Name = "",
Price = 100.00m
};
// Act
var response = await HttpClient.PostAsJsonAsync("/products", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
problem.Title.Should().Be("One or more validation errors occurred.");
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
});
}
[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 GetById_當產品不存在_應回傳404且包含ProblemDetails()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var response = await HttpClient.GetAsync($"/Products/{nonExistentId}");
// Assert
response.Should().Be404NotFound();
var content = await response.Content.ReadAsStringAsync();
var problemDetails = JsonSerializer.Deserialize<JsonElement>(content);
// 檢查 ProblemDetails 結構
problemDetails.GetProperty("type").GetString().Should().Be("https://httpstatuses.com/404");
problemDetails.GetProperty("title").GetString().Should().Be("產品不存在");
problemDetails.GetProperty("status").GetInt32().Should().Be(404);
problemDetails.GetProperty("detail").GetString().Should().Contain($"找不到 ID 為 {nonExistentId} 的產品");
}
[Fact]
public async Task GetHealth_應回傳200OK()
{
// Act
var response = await HttpClient.GetAsync("/health");
// Assert
response.Should().Be200Ok();
}
在設定 Testcontainers 時,要確保容器按正確順序啟動:
// 確保容器完全啟動
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();
每個測試都應該有乾淨的起始狀態:
public async Task DisposeAsync()
{
// 清理資料庫資料
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
專案的 TestWebApplicationFactory 已經處理了容器的正確啟動順序:
public async Task InitializeAsync()
{
// 建立並啟動容器
await _postgresContainer.StartAsync();
await _redisContainer.StartAsync();
}
專案使用 Respawner 來確保測試資料隔離:
/// <summary>
/// 清理資料庫資料
/// </summary>
public async Task CleanDatabaseAsync()
{
if (_respawner == null)
{
throw new InvalidOperationException("Respawner 尚未初始化,請先呼叫 InitializeDatabaseAsync");
}
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
await _respawner.ResetAsync(connection);
}
### 記憶體洩漏防範
專案中的 IntegrationTestBase 已經正確處理資源釋放:
```csharp
public virtual async Task DisposeAsync()
{
// 清理資料庫資料
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
使用實際專案中的 TestHelpers 類別:
/// <summary>
/// 驗證產品回應
/// </summary>
public static void AssertProductResponse(
ProductResponse response,
string expectedName,
decimal expectedPrice,
Guid? expectedId = null)
{
response.Should().NotBeNull();
response.Name.Should().Be(expectedName);
response.Price.Should().Be(expectedPrice);
response.CreatedAt.Should().BeAfter(DateTimeOffset.MinValue);
response.UpdatedAt.Should().BeAfter(DateTimeOffset.MinValue);
if (expectedId.HasValue)
{
response.Id.Should().Be(expectedId.Value);
}
else
{
response.Id.Should().NotBe(Guid.Empty);
}
}
專案使用 TestWebApplicationFactory 統一管理測試環境配置,確保測試環境的一致性和可重複性。
在測試失敗時,有效的診斷策略能幫助快速定位問題:
建立測試效能的監控機制:
在整合測試中,監控測試執行效能對於確保測試品質很重要:
Stopwatch
測量關鍵操作的執行時間GC.GetTotalMemory()
檢查記憶體使用狀況專案中實際的健康檢查測試範例:
[Fact]
public async Task Get_Health_應回傳200狀態碼()
{
// Arrange
// (無需特別準備)
// Act
var response = await HttpClient.GetAsync("/health");
// Assert
response.Should().Be200Ok();
}
使用 AwesomeAssertions 的 Satisfy
方法可以讓複雜物件的驗證更加清晰:
response.Should().Be200Ok()
.And.Satisfy<PagedResult<ProductResponse>>(result =>
{
result.Total.Should().Be(15);
result.Items.Should().AllSatisfy(product =>
{
product.Id.Should().NotBeEmpty();
product.Name.Should().NotBeNullOrEmpty();
product.Price.Should().BeGreaterThan(0);
});
});
今天我們完成了 Clean Architecture 的整合測試實戰,從 ASP.NET Core WebApi 到複雜的錯誤處理機制,建立了完整的整合測試技能。
統一錯誤處理:我們實作了 ASP.NET Core 9 的 IExceptionHandler
整合測試,確保全域例外處理能正確回傳結構化的錯誤回應。透過 ProblemDetails
標準,讓 API 錯誤處理更加一致和專業。
模型驗證整合:ValidationProblemDetails
的整合測試涵蓋了各種驗證失敗情境,從空值檢查到複雜的業務規則驗證,確保 API 能提供清楚的錯誤訊息。
Testcontainers 與 Clean Architecture:我們建立了支援 PostgreSQL 和 Redis 的完整測試環境,使用 Collection Fixture 模式確保測試效率。測試涵蓋了從 Controller 到 Repository 的完整資料流,驗證了各層級間的正確互動。
AwesomeAssertions 的實戰應用:透過豐富的斷言方法,我們能精確驗證 HTTP 回應的結構和內容。從狀態碼檢查到複雜物件驗證,提供了完整的測試覆蓋。
資料隔離與清理:每個測試都有乾淨的起始狀態,透過 DatabaseManager
確保測試間的獨立性。這種方法比共享資料更可靠,避免了測試間的相互干擾。
TestHelpers 設計模式:統一的測試資料建立方法,讓測試程式碼更加簡潔和可維護。這是整合測試可讀性的關鍵。
TimeProvider 整合:透過可控的時間提供者,確保測試結果的可預測性。這在驗證時間戳記、快取 TTL 等場景中特別重要。
整合測試雖然比單元測試複雜,但它提供了最接近真實環境的驗證。這種信心是單元測試無法給予的,也是高品質軟體不可或缺的一環。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第二十三天。明天會介紹 Day 24 – .NET Aspire Test Project 概論與配置。