fengling-gateway/.planning/codebase/TESTING.md
2026-02-28 15:44:16 +08:00

22 KiB

YARP Gateway 测试文档

概述

本文档记录了 YARP Gateway 项目的测试策略、测试模式和最佳实践。


1. 测试框架

1.1 当前测试状态

项目当前没有专门的测试目录或测试项目。

检查项目结构:

fengling-gateway/
├── src/                    # 源代码
│   └── YarpGateway.csproj  # 主项目
├── .planning/
└── (无 tests/ 或 test/ 目录)

检查 .csproj 文件确认无测试框架依赖:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
  <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
  <PackageReference Include="Serilog.AspNetCore" />
  <PackageReference Include="StackExchange.Redis" />
  <PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup>

结论:项目目前处于开发阶段,尚未建立测试基础设施。

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 测试命名约定

// 命名格式:[被测类]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 服务层测试示例

// RouteCacheTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

public class RouteCacheTests
{
    private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbContextFactory;
    private readonly Mock<ILogger<RouteCache>> _mockLogger;
    private readonly RouteCache _sut; // System Under Test

    public RouteCacheTests()
    {
        _mockDbContextFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
        _mockLogger = new Mock<ILogger<RouteCache>>();
        _sut = new RouteCache(_mockDbContextFactory.Object, _mockLogger.Object);
    }

    [Fact]
    public async Task InitializeAsync_ShouldLoadRoutesFromDatabase()
    {
        // Arrange
        var routes = new List<GwTenantRoute>
        {
            new() { Id = 1, ServiceName = "user-service", ClusterId = "user-cluster", IsGlobal = true }
        };

        var mockDbSet = CreateMockDbSet(routes);
        var mockContext = new Mock<GatewayDbContext>();
        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<DbSet<T>> CreateMockDbSet<T>(List<T> data) where T : class
    {
        var queryable = data.AsQueryable();
        var mockSet = new Mock<DbSet<T>>();
        mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
        mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
        mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
        mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());
        return mockSet;
    }
}

3.2 中间件测试示例

// TenantRoutingMiddlewareTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.AspNetCore.Http;

public class TenantRoutingMiddlewareTests
{
    private readonly Mock<RequestDelegate> _mockNext;
    private readonly Mock<IRouteCache> _mockRouteCache;
    private readonly Mock<ILogger<TenantRoutingMiddleware>> _mockLogger;
    private readonly TenantRoutingMiddleware _sut;

    public TenantRoutingMiddlewareTests()
    {
        _mockNext = new Mock<RequestDelegate>();
        _mockRouteCache = new Mock<IRouteCache>();
        _mockLogger = new Mock<ILogger<TenantRoutingMiddleware>>();
        _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<string>(), It.IsAny<string>()), 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 控制器测试示例

// GatewayConfigControllerTests.cs
using Xunit;
using Moq;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;

public class GatewayConfigControllerTests
{
    private readonly Mock<IDbContextFactory<GatewayDbContext>> _mockDbFactory;
    private readonly Mock<DatabaseRouteConfigProvider> _mockRouteProvider;
    private readonly Mock<DatabaseClusterConfigProvider> _mockClusterProvider;
    private readonly Mock<IRouteCache> _mockRouteCache;
    private readonly GatewayConfigController _sut;

    public GatewayConfigControllerTests()
    {
        _mockDbFactory = new Mock<IDbContextFactory<GatewayDbContext>>();
        _mockRouteProvider = new Mock<DatabaseRouteConfigProvider>();
        _mockClusterProvider = new Mock<DatabaseClusterConfigProvider>();
        _mockRouteCache = new Mock<IRouteCache>();
        
        _sut = new GatewayConfigController(
            _mockDbFactory.Object,
            _mockRouteProvider.Object,
            _mockClusterProvider.Object,
            _mockRouteCache.Object
        );
    }

    [Fact]
    public async Task GetTenants_ShouldReturnPaginatedList()
    {
        // Arrange
        var tenants = new List<GwTenant>
        {
            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<OkObjectResult>().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<OkObjectResult>().Subject;
        okResult.Value.Should().BeAssignableTo<GwTenant>();
    }

    [Fact]
    public async Task DeleteTenant_WithNonexistentId_ReturnsNotFound()
    {
        // Arrange
        // 设置模拟返回 null

        // Act
        var result = await _sut.DeleteTenant(999);

        // Assert
        result.Should().BeOfType<NotFoundResult>();
    }
}

4. Mock 模式

4.1 接口 Mock

// 使用 Moq 模拟接口
public class RouteCacheTests
{
    private readonly Mock<IRouteCache> _mockRouteCache;

    public RouteCacheTests()
    {
        _mockRouteCache = new Mock<IRouteCache>();
    }

    [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<string>(), It.IsAny<string>()), Times.Once);
    }
}

4.2 DbContext Mock

// 使用 In-Memory 数据库进行测试
public class TestDatabaseFixture
{
    public GatewayDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<GatewayDbContext>()
            .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<TestDatabaseFixture>
{
    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

// 使用 Moq 模拟 Redis
public class RedisConnectionManagerTests
{
    private readonly Mock<IConnectionMultiplexer> _mockRedis;
    private readonly Mock<IDatabase> _mockDatabase;

    public RedisConnectionManagerTests()
    {
        _mockRedis = new Mock<IConnectionMultiplexer>();
        _mockDatabase = new Mock<IDatabase>();
        _mockRedis.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
            .Returns(_mockDatabase.Object);
    }

    [Fact]
    public async Task AcquireLockAsync_WhenLockAvailable_ReturnsDisposable()
    {
        // Arrange
        _mockDatabase
            .Setup(d => d.StringSetAsync(
                It.IsAny<RedisKey>(),
                It.IsAny<RedisValue>(),
                It.IsAny<TimeSpan?>(),
                It.IsAny<When>(),
                It.IsAny<CommandFlags>()))
            .ReturnsAsync(true);

        // Act & Assert
        // 测试逻辑...
    }
}

5. 集成测试模式

5.1 WebApplicationFactory 模式

// 使用 WebApplicationFactory 进行 API 集成测试
using Microsoft.AspNetCore.Mvc.Testing;

public class GatewayIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public GatewayIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // 替换真实服务为测试替身
                services.RemoveAll<IDbContextFactory<GatewayDbContext>>();
                services.AddDbContextFactory<GatewayDbContext>(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 模式

// 使用 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 配置覆盖率收集

<!-- 添加到 .csproj 文件 -->
<ItemGroup>
  <PackageReference Include="coverlet.collector" Version="6.0.0">
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    <PrivateAssets>all</PrivateAssets>
  </PackageReference>
</ItemGroup>
# 运行测试并收集覆盖率
dotnet test --collect:"XPlat Code Coverage"

# 生成覆盖率报告
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage-report"

7. 如何运行测试

7.1 运行所有测试

# 运行所有测试
dotnet test

# 运行特定项目
dotnet test tests/YarpGateway.UnitTests

# 运行特定测试类
dotnet test --filter "FullyQualifiedName~RouteCacheTests"

# 运行特定测试方法
dotnet test --filter "FullyQualifiedName~RouteCacheTests.InitializeAsync_ShouldLoadRoutesFromDatabase"

7.2 运行测试类别

// 定义测试类别
[Trait("Category", "Unit")]
public class RouteCacheTests { }

[Trait("Category", "Integration")]
public class GatewayIntegrationTests { }
# 只运行单元测试
dotnet test --filter "Category=Unit"

# 排除集成测试
dotnet test --filter "Category!=Integration"

7.3 CI/CD 配置示例

# .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 模式

[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 单一职责

// ✅ 好:每个测试只验证一个行为
[Fact]
public async Task CreateTenant_WithValidData_ReturnsCreatedTenant() { }

[Fact]
public async Task CreateTenant_WithDuplicateCode_ReturnsBadRequest() { }

// ❌ 差:一个测试验证多个行为
[Fact]
public async Task CreateTenant_TestsAllScenarios() { }

8.3 测试隔离

public class RouteCacheTests
{
    // 每个测试使用独立实例
    private readonly RouteCache _sut;

    public RouteCacheTests()
    {
        // 在构造函数中初始化,确保每个测试独立
        _sut = new RouteCache(...);
    }
}

8.4 避免实现细节测试

// ✅ 好:测试行为而非实现
[Fact]
public async Task GetRoute_ReturnsCorrectRoute() { }

// ❌ 差:测试内部实现细节
[Fact]
public void InternalDictionary_ContainsCorrectKey() { }

9. 总结

当前状态

  • 无测试项目
  • 无测试框架依赖
  • 无测试覆盖率
  • 无 CI/CD 测试配置

建议行动计划

  1. 创建测试项目

    dotnet new xunit -n YarpGateway.UnitTests -o tests/YarpGateway.UnitTests
    dotnet new xunit -n YarpGateway.IntegrationTests -o tests/YarpGateway.IntegrationTests
    
  2. 添加测试依赖

    dotnet add package Moq
    dotnet add package FluentAssertions
    dotnet add package coverlet.collector
    
  3. 优先测试核心服务

    • RouteCache - 路由缓存核心逻辑
    • RedisConnectionManager - Redis 连接和分布式锁
    • TenantRoutingMiddleware - 租户路由中间件
  4. 建立 CI/CD 测试流程

    • 每次提交运行单元测试
    • 每次合并运行集成测试
    • 生成覆盖率报告

通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。