.NET 单元测试(六):集成测试

写在前面

本文是 .NET 单元测试系列的第六篇,介绍 ASP.NET Core 应用的集成测试:WebApplicationFactory、真实数据库测试、中间件和认证测试。前置知识:难测场景实战(第五篇)。


一、为什么需要集成测试

1.1 单元测试的局限

1
2
3
4
5
6
7
单元测试:
  ✓ 逻辑正确性
  ✗ 组件协作是否正确
  ✗ 配置是否正确
  ✗ 中间件是否生效
  ✗ 数据库查询是否正确
  ✗ 依赖注入注册是否正确

1.2 集成测试的范围

1
2
3
4
验证多个组件协作:
  Controller → Service → Repository → Database
  Middleware → Controller → Response
  配置注入 → 实际使用

二、WebApplicationFactory

2.1 基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 被测 API 项目:MyApp.Web
[Program]  // 让测试项目能访问 Program 类
public partial class TestProgram { }

// 测试项目
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 替换数据库为 InMemory
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            services.AddDbContext<AppDbContext>(options =>
                options.UseInMemoryDatabase("TestDb"));

            // 替换外部服务为 Mock
            services.AddScoped<IEmailService>(_ =>
            {
                var mock = new Mock<IEmailService>();
                mock.Setup(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()))
                    .Returns(Task.CompletedTask);
                return mock.Object;
            });
        });
    }
}

2.2 测试基类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public abstract class IntegrationTestBase : IClassFixture<CustomWebApplicationFactory>
{
    protected readonly HttpClient Client;
    protected readonly CustomWebApplicationFactory Factory;

    protected IntegrationTestBase(CustomWebApplicationFactory factory)
    {
        Factory = factory;
        Client = factory.CreateClient();
    }

    protected async Task ResetDatabaseAsync()
    {
        using var scope = Factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();
    }

    protected async Task SeedDataAsync(params object[] entities)
    {
        using var scope = Factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        foreach (var entity in entities)
        {
            context.Add(entity);
        }
        await context.SaveChangesAsync();
    }
}

2.3 基本 API 测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class UserControllerTests : IntegrationTestBase
{
    public UserControllerTests(CustomWebApplicationFactory factory) : base(factory) { }

    [Fact]
    public async Task GetUserById_ExistingUser_ReturnsOk()
    {
        // 准备数据
        await SeedDataAsync(new User { Id = 1, Name = "张三", Email = "zhang@test.com" });

        // 调用 API
        var response = await Client.GetAsync("/api/users/1");

        // 验证
        response.EnsureSuccessStatusCode();
        var user = await response.Content.ReadFromJsonAsync<UserDto>();
        Assert.Equal("张三", user!.Name);
    }

    [Fact]
    public async Task CreateUser_ValidInput_ReturnsCreated()
    {
        var request = new CreateUserRequest
        {
            Name = "李四",
            Email = "li@test.com"
        };

        var response = await Client.PostAsJsonAsync("/api/users", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        var user = await response.Content.ReadFromJsonAsync<UserDto>();
        Assert.Equal("李四", user!.Name);
        Assert.True(user.Id > 0);
    }

    [Fact]
    public async Task CreateUser_DuplicateEmail_ReturnsConflict()
    {
        await SeedDataAsync(new User { Name = "张三", Email = "dup@test.com" });

        var request = new CreateUserRequest { Name = "李四", Email = "dup@test.com" };

        var response = await Client.PostAsJsonAsync("/api/users", request);

        Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
    }

    [Fact]
    public async Task GetUserById_NonExisting_ReturnsNotFound()
    {
        var response = await Client.GetAsync("/api/users/999");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

三、数据库集成测试

3.1 用 Testcontainers 跑真实数据库

1
2
dotnet add package Testcontainers.MsSql
dotnet add package Testcontainers.Redis
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class RealDatabaseTests : IAsyncLifetime
{
    private MsSqlContainer _container = null!;
    private HttpClient _client = null!;

    public async Task InitializeAsync()
    {
        // 启动 SQL Server 容器
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .Build();
        await _container.StartAsync();

        // 用真实数据库创建 WebApplicationFactory
        var factory = new CustomWebApplicationFactory(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.UseSqlServer(_container.GetConnectionString()));
            });
        });

        _client = factory.CreateClient();

        // 运行迁移
        using var scope = factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await context.Database.MigrateAsync();
    }

    [Fact]
    public async Task CreateUser_WithRealDb_Succeeds()
    {
        var request = new CreateUserRequest { Name = "张三", Email = "zhang@test.com" };

        var response = await _client.PostAsJsonAsync("/api/users", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }

    public async Task DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

3.2 事务回滚模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 每个测试用事务包裹,测试完回滚,数据不残留
public class TransactionTestBase : IntegrationTestBase, IDisposable
{
    private readonly AppDbContext _context;
    private readonly IDbContextTransaction _transaction;

    public TransactionTestBase(CustomWebApplicationFactory factory) : base(factory)
    {
        var scope = factory.Services.CreateScope();
        _context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        _transaction = _context.Database.BeginTransaction();
    }

    public void Dispose()
    {
        _transaction.Rollback();
        _transaction.Dispose();
        _context.Dispose();
    }
}

四、测试中间件

4.1 测试自定义中间件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 被测中间件:请求日志
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("请求:{Method} {Path}",
            context.Request.Method, context.Request.Path);

        await _next(context);

        _logger.LogInformation("响应:{StatusCode}", context.Response.StatusCode);
    }
}

// 测试
[Fact]
public async Task RequestLoggingMiddleware_LogsRequestAndResponse()
{
    // 手动构建 HttpContext
    var mockLogger = new Mock<ILogger<RequestLoggingMiddleware>>();
    var mockNext = new Mock<RequestDelegate>();
    mockNext.Setup(next => next(It.IsAny<HttpContext>()))
            .Returns(Task.CompletedTask);

    var middleware = new RequestLoggingMiddleware(mockNext.Object, mockLogger.Object);

    var context = new DefaultHttpContext();
    context.Request.Method = "GET";
    context.Request.Path = "/api/test";
    context.Response.StatusCode = 200;

    await middleware.InvokeAsync(context);

    mockNext.Verify(n => n(context), Times.Once);
}

4.2 测试异常处理中间件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlingMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = ex.Message });
        }
        catch (NotFoundException ex)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsJsonAsync(new { error = ex.Message });
        }
        catch (Exception)
        {
            context.Response.StatusCode = 500;
            await context.Response.WriteAsJsonAsync(new { error = "服务器内部错误" });
        }
    }
}

[Fact]
public async Task ExceptionMiddleware_ValidationException_Returns400()
{
    var mockNext = new Mock<RequestDelegate>();
    mockNext.Setup(n => n(It.IsAny<HttpContext>()))
            .Throws(new ValidationException("名称不能为空"));

    var middleware = new ExceptionHandlingMiddleware(mockNext.Object);
    var context = new DefaultHttpContext();
    context.Response.Body = new MemoryStream();

    await middleware.InvokeAsync(context);

    Assert.Equal(400, context.Response.StatusCode);
}

[Fact]
public async Task ExceptionMiddleware_UnhandledException_Returns500()
{
    var mockNext = new Mock<RequestDelegate>();
    mockNext.Setup(n => n(It.IsAny<HttpContext>()))
            .Throws(new Exception("未知错误"));

    var middleware = new ExceptionHandlingMiddleware(mockNext.Object);
    var context = new DefaultHttpContext();
    context.Response.Body = new MemoryStream();

    await middleware.InvokeAsync(context);

    Assert.Equal(500, context.Response.StatusCode);
}

五、认证和授权测试

5.1 测试需要认证的接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class AuthenticatedApiTests : IntegrationTestBase
{
    public AuthenticatedApiTests(CustomWebApplicationFactory factory) : base(factory)
    {
        // 方式一:配置工厂不使用认证(简化测试)
    }

    [Fact]
    public async Task GetProfile_WithoutAuth_Returns401()
    {
        var response = await Client.GetAsync("/api/users/profile");

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    [Fact]
    public async Task GetProfile_WithAuth_ReturnsProfile()
    {
        // 模拟已认证用户
        var token = GenerateTestToken("user-1", "张三");
        Client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", token);

        var response = await Client.GetAsync("/api/users/profile");

        response.EnsureSuccessStatusCode();
    }
}

5.2 模拟认证用户

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 自定义 WebApplicationFactory,跳过真实认证
public class AuthenticatedWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 替换认证方案为测试用
            services.AddAuthentication(defaultScheme: "TestScheme")
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
                    "TestScheme", _ => { });
        });
    }
}

// 测试认证 Handler
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public const string TestUserId = "test-user-1";

    public TestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder) : base(options, logger, encoder) { }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, TestUserId),
            new Claim(ClaimTypes.Name, "测试用户"),
            new Claim(ClaimTypes.Role, "Admin"),
        };

        var identity = new ClaimsIdentity(claims, "TestScheme");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// 使用
public class AdminApiTests : IClassFixture<AuthenticatedWebApplicationFactory>
{
    private readonly HttpClient _client;

    public AdminApiTests(AuthenticatedWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
        // 所有请求自动带认证信息
    }

    [Fact]
    public async Task AdminEndpoint_WithTestAuth_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/admin/users");

        response.EnsureSuccessStatusCode();
    }
}

5.3 测试 RBAC 授权

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 模拟不同角色
public class RoleBasedTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly string _role;

    public RoleBasedTestAuthHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        string role = "User") : base(options, logger, encoder)
    {
        _role = role;
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user"),
            new Claim(ClaimTypes.Role, _role),
        };
        var identity = new ClaimsIdentity(claims, "TestScheme");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "TestScheme");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

// 测试
[Theory]
[InlineData("Admin", HttpStatusCode.OK)]
[InlineData("User", HttpStatusCode.Forbidden)]
public async Task AdminEndpoint_RoleCheck(string role, HttpStatusCode expectedStatus)
{
    var factory = new RoleBasedWebApplicationFactory(role);
    var client = factory.CreateClient();

    var response = await client.GetAsync("/api/admin/settings");

    Assert.Equal(expectedStatus, response.StatusCode);
}

六、Snapshot 测试

6.1 响应结构验证

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[Fact]
public async Task GetUserById_ReturnsExpectedStructure()
{
    await SeedDataAsync(new User { Id = 1, Name = "张三", Email = "zhang@test.com" });

    var response = await Client.GetAsync("/api/users/1");
    var body = await response.Content.ReadAsStringAsync();

    // 验证 JSON 结构包含预期字段
    using var doc = JsonDocument.Parse(body);
    var root = doc.RootElement;

    Assert.True(root.TryGetProperty("id", out _));
    Assert.True(root.TryGetProperty("name", out _));
    Assert.True(root.TryGetProperty("email", out _));
    Assert.Equal("张三", root.GetProperty("name").GetString());
}

七、集成测试最佳实践

7.1 测试隔离

1
2
3
4
每个测试有独立数据:
  - 用不同的数据库(每个测试 new 一个 InMemory 实例)
  - 或者用事务回滚
  - 或者测试前后清理数据

7.2 性能优化

1
2
3
4
5
6
7
8
WebApplicationFactory 很重:
  - 用 IClassFixture 共享 Factory
  - 用 CollectionFixture 跨类共享
  - 不要每个测试方法都 new Factory

数据库操作:
  - 用 InMemory 代替真实数据库(快速验证流程)
  - Testcontainers 跑少量关键路径

7.3 测试分类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 用 Trait 分类
[Fact, Trait("Category", "Integration")]
public void IntegrationTest1() { }

[Fact, Trait("Category", "Unit")]
public void UnitTest1() { }

// 运行指定类别
// dotnet test --filter "Category=Integration"
// dotnet test --filter "Category=Unit"

八、小结

本文学习了 ASP.NET Core 集成测试:

  • WebApplicationFactory 的使用和配置
  • API 测试(GET/POST/PUT/DELETE)
  • 数据库集成测试(InMemory、Testcontainers、事务回滚)
  • 中间件测试
  • 认证和授权测试
  • 测试隔离和最佳实践

下一篇将学习测试工程化:命名规范、覆盖率、TDD 和 CI/CD 集成。