dotnet-testing-advanced-webapi-integration-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWebApi 整合測試
WebApi 整合测试
適用情境
适用场景
技能等級: 進階
所需前置知識: xUnit 基礎、ASP.NET Core 基礎、Testcontainers 基礎、Clean Architecture
預計學習時間: 60-90 分鐘
所需前置知識: xUnit 基礎、ASP.NET Core 基礎、Testcontainers 基礎、Clean Architecture
預計學習時間: 60-90 分鐘
技能等级: 进阶
所需前置知识: xUnit 基础、ASP.NET Core 基础、Testcontainers 基础、Clean Architecture
预计学习时间: 60-90 分钟
所需前置知识: xUnit 基础、ASP.NET Core 基础、Testcontainers 基础、Clean Architecture
预计学习时间: 60-90 分钟
學習目標
学习目标
完成本技能學習後,您將能夠:
- 建立完整的 WebApi 整合測試架構
- 使用 實作現代化異常處理
IExceptionHandler - 驗證 和
ProblemDetails標準格式ValidationProblemDetails - 使用 Flurl 簡化 HTTP 測試的 URL 建構
- 使用 AwesomeAssertions 進行精確的 HTTP 回應驗證
- 建立多容器 (PostgreSQL + Redis) 測試環境
完成本技能学习后,您将能够:
- 建立完整的 WebApi 整合测试架构
- 使用 实现现代化异常处理
IExceptionHandler - 验证 和
ProblemDetails标准格式ValidationProblemDetails - 使用 Flurl 简化 HTTP 测试的 URL 构建
- 使用 AwesomeAssertions 进行精确的 HTTP 响应验证
- 建立多容器 (PostgreSQL + Redis) 测试环境
核心概念
核心概念
IExceptionHandler - 現代化異常處理
IExceptionHandler - 现代化异常处理
ASP.NET Core 8+ 引入的 介面提供了比傳統 middleware 更優雅的錯誤處理方式:
IExceptionHandlercsharp
/// <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 ?? 500;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
private static ProblemDetails CreateProblemDetails(Exception exception)
{
return exception switch
{
KeyNotFoundException => new ProblemDetails
{
Type = "https://httpstatuses.com/404",
Title = "資源不存在",
Status = 404,
Detail = exception.Message
},
ArgumentException => new ProblemDetails
{
Type = "https://httpstatuses.com/400",
Title = "參數錯誤",
Status = 400,
Detail = exception.Message
},
_ => new ProblemDetails
{
Type = "https://httpstatuses.com/500",
Title = "內部伺服器錯誤",
Status = 500,
Detail = "發生未預期的錯誤"
}
};
}
}ASP.NET Core 8+ 引入的 接口提供了比传统 middleware 更优雅的错误处理方式:
IExceptionHandlercsharp
/// <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 ?? 500;
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
private static ProblemDetails CreateProblemDetails(Exception exception)
{
return exception switch
{
KeyNotFoundException => new ProblemDetails
{
Type = "https://httpstatuses.com/404",
Title = "资源不存在",
Status = 404,
Detail = exception.Message
},
ArgumentException => new ProblemDetails
{
Type = "https://httpstatuses.com/400",
Title = "参数错误",
Status = 400,
Detail = exception.Message
},
_ => new ProblemDetails
{
Type = "https://httpstatuses.com/500",
Title = "内部服务器错误",
Status = 500,
Detail = "发生未预期的错误"
}
};
}
}ProblemDetails 標準格式
ProblemDetails 标准格式
RFC 7807 定義的統一錯誤回應格式:
| 欄位 | 說明 |
|---|---|
| 問題類型的 URI |
| 簡短的錯誤描述 |
| HTTP 狀態碼 |
| 詳細的錯誤說明 |
| 發生問題的實例 URI |
RFC 7807 定义的统一错误响应格式:
| 字段 | 说明 |
|---|---|
| 问题类型的 URI |
| 简短的错误描述 |
| HTTP 状态码 |
| 详细的错误说明 |
| 发生问题的实例 URI |
ValidationProblemDetails - 驗證錯誤專用
ValidationProblemDetails - 验证错误专用
json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "輸入的資料包含驗證錯誤",
"errors": {
"Name": ["產品名稱不能為空"],
"Price": ["產品價格必須大於 0"]
}
}json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"detail": "输入的资料包含验证错误",
"errors": {
"Name": ["产品名称不能为空"],
"Price": ["产品价格必须大于 0"]
}
}FluentValidation 異常處理器
FluentValidation 异常处理器
FluentValidation 異常處理器實作 介面,專門處理 ,將驗證錯誤轉換為標準的 格式回應。處理器之間按照註冊順序執行,特定處理器(如 FluentValidation)必須在全域處理器之前註冊。
IExceptionHandlerValidationExceptionValidationProblemDetails📖 完整實作程式碼請參閱 references/exception-handler-details.md
FluentValidation 异常处理器实现 接口,专门处理 ,将验证错误转换为标准的 格式响应。处理器之间按照注册顺序执行,特定处理器(如 FluentValidation)必须在全局处理器之前注册。
IExceptionHandlerValidationExceptionValidationProblemDetails📖 完整实现代码请参阅 references/exception-handler-details.md
整合測試基礎設施
整合测试基础设施
TestWebApplicationFactory
TestWebApplicationFactory
csharp
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()
{
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("test_db")
.WithUsername("testuser")
.WithPassword("testpass")
.WithCleanUp(true)
.Build();
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
_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"
});
});
builder.ConfigureServices(services =>
{
// 替換 TimeProvider
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();
}
}csharp
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()
{
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("test_db")
.WithUsername("testuser")
.WithPassword("testpass")
.WithCleanUp(true)
.Build();
_redisContainer = new RedisBuilder()
.WithImage("redis:7-alpine")
.WithCleanUp(true)
.Build();
_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"
});
});
builder.ConfigureServices(services =>
{
// 替换 TimeProvider
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();
}
}Collection Fixture 模式
Collection Fixture 模式
csharp
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
public const string Name = "Integration Tests";
}csharp
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection : ICollectionFixture<TestWebApplicationFactory>
{
public const string Name = "Integration Tests";
}測試基底類別
测试基类
csharp
[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());
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
protected void ResetTime()
{
Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
protected void AdvanceTime(TimeSpan timeSpan)
{
Factory.TimeProvider.Advance(timeSpan);
}
}csharp
[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());
FlurlClient = new FlurlClient(HttpClient);
}
public virtual async Task InitializeAsync()
{
await DatabaseManager.InitializeDatabaseAsync();
}
public virtual async Task DisposeAsync()
{
await DatabaseManager.CleanDatabaseAsync();
FlurlClient.Dispose();
}
protected void ResetTime()
{
Factory.TimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
protected void AdvanceTime(TimeSpan timeSpan)
{
Factory.TimeProvider.Advance(timeSpan);
}
}Flurl 簡化 URL 建構
Flurl 简化 URL 构建
Flurl 提供流暢的 API 來建構複雜的 URL:
csharp
// 傳統方式
var url = $"/products?pageSize={pageSize}&page={page}&keyword={keyword}";
// 使用 Flurl
var url = "/products"
.SetQueryParam("pageSize", 5)
.SetQueryParam("page", 2)
.SetQueryParam("keyword", "特殊");Flurl 提供流畅的 API 来构建复杂的 URL:
csharp
// 传统方式
var url = $"/products?pageSize={pageSize}&page={page}&keyword={keyword}";
// 使用 Flurl
var url = "/products"
.SetQueryParam("pageSize", 5)
.SetQueryParam("page", 2)
.SetQueryParam("keyword", "特殊");測試範例
测试示例
成功建立產品測試
成功建立产品测试
csharp
[Fact]
public async Task CreateProduct_使用有效資料_應成功建立產品()
{
// Arrange
var request = new ProductCreateRequest { Name = "新產品", Price = 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);
});
}csharp
[Fact]
public async Task CreateProduct_使用有效资料_应成功建立产品()
{
// Arrange
var request = new ProductCreateRequest { Name = "新产品", Price = 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);
});
}驗證錯誤測試
验证错误测试
csharp
[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.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("產品名稱不能為空");
});
}csharp
[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.Errors.Should().ContainKey("Name");
problem.Errors["Name"].Should().Contain("产品名称不能为空");
});
}資源不存在測試
资源不存在测试
csharp
[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);
});
}csharp
[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);
});
}分頁查詢測試
分页查询测试
csharp
[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);
});
}csharp
[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);
});
}資料管理策略
数据管理策略
TestHelpers 設計
TestHelpers 设计
csharp
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 = Enumerable.Range(1, count)
.Select(i => SeedSpecificProductAsync(dbManager, $"產品 {i:D2}", i * 10.0m));
await Task.WhenAll(tasks);
}
}csharp
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 = Enumerable.Range(1, count)
.Select(i => SeedSpecificProductAsync(dbManager, $"产品 {i:D2}", i * 10.0m));
await Task.WhenAll(tasks);
}
}SQL 指令碼外部化
SQL 脚本外部化
text
tests/Integration/
└── SqlScripts/
└── Tables/
└── CreateProductsTable.sqltext
tests/Integration/
└── SqlScripts/
└── Tables/
└── CreateProductsTable.sql最佳實務
最佳实践
1. 測試結構設計
1. 测试结构设计
- 單一職責:每個測試專注於一個特定場景
- 3A 模式:清楚區分 Arrange、Act、Assert
- 清晰命名:方法名稱表達測試意圖
- 单一职责:每个测试专注于一个特定场景
- 3A 模式:清楚区分 Arrange、Act、Assert
- 清晰命名:方法名称表达测试意图
2. 錯誤處理驗證
2. 错误处理验证
- ValidationProblemDetails:驗證錯誤回應格式
- ProblemDetails:驗證業務異常回應
- HTTP 狀態碼:確認正確的狀態碼
- ValidationProblemDetails:验证错误响应格式
- ProblemDetails:验证业务异常响应
- HTTP 状态码:确认正确的状态码
3. 效能考量
3. 效能考量
- 容器共享:使用 Collection Fixture
- 資料清理:測試後清理資料,不重建容器
- 並行執行:確保測試獨立性
- 容器共享:使用 Collection Fixture
- 数据清理:测试后清理数据,不重建容器
- 并行执行:确保测试独立性
相依套件
依赖包
xml
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.0.0" />
<PackageReference Include="Testcontainers.Redis" Version="4.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Respawn" Version="6.2.1" />xml
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.0.0" />
<PackageReference Include="Testcontainers.Redis" Version="4.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Respawn" Version="6.2.1" />專案結構
项目结构
text
src/
├── Api/ # WebApi 層
├── Application/ # 應用服務層
├── Domain/ # 領域模型
└── Infrastructure/ # 基礎設施層
tests/
└── Integration/
├── Fixtures/
│ ├── TestWebApplicationFactory.cs
│ ├── IntegrationTestCollection.cs
│ └── IntegrationTestBase.cs
├── Handlers/
│ ├── GlobalExceptionHandler.cs
│ └── FluentValidationExceptionHandler.cs
├── Helpers/
│ ├── DatabaseManager.cs
│ └── TestHelpers.cs
├── SqlScripts/
│ └── Tables/
└── Controllers/
└── ProductsControllerTests.cstext
src/
├── Api/ # WebApi 层
├── Application/ # 应用服务层
├── Domain/ # 领域模型
└── Infrastructure/ # 基础设施层
tests/
└── Integration/
├── Fixtures/
│ ├── TestWebApplicationFactory.cs
│ ├── IntegrationTestCollection.cs
│ └── IntegrationTestBase.cs
├── Handlers/
│ ├── GlobalExceptionHandler.cs
│ └── FluentValidationExceptionHandler.cs
├── Helpers/
│ ├── DatabaseManager.cs
│ └── TestHelpers.cs
├── SqlScripts/
│ └── Tables/
└── Controllers/
└── ProductsControllerTests.cs參考資源
参考资源
原始文章
原始文章
本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
- Day 23 - 整合測試實戰:WebApi 服務的整合測試
本技能内容提炼自「老派软件工程师的测试修炼 - 30 天挑战」系列文章:
- Day 23 - 整合测试实战:WebApi 服务的整合测试