Skip to content

集成测试 ​

集成测试验证 EF Core 应用与真实数据库的交互,确保数据访问层在实际环境中正常工作。本章将深入探讨如何构建高效的集成测试框架,包括测试数据库管理、事务回滚、数据隔离和CI/CD集成。

目录 ​


1. 集成测试vs单元测试 ​

1.1 对比分析 ​

特性单元测试集成测试
范围单个类/方法多个组件协作
数据库Mock/InMemory真实数据库
速度毫秒级秒级
目的验证逻辑正确性验证组件集成
数量大量(70%)适量(20%)
维护成本低中

1.2 何时使用集成测试? ​

✅ 适合集成测试的场景:

  1. EF Core配置验证

    • 实体映射是否正确
    • 关系配置是否生效
    • 约束是否起作用
  2. LINQ查询翻译

    • 复杂查询能否翻译为SQL
    • 性能是否符合预期
    • 生成的SQL是否正确
  3. 迁移验证

    • 迁移是否能应用
    • 数据是否保留
    • 回滚是否工作
  4. 事务行为

    • 并发控制
    • 锁机制
    • 隔离级别

❌ 不需要集成测试:

  • 纯业务逻辑(用单元测试)
  • 字符串处理
  • 数学计算
  • 数据转换

2. 测试数据库策略 ​

2.1 策略对比 ​

策略1: 专用测试服务器

csharp
// appsettings.Testing.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=test-server;Database=MyApp_Tests;Trusted_Connection=True;"
  }
}

// ✅ 优点: 接近生产环境
// ❌ 缺点: 需要维护,可能有并发冲突

策略2: LocalDB(SQL Server)

csharp
// 使用LocalDB(轻量级SQL Server)
var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=MyApp_Tests;")
    .Options;

// ✅ 优点: 无需安装完整SQL Server
// ❌ 缺点: 仅Windows,启动慢

策略3: Docker容器(Testcontainers) ← 推荐

csharp
// 动态创建SQL Server容器
var container = new SqlServerBuilder()
    .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
    .Build();

await container.StartAsync();

var connectionString = container.GetConnectionString();

// ✅ 优点: 完全隔离,跨平台,可重复
// ❌ 缺点: 需要Docker,稍慢

策略4: SQLite内存

csharp
var options = new DbContextOptionsBuilder<AppDbContext>()
    .UseSqlite("Data Source=:memory:")
    .Options;

// ✅ 优点: 超快,无需外部依赖
// ❌ 缺点: SQL方言差异,不支持所有功能

3. Testcontainers方案 ​

3.1 安装与配置 ​

安装NuGet包:

bash
dotnet add package Testcontainers.MsSql
dotnet add package Testcontainers.PostgreSql
dotnet add package Testcontainers.MySql

3.2 基础实现 ​

Testcontainers Fixture:

csharp
public class DatabaseContainerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container;
    
    public string ConnectionString { get; private set; }
    
    public DatabaseContainerFixture()
    {
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .WithName($"myapp-tests-{Guid.NewGuid():N}")
            .WithCleanUp(true)  // 自动清理
            .Build();
    }
    
    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        ConnectionString = _container.GetConnectionString();
        
        // 创建数据库架构
        await CreateDatabaseSchemaAsync();
    }
    
    private async Task CreateDatabaseSchemaAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(ConnectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();
    }
    
    public async Task DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

xUnit集合Fixture:

csharp
[CollectionDefinition("DatabaseCollection")]
public class DatabaseCollection : ICollectionFixture<DatabaseContainerFixture>
{
}

[Collection("DatabaseCollection")]
public class ProductRepositoryTests : IDisposable
{
    private readonly DatabaseContainerFixture _fixture;
    private readonly AppDbContext _context;
    
    public ProductRepositoryTests(DatabaseContainerFixture fixture)
    {
        _fixture = fixture;
        
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(fixture.ConnectionString)
            .Options;
        
        _context = new AppDbContext(options);
    }
    
    [Fact]
    public async Task GetByIdAsync_ReturnsProduct()
    {
        // Arrange
        var product = new Product { Name = "Test", Price = 99.99m };
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        
        // Act
        var repository = new ProductRepository(_context);
        var result = await repository.GetByIdAsync(product.Id);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal("Test", result.Name);
    }
    
    public void Dispose()
    {
        _context.Dispose();
    }
}

3.3 PostgreSQL示例 ​

csharp
public class PostgresContainerFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container;
    
    public string ConnectionString { get; private set; }
    
    public PostgresContainerFixture()
    {
        _container = new PostgreSqlBuilder()
            .WithImage("postgres:16-alpine")
            .WithDatabase("testdb")
            .WithUsername("postgres")
            .WithPassword("password")
            .WithCleanUp(true)
            .Build();
    }
    
    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        ConnectionString = _container.GetConnectionString();
        
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(ConnectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();
    }
    
    public async Task DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

4. 事务回滚模式 ​

4.1 自动回滚模式 ​

每个测试在事务中执行,测试后回滚:

csharp
public abstract class TransactionalTestBase : IAsyncLifetime
{
    protected AppDbContext Context { get; private set; }
    private IDbContextTransaction _transaction;
    
    private readonly string _connectionString;
    
    protected TransactionalTestBase(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_connectionString)
            .Options;
        
        Context = new AppDbContext(options);
        
        // 开启事务
        _transaction = await Context.Database.BeginTransactionAsync();
    }
    
    public async Task DisposeAsync()
    {
        // 回滚事务 - 清除所有更改
        if (_transaction != null)
        {
            await _transaction.RollbackAsync();
            await _transaction.DisposeAsync();
        }
        
        await Context.DisposeAsync();
    }
}

// 使用
[Collection("DatabaseCollection")]
public class OrderServiceTests : TransactionalTestBase
{
    public OrderServiceTests(DatabaseContainerFixture fixture) 
        : base(fixture.ConnectionString)
    {
    }
    
    [Fact]
    public async Task CreateOrderAsync_PersistsCorrectly()
    {
        // Arrange
        var service = new OrderService(Context);
        
        // Act
        var order = await service.CreateOrderAsync(new CreateOrderRequest
        {
            CustomerId = 1,
            Items = new List<OrderItemRequest>
            {
                new() { ProductId = 1, Quantity = 2 }
            }
        });
        
        // Assert
        Assert.NotNull(order);
        Assert.True(order.Id > 0);
        
        // 测试结束后自动回滚,数据库保持干净
    }
    
    [Fact]
    public async Task AnotherTest_IsolationGuaranteed()
    {
        // 这个测试看不到上一个测试的数据
        var orders = await Context.Orders.ToListAsync();
        Assert.Empty(orders);  // ✅ 空,因为上一个测试已回滚
    }
}

4.2 Savepoint模式 ​

部分回滚到保存点:

csharp
[Fact]
public async Task TestWithPartialRollback()
{
    using var transaction = await Context.Database.BeginTransactionAsync();
    
    try
    {
        // 步骤1: 创建分类
        var category = new Category { Name = "Electronics" };
        Context.Categories.Add(category);
        await Context.SaveChangesAsync();
        
        // 创建保存点
        await transaction.CreateSavepointAsync("AfterCategory");
        
        // 步骤2: 创建产品(可能失败)
        try
        {
            var product = new Product 
            { 
                Name = "Laptop", 
                CategoryId = category.Id,
                Price = -100  // 无效价格,触发验证失败
            };
            
            Context.Products.Add(product);
            await Context.SaveChangesAsync();
        }
        catch
        {
            // 回滚到保存点,保留分类
            await transaction.RollbackToSavepointAsync("AfterCategory");
        }
        
        // Assert: 分类仍存在,产品不存在
        Assert.NotNull(await Context.Categories.FindAsync(category.Id));
        Assert.Empty(Context.Products);
        
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

5. Web API集成测试 ​

5.1 WebApplicationFactory ​

安装NuGet包:

bash
dotnet add package Microsoft.AspNetCore.Mvc.Testing

创建Web API测试:

csharp
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;
    
    public ApiIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(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("Server=localhost;Database=MyApp_ApiTests;");
                });
            });
        });
        
        _client = _factory.CreateClient();
    }
    
    [Fact]
    public async Task GetProducts_ReturnsOkWithProducts()
    {
        // Act
        var response = await _client.GetAsync("/api/products");
        
        // Assert
        response.EnsureSuccessStatusCode();
        
        var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        Assert.NotNull(products);
    }
    
    [Fact]
    public async Task CreateProduct_ValidData_ReturnsCreated()
    {
        // Arrange
        var newProduct = new CreateProductDto
        {
            Name = "Test Product",
            Price = 99.99m,
            Stock = 10
        };
        
        // Act
        var response = await _client.PostAsJsonAsync("/api/products", newProduct);
        
        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        
        var created = await response.Content.ReadFromJsonAsync<ProductDto>();
        Assert.NotNull(created);
        Assert.Equal("Test Product", created.Name);
    }
    
    [Fact]
    public async Task UpdateProduct_NonExistent_ReturnsNotFound()
    {
        // Arrange
        var update = new UpdateProductDto { Name = "Updated", Price = 199.99m };
        
        // Act
        var response = await _client.PutAsJsonAsync("/api/products/99999", update);
        
        // Assert
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

5.2 认证测试 ​

csharp
public class AuthenticatedApiTests
{
    private async Task<HttpClient> CreateAuthenticatedClient(string userId = "user123")
    {
        var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureTestServices(services =>
            {
                // Mock认证
                services.AddAuthentication("Test")
                    .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", options => {});
            });
        }).CreateClient();
        
        // 设置认证头
        client.DefaultRequestHeaders.Authorization = 
            new AuthenticationHeaderValue("Test", userId);
        
        return client;
    }
    
    [Fact]
    public async Task GetOrders_RequiresAuthentication()
    {
        // Act: 未认证的请求
        var response = await _client.GetAsync("/api/orders");
        
        // Assert
        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }
    
    [Fact]
    public async Task GetOrders_Authenticated_ReturnsUserOrders()
    {
        // Arrange
        var client = await CreateAuthenticatedClient("user123");
        
        // Act
        var response = await client.GetAsync("/api/orders");
        
        // Assert
        response.EnsureSuccessStatusCode();
        
        var orders = await response.Content.ReadFromJsonAsync<List<OrderDto>>();
        Assert.All(orders, o => Assert.Equal("user123", o.CustomerId));
    }
}

// 测试认证处理器
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.Name, "Test User") };
        var identity = new ClaimsIdentity(claims, "Test");
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, "Test");
        
        var result = AuthenticateResult.Success(ticket);
        return Task.FromResult(result);
    }
}

6. 测试迁移与种子数据 ​

6.1 迁移测试 ​

csharp
public class MigrationTests : IAsyncLifetime
{
    private readonly MsSqlContainer _container;
    private string _connectionString;
    
    public MigrationTests()
    {
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
            .Build();
    }
    
    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        _connectionString = _container.GetConnectionString();
    }
    
    [Fact]
    public async Task Migrations_ApplySuccessfully()
    {
        // Arrange
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_connectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        
        // Act
        var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
        await context.Database.MigrateAsync();
        
        // Assert
        var appliedMigrations = await context.Database.GetAppliedMigrationsAsync();
        Assert.NotEmpty(appliedMigrations);
        
        // 验证可以连接
        Assert.True(await context.Database.CanConnectAsync());
    }
    
    [Fact]
    public async Task SeedData_PopulatedCorrectly()
    {
        // Arrange
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_connectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        await context.Database.MigrateAsync();
        
        // Act
        var categories = await context.Categories.ToListAsync();
        var adminUser = await context.Users.FirstOrDefaultAsync(u => u.IsAdmin);
        
        // Assert
        Assert.NotEmpty(categories);  // 种子数据已加载
        Assert.NotNull(adminUser);
    }
    
    public async Task DisposeAsync()
    {
        await _container.DisposeAsync();
    }
}

6.2 种子数据辅助类 ​

csharp
public static class TestSeedData
{
    public static async Task SeedTestDataAsync(AppDbContext context)
    {
        // 清空现有数据
        context.Orders.RemoveRange(context.Orders);
        context.Products.RemoveRange(context.Products);
        context.Categories.RemoveRange(context.Categories);
        await context.SaveChangesAsync();
        
        // 插入测试数据
        var categories = new List<Category>
        {
            new() { Id = 1, Name = "Electronics" },
            new() { Id = 2, Name = "Books" }
        };
        
        context.Categories.AddRange(categories);
        
        var products = new List<Product>
        {
            new() { Id = 1, Name = "Laptop", Price = 999.99m, CategoryId = 1, Stock = 50 },
            new() { Id = 2, Name = "Phone", Price = 699.99m, CategoryId = 1, Stock = 100 },
            new() { Id = 3, Name = "C# Book", Price = 49.99m, CategoryId = 2, Stock = 30 }
        };
        
        context.Products.AddRange(products);
        await context.SaveChangesAsync();
    }
}

// 使用
[Collection("DatabaseCollection")]
public class ProductSearchTests
{
    private readonly AppDbContext _context;
    
    public ProductSearchTests(DatabaseContainerFixture fixture)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(fixture.ConnectionString)
            .Options;
        
        _context = new AppDbContext(options);
    }
    
    [Fact]
    public async Task SearchByName_FindsProducts()
    {
        // Arrange
        await TestSeedData.SeedTestDataAsync(_context);
        
        // Act
        var results = await _context.Products
            .Where(p => p.Name.Contains("Book"))
            .ToListAsync();
        
        // Assert
        Assert.Single(results);
        Assert.Equal("C# Book", results[0].Name);
    }
}

7. CI/CD中的集成测试 ​

7.1 GitHub Actions ​

yaml
name: Integration Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    
    services:
      sqlserver:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          ACCEPT_EULA: Y
          SA_PASSWORD: YourStrong@Passw0rd
        ports:
          - 1433:1433
        options: >-
          --health-cmd="sqlcmd -U sa -P YourStrong@Passw0rd -Q 'SELECT 1'"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '8.0.x'
      
      - name: Restore dependencies
        run: dotnet restore
      
      - name: Build
        run: dotnet build --no-restore
      
      - name: Run Integration Tests
        env:
          ConnectionStrings__DefaultConnection: "Server=localhost;Database=MyApp_Tests;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;"
        run: dotnet test --filter "Category=Integration" --no-build --verbosity normal
      
      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: test-results
          path: '**/*.trx'

7.2 Azure DevOps ​

yaml
stages:
  - stage: Testing
    jobs:
      - job: IntegrationTests
        pool:
          vmImage: 'ubuntu-latest'
        
        services:
          postgres:
            image: postgres:16-alpine
            ports:
              - 5432:5432
            env:
              POSTGRES_PASSWORD: password
        
        steps:
          - task: DotNetCoreCLI@2
            displayName: 'Run Integration Tests'
            inputs:
              command: 'test'
              arguments: '--filter "Category=Integration" --configuration Release'
            env:
              ConnectionStrings__DefaultConnection: 'Host=localhost;Database=testdb;Username=postgres;Password=password'

8. 性能测试与基准测试 ​

8.1 简单性能测试 ​

csharp
public class PerformanceTests : IClassFixture<DatabaseContainerFixture>
{
    private readonly DatabaseContainerFixture _fixture;
    
    public PerformanceTests(DatabaseContainerFixture fixture)
    {
        _fixture = fixture;
    }
    
    [Fact]
    public async Task BulkInsert_PerformanceTest()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_fixture.ConnectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        
        // 预热
        await SeedSmallDatasetAsync(context);
        
        // 测试: 插入10000条记录
        var stopwatch = Stopwatch.StartNew();
        
        var products = Enumerable.Range(1, 10000)
            .Select(i => new Product 
            { 
                Name = $"Product {i}", 
                Price = i * 10,
                Stock = 100 
            })
            .ToList();
        
        context.Products.AddRange(products);
        await context.SaveChangesAsync();
        
        stopwatch.Stop();
        
        // Assert: 应该在合理时间内完成
        Assert.True(stopwatch.ElapsedMilliseconds < 5000, 
            $"Bulk insert took {stopwatch.ElapsedMilliseconds}ms");
        
        Console.WriteLine($"Inserted 10000 records in {stopwatch.ElapsedMilliseconds}ms");
    }
    
    [Fact]
    public async Task ComplexQuery_PerformanceTest()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_fixture.ConnectionString)
            .Options;
        
        using var context = new AppDbContext(options);
        
        // 准备大数据
        await SeedLargeDatasetAsync(context);
        
        // 测试复杂查询
        var stopwatch = Stopwatch.StartNew();
        
        var results = await context.Orders
            .Where(o => o.OrderDate >= DateTime.UtcNow.AddDays(-30))
            .Include(o => o.Items)
            .ThenInclude(i => i.Product)
            .OrderByDescending(o => o.TotalAmount)
            .Take(100)
            .ToListAsync();
        
        stopwatch.Stop();
        
        Assert.True(stopwatch.ElapsedMilliseconds < 1000,
            $"Complex query took {stopwatch.ElapsedMilliseconds}ms");
        
        Console.WriteLine($"Query executed in {stopwatch.ElapsedMilliseconds}ms");
    }
}

8.2 BenchmarkDotNet基准测试 ​

安装NuGet包:

bash
dotnet add package BenchmarkDotNet

创建基准测试:

csharp
[MemoryDiagnoser]
public class EfCoreBenchmarks
{
    private AppDbContext _context;
    private List<Product> _products;
    
    [GlobalSetup]
    public void Setup()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer("Server=localhost;Database=Benchmarks;")
            .Options;
        
        _context = new AppDbContext(options);
        _context.Database.EnsureCreated();
        
        _products = GenerateProducts(1000);
    }
    
    [Benchmark]
    public async Task AddEntities()
    {
        _context.Products.AddRange(_products);
        await _context.SaveChangesAsync();
    }
    
    [Benchmark]
    public async Task QueryEntities()
    {
        var results = await _context.Products
            .Where(p => p.Price > 50)
            .ToListAsync();
    }
    
    [GlobalCleanup]
    public void Cleanup()
    {
        _context.Database.EnsureDeleted();
        _context.Dispose();
    }
}

// 运行基准测试
public class Program
{
    public static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<EfCoreBenchmarks>();
    }
}

总结 ​

集成测试是保证EF Core应用质量的关键环节:

核心策略 ​

  1. Testcontainers - 完全隔离,推荐方案
  2. 事务回滚 - 保持测试独立
  3. WebApplicationFactory - API集成测试
  4. CI/CD集成 - 自动化验证

最佳实践 ​

✅ 使用真实数据库测试
✅ 每个测试独立隔离
✅ 自动化清理数据
✅ 监控性能指标
✅ 集成到CI/CD流水线

掌握这些技巧,你可以构建可靠的集成测试套件!

基于 MIT 许可发布