22 KiB
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 测试配置
建议行动计划
-
创建测试项目
dotnet new xunit -n YarpGateway.UnitTests -o tests/YarpGateway.UnitTests dotnet new xunit -n YarpGateway.IntegrationTests -o tests/YarpGateway.IntegrationTests -
添加测试依赖
dotnet add package Moq dotnet add package FluentAssertions dotnet add package coverlet.collector -
优先测试核心服务
RouteCache- 路由缓存核心逻辑RedisConnectionManager- Redis 连接和分布式锁TenantRoutingMiddleware- 租户路由中间件
-
建立 CI/CD 测试流程
- 每次提交运行单元测试
- 每次合并运行集成测试
- 生成覆盖率报告
通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。