ASP.NET Core 集成测试
什么是集成测试?
核心概念
集成测试验证多个组件一起工作的情况,包括:
- 完整的请求/响应流程
- 数据库交互
- 中间件管道
- 依赖注入
- 配置加载
单元测试 vs 集成测试
维度 | 单元测试 | 集成测试 |
---|---|---|
测试范围 | 单个类/方法 | 多个组件协作 |
依赖 | Mock/Stub | 真实依赖(或测试替身) |
执行速度 | 快 | 慢 |
隔离性 | 高 | 低 |
置信度 | 低 | 高 |
适用场景 | 业务逻辑 | API 端点、数据流 |
WebApplicationFactory
1. 安装包
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Microsoft.EntityFrameworkCore.InMemory
2. 基本设置
- 自定义 Factory
- 测试类基类
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class CustomWebApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 移除真实数据库上下文
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// 添加内存数据库
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
// 构建服务提供者并初始化数据库
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
SeedTestData(db);
});
return base.CreateHost(builder);
}
private void SeedTestData(AppDbContext db)
{
db.Users.AddRange(
new User { Id = 1, Name = "张三", Email = "zhangsan@example.com" },
new User { Id = 2, Name = "李四", Email = "lisi@example.com" }
);
db.SaveChanges();
}
}
public class IntegrationTestBase : IClassFixture<CustomWebApplicationFactory<Program>>
{
protected readonly HttpClient _client;
protected readonly CustomWebApplicationFactory<Program> _factory;
public IntegrationTestBase(CustomWebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
protected async Task<T?> GetAsync<T>(string url)
{
var response = await _client.GetAsync(url);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
protected async Task<HttpResponseMessage> PostAsync<T>(string url, T data)
{
var json = JsonSerializer.Serialize(data);
var content = new StringContent(json, Encoding.UTF8, "application/json");
return await _client.PostAsync(url, content);
}
}
3. 编写集成测试
public class UsersControllerIntegrationTests : IntegrationTestBase
{
public UsersControllerIntegrationTests(CustomWebApplicationFactory<Program> factory)
: base(factory)
{
}
[Fact]
public async Task GetUsers_ReturnsSuccessAndUsers()
{
// Arrange
// (数据已在 Factory 中初始化)
// Act
var users = await GetAsync<List<UserDto>>("/api/users");
// Assert
users.Should().NotBeNull();
users.Should().HaveCount(2);
users.Should().Contain(u => u.Name == "张三");
}
[Fact]
public async Task GetUser_WithValidId_ReturnsUser()
{
// Act
var user = await GetAsync<UserDto>("/api/users/1");
// Assert
user.Should().NotBeNull();
user!.Id.Should().Be(1);
user.Name.Should().Be("张三");
user.Email.Should().Be("zhangsan@example.com");
}
[Fact]
public async Task GetUser_WithInvalidId_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync("/api/users/999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task CreateUser_WithValidData_ReturnsCreated()
{
// Arrange
var newUser = new CreateUserRequest
{
Name = "王五",
Email = "wangwu@example.com"
};
// Act
var response = await PostAsync("/api/users", newUser);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var content = await response.Content.ReadAsStringAsync();
var user = JsonSerializer.Deserialize<UserDto>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
user.Should().NotBeNull();
user!.Name.Should().Be("王五");
user.Email.Should().Be("wangwu@example.com");
// 验证 Location 头
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task CreateUser_WithInvalidData_ReturnsBadRequest()
{
// Arrange
var newUser = new CreateUserRequest
{
Name = "", // 无效:名称为空
Email = "invalid-email" // 无效:邮箱格式错误
};
// Act
var response = await PostAsync("/api/users", newUser);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task UpdateUser_WithValidData_ReturnsNoContent()
{
// Arrange
var updateUser = new UpdateUserRequest
{
Name = "张三(更新)",
Email = "zhangsan.updated@example.com"
};
var json = JsonSerializer.Serialize(updateUser);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PutAsync("/api/users/1", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
// 验证更新
var user = await GetAsync<UserDto>("/api/users/1");
user!.Name.Should().Be("张三(更新)");
}
[Fact]
public async Task DeleteUser_WithValidId_ReturnsNoContent()
{
// Act
var response = await _client.DeleteAsync("/api/users/1");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
// 验证删除
var getResponse = await _client.GetAsync("/api/users/1");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
测试认证和授权
1. 添加测试认证
public class AuthenticatedWebApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 移除真实的 JWT 认证
var jwtDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(JwtBearerHandler));
if (jwtDescriptor != null)
{
services.Remove(jwtDescriptor);
}
// 添加测试认证
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
return base.CreateHost(builder);
}
}
// 测试认证处理器
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Email, "test@example.com"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
2. 测试需要认证的端点
public class AuthenticatedEndpointsTests : IClassFixture<AuthenticatedWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public AuthenticatedEndpointsTests(AuthenticatedWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
// 添加测试认证头
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Test");
}
[Fact]
public async Task GetProfile_WithAuthentication_ReturnsProfile()
{
// Act
var response = await _client.GetAsync("/api/profile");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<ProfileDto>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
profile.Should().NotBeNull();
profile!.Name.Should().Be("Test User");
profile.Email.Should().Be("test@example.com");
}
[Fact]
public async Task AdminEndpoint_WithAdminRole_ReturnsSuccess()
{
// Act
var response = await _client.GetAsync("/api/admin/users");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
测试数据库操作
1. 使用真实数据库(PostgreSQL/SQL Server)
public class DatabaseIntegrationTestBase : IClassFixture<DatabaseFixture>
{
protected readonly DatabaseFixture _fixture;
public DatabaseIntegrationTestBase(DatabaseFixture fixture)
{
_fixture = fixture;
}
}
public class DatabaseFixture : IDisposable
{
public AppDbContext DbContext { get; private set; }
public DatabaseFixture()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=TestDb;Username=test;Password=test")
.Options;
DbContext = new AppDbContext(options);
// 清空数据库
DbContext.Database.EnsureDeleted();
DbContext.Database.EnsureCreated();
SeedData();
}
private void SeedData()
{
DbContext.Users.AddRange(
new User { Name = "张三", Email = "zhangsan@example.com" },
new User { Name = "李四", Email = "lisi@example.com" }
);
DbContext.SaveChanges();
}
public void Dispose()
{
DbContext.Database.EnsureDeleted();
DbContext.Dispose();
}
}
2. 使用事务隔离测试
public class TransactionalTestBase : IDisposable
{
protected readonly AppDbContext _context;
private readonly IDbContextTransaction _transaction;
public TransactionalTestBase()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
_transaction = _context.Database.BeginTransaction();
}
public void Dispose()
{
_transaction.Rollback();
_transaction.Dispose();
_context.Dispose();
}
}
public class UserRepositoryTests : TransactionalTestBase
{
[Fact]
public async Task AddUser_SavesUserToDatabase()
{
// Arrange
var repository = new UserRepository(_context);
var user = new User { Name = "Test", Email = "test@example.com" };
// Act
await repository.AddAsync(user);
await _context.SaveChangesAsync();
// Assert
var savedUser = await _context.Users.FirstOrDefaultAsync(u => u.Email == "test@example.com");
savedUser.Should().NotBeNull();
savedUser!.Name.Should().Be("Test");
}
}
测试外部依赖
1. Mock HTTP 客户端
public class ExternalApiTests : IntegrationTestBase
{
public ExternalApiTests(CustomWebApplicationFactory<Program> factory)
: base(factory)
{
}
[Fact]
public async Task GetWeather_CallsExternalApi_ReturnsWeatherData()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("https://api.weather.com/current")
.Respond("application/json", @"{
""temperature"": 25,
""condition"": ""Sunny""
}");
var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddHttpClient("WeatherClient")
.ConfigurePrimaryHttpMessageHandler(() => mockHttp);
});
});
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/api/weather");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
var weather = JsonSerializer.Deserialize<WeatherDto>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
weather.Should().NotBeNull();
weather!.Temperature.Should().Be(25);
}
}
2. Mock 外部服务
public class EmailServiceTests : IntegrationTestBase
{
private readonly Mock<IEmailService> _mockEmailService;
public EmailServiceTests(CustomWebApplicationFactory<Program> factory)
: base(factory)
{
_mockEmailService = new Mock<IEmailService>();
// 替换真实的邮件服务
_factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IEmailService));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddSingleton(_mockEmailService.Object);
});
});
}
[Fact]
public async Task Register_SendsWelcomeEmail()
{
// Arrange
var request = new RegisterRequest
{
Email = "newuser@example.com",
Password = "Password123!"
};
// Act
var response = await PostAsync("/api/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
_mockEmailService.Verify(
x => x.SendWelcomeEmailAsync(It.Is<string>(email => email == "newuser@example.com")),
Times.Once);
}
}
测试中间件
public class MiddlewareTests : IntegrationTestBase
{
public MiddlewareTests(CustomWebApplicationFactory<Program> factory)
: base(factory)
{
}
[Fact]
public async Task Request_WithoutApiKey_ReturnsUnauthorized()
{
// Act
var response = await _client.GetAsync("/api/secure/data");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Request_WithValidApiKey_ReturnsSuccess()
{
// Arrange
_client.DefaultRequestHeaders.Add("X-API-Key", "valid-key");
// Act
var response = await _client.GetAsync("/api/secure/data");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task Request_IncludesCorrelationId()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.Headers.Should().ContainKey("X-Correlation-Id");
}
}
并行测试
配置测试集合
// 定义测试集合,控制并行度
[CollectionDefinition("Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
// 这个类没有代码,只是用于定义集合
}
// 使用集合
[Collection("Database collection")]
public class UserRepositoryTests
{
private readonly DatabaseFixture _fixture;
public UserRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
// 测试方法...
}
[Collection("Database collection")]
public class ProductRepositoryTests
{
private readonly DatabaseFixture _fixture;
public ProductRepositoryTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
// 测试方法...
}
最佳实践
最佳实践清单
1. 使用内存数据库进行快速测试
// ✅ 好:使用内存数据库
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
// 对于需要特定数据库功能的测试,使用真实数据库
services.AddDbContext<AppDbContext>(options =>
{
options.UseNpgsql("TestConnectionString");
});
2. 隔离测试数据
// ✅ 好:每个测试使用独立的数据库
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase(Guid.NewGuid().ToString());
});
// ❌ 差:所有测试共享数据库
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("SharedDb"); // 测试之间可能互相影响
});
3. 清理测试数据
// ✅ 好:测试后清理
public void Dispose()
{
_context.Database.EnsureDeleted();
_context.Dispose();
}
4. 测试完整的端到端流程
// ✅ 好:测试完整流程
[Fact]
public async Task CompleteUserWorkflow()
{
// 1. 创建用户
var createResponse = await PostAsync("/api/users", new { Name = "Test" });
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var userId = await GetUserIdFromResponse(createResponse);
// 2. 获取用户
var getResponse = await _client.GetAsync($"/api/users/{userId}");
getResponse.StatusCode.Should().Be(HttpStatusCode.OK);
// 3. 更新用户
var updateResponse = await _client.PutAsync($"/api/users/{userId}",
new StringContent("..."));
updateResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
// 4. 删除用户
var deleteResponse = await _client.DeleteAsync($"/api/users/{userId}");
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
5. 使用有意义的测试名称
// ✅ 好:清晰的测试名称
[Fact]
public async Task GetUser_WithInvalidId_ReturnsNotFound() { }
[Fact]
public async Task CreateUser_WithDuplicateEmail_ReturnsBadRequest() { }
// ❌ 差:不清晰的测试名称
[Fact]
public async Task Test1() { }
[Fact]
public async Task TestUser() { }
6. AAA 模式
// ✅ 好:遵循 AAA 模式
[Fact]
public async Task GetUser_ReturnsUser()
{
// Arrange(准备)
var userId = 1;
// Act(执行)
var response = await _client.GetAsync($"/api/users/{userId}");
// Assert(断言)
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
总结
关键要点
- 使用
WebApplicationFactory
创建测试服务器 - 内存数据库适合快速测试,真实数据库用于特定功能测试
- 隔离测试数据,避免测试之间相互影响
- Mock 外部依赖,确保测试可靠性
- 测试完整的请求/响应流程
- 测试认证、授权、中间件等横切关注点
- 使用有意义的测试名称
- 遵循 AAA 模式组织测试代码