Skip to main content

ASP.NET Core 集成测试

什么是集成测试?

核心概念

集成测试验证多个组件一起工作的情况,包括:

  • 完整的请求/响应流程
  • 数据库交互
  • 中间件管道
  • 依赖注入
  • 配置加载

单元测试 vs 集成测试

维度单元测试集成测试
测试范围单个类/方法多个组件协作
依赖Mock/Stub真实依赖(或测试替身)
执行速度
隔离性
置信度
适用场景业务逻辑API 端点、数据流

WebApplicationFactory

1. 安装包

dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Microsoft.EntityFrameworkCore.InMemory

2. 基本设置

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();
}
}

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 模式组织测试代码

相关资源