833 lines
22 KiB
Markdown
833 lines
22 KiB
Markdown
# YARP Gateway 测试文档
|
|
|
|
## 概述
|
|
|
|
本文档记录了 YARP Gateway 项目的测试策略、测试模式和最佳实践。
|
|
|
|
---
|
|
|
|
## 1. 测试框架
|
|
|
|
### 1.1 当前测试状态
|
|
|
|
**项目当前没有专门的测试目录或测试项目。**
|
|
|
|
检查项目结构:
|
|
```
|
|
fengling-gateway/
|
|
├── src/ # 源代码
|
|
│ └── YarpGateway.csproj # 主项目
|
|
├── .planning/
|
|
└── (无 tests/ 或 test/ 目录)
|
|
```
|
|
|
|
检查 `.csproj` 文件确认无测试框架依赖:
|
|
```xml
|
|
<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 测试命名约定
|
|
|
|
```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<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 中间件测试示例
|
|
|
|
```csharp
|
|
// 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 控制器测试示例
|
|
|
|
```csharp
|
|
// 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
|
|
|
|
```csharp
|
|
// 使用 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
|
|
|
|
```csharp
|
|
// 使用 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
|
|
|
|
```csharp
|
|
// 使用 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 模式
|
|
|
|
```csharp
|
|
// 使用 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 模式
|
|
|
|
```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
|
|
<!-- 添加到 .csproj 文件 -->
|
|
<ItemGroup>
|
|
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
<PrivateAssets>all</PrivateAssets>
|
|
</PackageReference>
|
|
</ItemGroup>
|
|
```
|
|
|
|
```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 测试流程**
|
|
- 每次提交运行单元测试
|
|
- 每次合并运行集成测试
|
|
- 生成覆盖率报告
|
|
|
|
通过建立完善的测试体系,可以显著提高代码质量和项目可维护性。 |