Appearance
集成测试
集成测试验证 EF Core 应用与真实数据库的交互,确保数据访问层在实际环境中正常工作。本章将深入探讨如何构建高效的集成测试框架,包括测试数据库管理、事务回滚、数据隔离和CI/CD集成。
目录
- 1. 集成测试vs单元测试
- 2. 测试数据库策略
- 3. Testcontainers方案
- 4. 事务回滚模式
- 5. Web API集成测试
- 6. 测试迁移与种子数据
- 7. CI/CD中的集成测试
- 8. 性能测试与基准测试
1. 集成测试vs单元测试
1.1 对比分析
| 特性 | 单元测试 | 集成测试 |
|---|---|---|
| 范围 | 单个类/方法 | 多个组件协作 |
| 数据库 | Mock/InMemory | 真实数据库 |
| 速度 | 毫秒级 | 秒级 |
| 目的 | 验证逻辑正确性 | 验证组件集成 |
| 数量 | 大量(70%) | 适量(20%) |
| 维护成本 | 低 | 中 |
1.2 何时使用集成测试?
✅ 适合集成测试的场景:
EF Core配置验证
- 实体映射是否正确
- 关系配置是否生效
- 约束是否起作用
LINQ查询翻译
- 复杂查询能否翻译为SQL
- 性能是否符合预期
- 生成的SQL是否正确
迁移验证
- 迁移是否能应用
- 数据是否保留
- 回滚是否工作
事务行为
- 并发控制
- 锁机制
- 隔离级别
❌ 不需要集成测试:
- 纯业务逻辑(用单元测试)
- 字符串处理
- 数学计算
- 数据转换
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.MySql3.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应用质量的关键环节:
核心策略
- Testcontainers - 完全隔离,推荐方案
- 事务回滚 - 保持测试独立
- WebApplicationFactory - API集成测试
- CI/CD集成 - 自动化验证
最佳实践
✅ 使用真实数据库测试
✅ 每个测试独立隔离
✅ 自动化清理数据
✅ 监控性能指标
✅ 集成到CI/CD流水线
掌握这些技巧,你可以构建可靠的集成测试套件!