# YARP Gateway 测试文档 ## 概述 本文档记录了 YARP Gateway 项目的测试策略、测试模式和最佳实践。 --- ## 1. 测试框架 ### 1.1 当前测试状态 **项目当前没有专门的测试目录或测试项目。** 检查项目结构: ``` fengling-gateway/ ├── src/ # 源代码 │ └── YarpGateway.csproj # 主项目 ├── .planning/ └── (无 tests/ 或 test/ 目录) ``` 检查 `.csproj` 文件确认无测试框架依赖: ```xml ``` **结论**:项目目前处于开发阶段,尚未建立测试基础设施。 ### 1.2 推荐测试框架 基于项目技术栈,推荐以下测试框架: | 框架 | 用途 | NuGet 包 | |------|------|----------| | xUnit | 单元测试框架 | `xunit` | | Moq | Mock 框架 | `Moq` | | FluentAssertions | 断言库 | `FluentAssertions` | | Microsoft.NET.Test.Sdk | 测试 SDK | `Microsoft.NET.Test.Sdk` | | Testcontainers | 集成测试容器 | `Testcontainers.PostgreSql`, `Testcontainers.Redis` | --- ## 2. 推荐测试结构 ### 2.1 测试项目组织 建议创建独立的测试项目: ``` tests/ ├── YarpGateway.UnitTests/ # 单元测试 │ ├── Services/ │ │ ├── RouteCacheTests.cs │ │ └── RedisConnectionManagerTests.cs │ ├── Middleware/ │ │ ├── JwtTransformMiddlewareTests.cs │ │ └── TenantRoutingMiddlewareTests.cs │ └── Controllers/ │ └── GatewayConfigControllerTests.cs │ ├── YarpGateway.IntegrationTests/ # 集成测试 │ ├── GatewayEndpointsTests.cs │ └── DatabaseTests.cs │ └── YarpGateway.LoadTests/ # 负载测试(可选) └── RoutePerformanceTests.cs ``` ### 2.2 测试命名约定 ```csharp // 命名格式:[被测类]Tests public class RouteCacheTests { } // 方法命名格式:[方法名]_[场景]_[期望结果] [Fact] public async Task InitializeAsync_WithValidData_LoadsRoutesFromDatabase() { } [Fact] public async Task GetRoute_WithNonexistentTenant_ReturnsNull() { } [Fact] public async Task ReloadAsync_WhenCalled_RefreshesCache() { } ``` --- ## 3. 单元测试模式 ### 3.1 服务层测试示例 ```csharp // RouteCacheTests.cs using Xunit; using Moq; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; public class RouteCacheTests { private readonly Mock> _mockDbContextFactory; private readonly Mock> _mockLogger; private readonly RouteCache _sut; // System Under Test public RouteCacheTests() { _mockDbContextFactory = new Mock>(); _mockLogger = new Mock>(); _sut = new RouteCache(_mockDbContextFactory.Object, _mockLogger.Object); } [Fact] public async Task InitializeAsync_ShouldLoadRoutesFromDatabase() { // Arrange var routes = new List { new() { Id = 1, ServiceName = "user-service", ClusterId = "user-cluster", IsGlobal = true } }; var mockDbSet = CreateMockDbSet(routes); var mockContext = new Mock(); mockContext.Setup(c => c.TenantRoutes).Returns(mockDbSet.Object); _mockDbContextFactory .Setup(f => f.CreateDbContext()) .Returns(mockContext.Object); // Act await _sut.InitializeAsync(); // Assert var result = _sut.GetRoute("tenant1", "user-service"); result.Should().NotBeNull(); result!.ClusterId.Should().Be("user-cluster"); } [Fact] public async Task GetRoute_WhenTenantRouteExists_ReturnsTenantRoute() { // Arrange - 设置租户专用路由 // ... // Act var result = _sut.GetRoute("tenant1", "service1"); // Assert result.Should().NotBeNull(); result!.IsGlobal.Should().BeFalse(); } [Fact] public async Task GetRoute_WhenNoTenantRouteButGlobalExists_ReturnsGlobalRoute() { // Arrange // ... // Act var result = _sut.GetRoute("tenant-without-route", "global-service"); // Assert result.Should().NotBeNull(); result!.IsGlobal.Should().BeTrue(); } // 辅助方法:创建模拟 DbSet private Mock> CreateMockDbSet(List data) where T : class { var queryable = data.AsQueryable(); var mockSet = new Mock>(); mockSet.As>().Setup(m => m.Provider).Returns(queryable.Provider); mockSet.As>().Setup(m => m.Expression).Returns(queryable.Expression); mockSet.As>().Setup(m => m.ElementType).Returns(queryable.ElementType); mockSet.As>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator()); return mockSet; } } ``` ### 3.2 中间件测试示例 ```csharp // TenantRoutingMiddlewareTests.cs using Xunit; using Moq; using FluentAssertions; using Microsoft.AspNetCore.Http; public class TenantRoutingMiddlewareTests { private readonly Mock _mockNext; private readonly Mock _mockRouteCache; private readonly Mock> _mockLogger; private readonly TenantRoutingMiddleware _sut; public TenantRoutingMiddlewareTests() { _mockNext = new Mock(); _mockRouteCache = new Mock(); _mockLogger = new Mock>(); _sut = new TenantRoutingMiddleware(_mockNext.Object, _mockRouteCache.Object, _mockLogger.Object); } [Fact] public async Task InvokeAsync_WithoutTenantHeader_CallsNextWithoutProcessing() { // Arrange var context = new DefaultHttpContext(); context.Request.Path = "/api/user-service/users"; // Act await _sut.InvokeAsync(context); // Assert _mockNext.Verify(n => n(context), Times.Once); _mockRouteCache.Verify(r => r.GetRoute(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task InvokeAsync_WithValidTenantAndRoute_SetsDynamicClusterId() { // Arrange var context = new DefaultHttpContext(); context.Request.Path = "/api/order-service/orders"; context.Request.Headers["X-Tenant-Id"] = "tenant-123"; var routeInfo = new RouteInfo { ClusterId = "order-cluster", IsGlobal = false }; _mockRouteCache .Setup(r => r.GetRoute("tenant-123", "order-service")) .Returns(routeInfo); // Act await _sut.InvokeAsync(context); // Assert context.Items["DynamicClusterId"].Should().Be("order-cluster"); _mockNext.Verify(n => n(context), Times.Once); } [Fact] public async Task InvokeAsync_WithNoMatchingRoute_CallsNextWithoutClusterId() { // Arrange var context = new DefaultHttpContext(); context.Request.Path = "/api/unknown-service/data"; context.Request.Headers["X-Tenant-Id"] = "tenant-123"; _mockRouteCache .Setup(r => r.GetRoute("tenant-123", "unknown-service")) .Returns((RouteInfo?)null); // Act await _sut.InvokeAsync(context); // Assert context.Items.ContainsKey("DynamicClusterId").Should().BeFalse(); _mockNext.Verify(n => n(context), Times.Once); } } ``` ### 3.3 控制器测试示例 ```csharp // GatewayConfigControllerTests.cs using Xunit; using Moq; using FluentAssertions; using Microsoft.AspNetCore.Mvc; public class GatewayConfigControllerTests { private readonly Mock> _mockDbFactory; private readonly Mock _mockRouteProvider; private readonly Mock _mockClusterProvider; private readonly Mock _mockRouteCache; private readonly GatewayConfigController _sut; public GatewayConfigControllerTests() { _mockDbFactory = new Mock>(); _mockRouteProvider = new Mock(); _mockClusterProvider = new Mock(); _mockRouteCache = new Mock(); _sut = new GatewayConfigController( _mockDbFactory.Object, _mockRouteProvider.Object, _mockClusterProvider.Object, _mockRouteCache.Object ); } [Fact] public async Task GetTenants_ShouldReturnPaginatedList() { // Arrange var tenants = new List { new() { Id = 1, TenantCode = "tenant1", TenantName = "Tenant 1" }, new() { Id = 2, TenantCode = "tenant2", TenantName = "Tenant 2" } }; // 设置模拟 DbContext... // Act var result = await _sut.GetTenants(page: 1, pageSize: 10); // Assert var okResult = result.Should().BeOfType().Subject; var response = okResult.Value.Should().BeAnonymousType(); response.Property("total").Should().Be(2); } [Fact] public async Task CreateTenant_WithValidData_ReturnsCreatedTenant() { // Arrange var dto = new GatewayConfigController.CreateTenantDto { TenantCode = "new-tenant", TenantName = "New Tenant" }; // Act var result = await _sut.CreateTenant(dto); // Assert var okResult = result.Should().BeOfType().Subject; okResult.Value.Should().BeAssignableTo(); } [Fact] public async Task DeleteTenant_WithNonexistentId_ReturnsNotFound() { // Arrange // 设置模拟返回 null // Act var result = await _sut.DeleteTenant(999); // Assert result.Should().BeOfType(); } } ``` --- ## 4. Mock 模式 ### 4.1 接口 Mock ```csharp // 使用 Moq 模拟接口 public class RouteCacheTests { private readonly Mock _mockRouteCache; public RouteCacheTests() { _mockRouteCache = new Mock(); } [Fact] public async Task TestMethod() { // 设置返回值 _mockRouteCache .Setup(r => r.GetRoute("tenant1", "service1")) .Returns(new RouteInfo { ClusterId = "cluster1" }); // 设置异步方法 _mockRouteCache .Setup(r => r.InitializeAsync()) .Returns(Task.CompletedTask); // 验证调用 _mockRouteCache.Verify(r => r.GetRoute(It.IsAny(), It.IsAny()), Times.Once); } } ``` ### 4.2 DbContext Mock ```csharp // 使用 In-Memory 数据库进行测试 public class TestDatabaseFixture { public GatewayDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; var context = new GatewayDbContext(options); // 种子数据 context.Tenants.Add(new GwTenant { Id = 1, TenantCode = "test-tenant" }); context.TenantRoutes.Add(new GwTenantRoute { Id = 1, ServiceName = "test-service", ClusterId = "test-cluster" }); context.SaveChanges(); return context; } } public class GatewayDbContextTests : IClassFixture { private readonly TestDatabaseFixture _fixture; public GatewayDbContextTests(TestDatabaseFixture fixture) { _fixture = fixture; } [Fact] public async Task SaveChangesAsync_ShouldNotifyConfigChange() { // Arrange await using var context = _fixture.CreateContext(); // Act var route = new GwTenantRoute { ServiceName = "new-service", ClusterId = "new-cluster" }; context.TenantRoutes.Add(route); await context.SaveChangesAsync(); // Assert // 验证通知行为(如果需要) } } ``` ### 4.3 Redis Mock ```csharp // 使用 Moq 模拟 Redis public class RedisConnectionManagerTests { private readonly Mock _mockRedis; private readonly Mock _mockDatabase; public RedisConnectionManagerTests() { _mockRedis = new Mock(); _mockDatabase = new Mock(); _mockRedis.Setup(r => r.GetDatabase(It.IsAny(), It.IsAny())) .Returns(_mockDatabase.Object); } [Fact] public async Task AcquireLockAsync_WhenLockAvailable_ReturnsDisposable() { // Arrange _mockDatabase .Setup(d => d.StringSetAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); // Act & Assert // 测试逻辑... } } ``` --- ## 5. 集成测试模式 ### 5.1 WebApplicationFactory 模式 ```csharp // 使用 WebApplicationFactory 进行 API 集成测试 using Microsoft.AspNetCore.Mvc.Testing; public class GatewayIntegrationTests : IClassFixture> { private readonly WebApplicationFactory _factory; private readonly HttpClient _client; public GatewayIntegrationTests(WebApplicationFactory factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // 替换真实服务为测试替身 services.RemoveAll>(); services.AddDbContextFactory(options => options.UseInMemoryDatabase("TestDb")); }); }); _client = _factory.CreateClient(); } [Fact] public async Task GetHealth_ReturnsHealthy() { // Act var response = await _client.GetAsync("/health"); // Assert response.Should().BeSuccessful(); var content = await response.Content.ReadAsStringAsync(); content.Should().Contain("healthy"); } [Fact] public async Task GetTenants_ReturnsPaginatedList() { // Act var response = await _client.GetAsync("/api/gateway/tenants?page=1&pageSize=10"); // Assert response.Should().BeSuccessful(); // 进一步验证响应内容... } } ``` ### 5.2 Testcontainers 模式 ```csharp // 使用 Testcontainers 进行真实数据库集成测试 using Testcontainers.PostgreSql; using Testcontainers.Redis; public class DatabaseIntegrationTests : IAsyncLifetime { private readonly PostgreSqlContainer _postgresContainer; private readonly RedisContainer _redisContainer; public DatabaseIntegrationTests() { _postgresContainer = new PostgreSqlBuilder() .WithImage("postgres:15-alpine") .WithDatabase("test_gateway") .WithUsername("test") .WithPassword("test") .Build(); _redisContainer = new RedisBuilder() .WithImage("redis:7-alpine") .Build(); } public async Task InitializeAsync() { await _postgresContainer.StartAsync(); await _redisContainer.StartAsync(); } public async Task DisposeAsync() { await _postgresContainer.DisposeAsync(); await _redisContainer.DisposeAsync(); } [Fact] public async Task FullWorkflow_CreateTenantAndRoute_RouteShouldWork() { // Arrange var connectionString = _postgresContainer.GetConnectionString(); // 使用真实连接进行端到端测试... } } ``` --- ## 6. 测试覆盖率 ### 6.1 当前状态 项目当前无测试覆盖率数据。 ### 6.2 推荐覆盖率目标 | 层级 | 目标覆盖率 | 说明 | |------|-----------|------| | Services | 80%+ | 核心业务逻辑,必须高覆盖 | | Middleware | 75%+ | 关键请求处理逻辑 | | Controllers | 70%+ | API 端点行为验证 | | Config | 60%+ | 配置加载和验证 | | Models | 30%+ | 简单 POCO 类,低优先级 | ### 6.3 配置覆盖率收集 ```xml runtime; build; native; contentfiles; analyzers; buildtransitive all ``` ```bash # 运行测试并收集覆盖率 dotnet test --collect:"XPlat Code Coverage" # 生成覆盖率报告 dotnet tool install -g dotnet-reportgenerator-globaltool reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage-report" ``` --- ## 7. 如何运行测试 ### 7.1 运行所有测试 ```bash # 运行所有测试 dotnet test # 运行特定项目 dotnet test tests/YarpGateway.UnitTests # 运行特定测试类 dotnet test --filter "FullyQualifiedName~RouteCacheTests" # 运行特定测试方法 dotnet test --filter "FullyQualifiedName~RouteCacheTests.InitializeAsync_ShouldLoadRoutesFromDatabase" ``` ### 7.2 运行测试类别 ```csharp // 定义测试类别 [Trait("Category", "Unit")] public class RouteCacheTests { } [Trait("Category", "Integration")] public class GatewayIntegrationTests { } ``` ```bash # 只运行单元测试 dotnet test --filter "Category=Unit" # 排除集成测试 dotnet test --filter "Category!=Integration" ``` ### 7.3 CI/CD 配置示例 ```yaml # .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_DB: test_gateway POSTGRES_USER: test POSTGRES_PASSWORD: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - name: Test run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" env: ConnectionStrings__DefaultConnection: "Host=localhost;Database=test_gateway;Username=test;Password=test" Redis__ConnectionString: "localhost:6379" - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./tests/**/coverage.cobertura.xml ``` --- ## 8. 测试最佳实践 ### 8.1 AAA 模式 ```csharp [Fact] public async Task Method_Scenario_ExpectedResult() { // Arrange - 准备测试数据和环境 var input = "test-data"; // Act - 执行被测试的方法 var result = await _sut.MethodAsync(input); // Assert - 验证结果 result.Should().Be(expected); } ``` ### 8.2 单一职责 ```csharp // ✅ 好:每个测试只验证一个行为 [Fact] public async Task CreateTenant_WithValidData_ReturnsCreatedTenant() { } [Fact] public async Task CreateTenant_WithDuplicateCode_ReturnsBadRequest() { } // ❌ 差:一个测试验证多个行为 [Fact] public async Task CreateTenant_TestsAllScenarios() { } ``` ### 8.3 测试隔离 ```csharp public class RouteCacheTests { // 每个测试使用独立实例 private readonly RouteCache _sut; public RouteCacheTests() { // 在构造函数中初始化,确保每个测试独立 _sut = new RouteCache(...); } } ``` ### 8.4 避免实现细节测试 ```csharp // ✅ 好:测试行为而非实现 [Fact] public async Task GetRoute_ReturnsCorrectRoute() { } // ❌ 差:测试内部实现细节 [Fact] public void InternalDictionary_ContainsCorrectKey() { } ``` --- ## 9. 总结 ### 当前状态 - ❌ 无测试项目 - ❌ 无测试框架依赖 - ❌ 无测试覆盖率 - ❌ 无 CI/CD 测试配置 ### 建议行动计划 1. **创建测试项目** ```bash dotnet new xunit -n YarpGateway.UnitTests -o tests/YarpGateway.UnitTests dotnet new xunit -n YarpGateway.IntegrationTests -o tests/YarpGateway.IntegrationTests ``` 2. **添加测试依赖** ```bash dotnet add package Moq dotnet add package FluentAssertions dotnet add package coverlet.collector ``` 3. **优先测试核心服务** - `RouteCache` - 路由缓存核心逻辑 - `RedisConnectionManager` - Redis 连接和分布式锁 - `TenantRoutingMiddleware` - 租户路由中间件 4. **建立 CI/CD 测试流程** - 每次提交运行单元测试 - 每次合并运行集成测试 - 生成覆盖率报告 通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。