Appearance
单元测试
EF Core 单元测试是保证数据访问层质量的关键。本章将深入探讨如何为 EF Core 应用编写高质量的单元测试,包括内存数据库、Mocking技术、测试隔离、以及常见陷阱的避免。
目录
1. 单元测试基础
1.1 为什么要测试EF Core代码?
测试的价值:
csharp
// ❌ 没有测试:
// - 重构时可能破坏现有功能
// - 边界条件未验证
// - 回归bug频发
// - 部署到生产环境才发现问题
// ✅ 有测试:
// - 自信重构
// - 自动化回归检测
// - 文档化预期行为
// - 早期发现bug测试金字塔:
/\
/ \
/ E2E \ 端到端测试 (10%)
/--------\
/ \
/ Integration\ 集成测试 (20%)
/--------------\
/ \
/ Unit Tests \ 单元测试 (70%)
--------------------1.2 测试什么?
应该测试:
✅ 业务逻辑
- 查询过滤条件
- 数据转换逻辑
- 验证规则
- 计算逻辑
✅ EF Core配置
- 实体映射
- 关系配置
- 约束验证
- 值转换器
✅ 边界条件
- 空数据
- 大数据量
- 并发访问
- 异常处理
不应该测试:
❌ EF Core框架本身
- SaveChanges是否保存(这是框架的职责)
- LINQ翻译是否正确(框架已测试)
- 迁移生成(单独测试)
2. 测试策略选择
2.1 三种主要策略对比
| 策略 | 速度 | 真实性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| InMemory | 快 | 中 | 低 | 快速原型、简单测试 |
| SQLite内存 | 中 | 高 | 中 | 推荐用于大多数场景 |
| Mocking | 最快 | 低 | 高 | 隔离外部依赖 |
2.2 策略决策树
mermaid
graph TD
A[需要测试EF Core?] --> B{测试类型?}
B -->|业务逻辑| C[Mocking]
B -->|数据访问| D{需要真实SQL?}
D -->|是| E[SQLite内存]
D -->|否| F[InMemory]
E --> G[集成测试]
F --> H[单元测试]
C --> H3. InMemory数据库
3.1 基础使用
安装NuGet包:
bash
dotnet add package Microsoft.EntityFrameworkCore.InMemory创建测试基类:
csharp
public abstract class TestBase : IDisposable
{
protected readonly AppDbContext Context;
protected TestBase()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) // 唯一数据库名
.Options;
Context = new AppDbContext(options);
}
public void Dispose()
{
Context.Dispose();
}
}编写测试:
csharp
public class ProductServiceTests : TestBase
{
[Fact]
public async Task GetProductsAsync_ReturnsOnlyAvailableProducts()
{
// Arrange - 准备测试数据
Context.Products.AddRange(
new Product { Id = 1, Name = "Product A", Price = 100, IsAvailable = true },
new Product { Id = 2, Name = "Product B", Price = 200, IsAvailable = false },
new Product { Id = 3, Name = "Product C", Price = 150, IsAvailable = true }
);
await Context.SaveChangesAsync();
// Act - 执行被测方法
var service = new ProductService(Context);
var products = await service.GetAvailableProductsAsync();
// Assert - 验证结果
Assert.Equal(2, products.Count);
Assert.All(products, p => Assert.True(p.IsAvailable));
Assert.DoesNotContain(products, p => p.Id == 2);
}
[Fact]
public async Task CreateProductAsync_SetsCreatedAt()
{
// Arrange
var service = new ProductService(Context);
var request = new CreateProductRequest
{
Name = "Test Product",
Price = 99.99m
};
// Act
var product = await service.CreateProductAsync(request);
// Assert
Assert.NotNull(product);
Assert.Equal("Test Product", product.Name);
Assert.NotEqual(default, product.CreatedAt);
Assert.True(product.CreatedAt <= DateTime.UtcNow);
}
}3.2 种子数据辅助类
csharp
public static class TestDataBuilder
{
public static List<Category> BuildCategories()
{
return new List<Category>
{
new Category { Id = 1, Name = "Electronics" },
new Category { Id = 2, Name = "Books" },
new Category { Id = 3, Name = "Clothing" }
};
}
public static List<Product> BuildProducts()
{
return new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 999.99m, CategoryId = 1, Stock = 50 },
new Product { Id = 2, Name = "Phone", Price = 699.99m, CategoryId = 1, Stock = 100 },
new Product { Id = 3, Name = "C# Book", Price = 49.99m, CategoryId = 2, Stock = 30 },
new Product { Id = 4, Name = "T-Shirt", Price = 19.99m, CategoryId = 3, Stock = 200 }
};
}
public static List<Customer> BuildCustomers()
{
return new List<Customer>
{
new Customer { Id = 1, Name = "Alice", Email = "alice@example.com" },
new Customer { Id = 2, Name = "Bob", Email = "bob@example.com" }
};
}
}
// 使用
public class OrderServiceTests : TestBase
{
[Fact]
public async Task GetCustomerOrdersAsync_ReturnsCorrectOrders()
{
// Arrange
Context.Categories.AddRange(TestDataBuilder.BuildCategories());
Context.Products.AddRange(TestDataBuilder.BuildProducts());
Context.Customers.AddRange(TestDataBuilder.BuildCustomers());
Context.Orders.AddRange(
new Order { Id = 1, CustomerId = 1, TotalAmount = 999.99m, OrderDate = DateTime.UtcNow.AddDays(-10) },
new Order { Id = 2, CustomerId = 1, TotalAmount = 49.99m, OrderDate = DateTime.UtcNow.AddDays(-5) },
new Order { Id = 3, CustomerId = 2, TotalAmount = 699.99m, OrderDate = DateTime.UtcNow.AddDays(-2) }
);
await Context.SaveChangesAsync();
// Act
var service = new OrderService(Context);
var orders = await service.GetCustomerOrdersAsync(1);
// Assert
Assert.Equal(2, orders.Count);
Assert.All(orders, o => Assert.Equal(1, o.CustomerId));
}
}3.3 InMemory的局限性
⚠️ 重要警告:
csharp
// 问题1: InMemory不强制执行外键约束
public async Task InMemoryAllowsOrphanedEntities()
{
var category = new Category { Id = 1, Name = "Test" };
Context.Categories.Add(category);
await Context.SaveChangesAsync();
var product = new Product { Id = 1, Name = "Orphan", CategoryId = 999 }; // 不存在的分类
Context.Products.Add(product);
await Context.SaveChangesAsync(); // ✅ InMemory允许!
// 但在真实数据库中会失败!
}
// 问题2: InMemory不执行数据库约束
public async Task InMemoryIgnoresUniqueConstraints()
{
Context.Products.AddRange(
new Product { Id = 1, Name = "Product1", Sku = "SKU001" },
new Product { Id = 2, Name = "Product2", Sku = "SKU001" } // 重复SKU
);
await Context.SaveChangesAsync(); // ✅ InMemory允许!
// 但真实数据库可能有 UNIQUE 约束
}
// 问题3: InMemory查询评估与真实数据库不同
public async Task InMemoryQueryDifference()
{
// 某些LINQ查询在InMemory中能工作,但在SQL Server中失败
var products = Context.Products
.Where(p => ComplexCalculation(p)) // 客户端评估
.ToList();
// InMemory: ✅ 工作(在客户端执行)
// SQL Server: ❌ 可能失败(无法翻译为SQL)
}解决方案: 使用 SQLite 内存模式进行更真实的测试。
4. SQLite内存模式
4.1 配置SQLite内存数据库
安装NuGet包:
bash
dotnet add package Microsoft.EntityFrameworkCore.Sqlite创建测试基类:
csharp
public abstract class SqliteTestBase : IDisposable
{
protected readonly AppDbContext Context;
private readonly DbConnection _connection;
protected SqliteTestBase()
{
// 创建内存连接
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
Context = new AppDbContext(options);
// 创建数据库架构
Context.Database.EnsureCreated();
}
public void Dispose()
{
Context.Dispose();
_connection.Close();
_connection.Dispose();
}
}编写测试(与InMemory相同接口):
csharp
public class ProductServiceSqliteTests : SqliteTestBase
{
[Fact]
public async Task GetProductsAsync_EnforcesForeignKeyConstraints()
{
// Arrange
var category = new Category { Id = 1, Name = "Electronics" };
Context.Categories.Add(category);
await Context.SaveChangesAsync();
var product = new Product
{
Id = 1,
Name = "Laptop",
CategoryId = 999 // 不存在的分类
};
Context.Products.Add(product);
// Act & Assert
await Assert.ThrowsAsync<DbUpdateException>(() =>
Context.SaveChangesAsync()); // ✅ 抛出异常!
}
}4.2 性能优化
重用数据库架构:
csharp
public class SharedDatabaseFixture : IDisposable
{
private static readonly object _lock = new();
private static bool _initialized;
public DbConnection Connection { get; }
public SharedDatabaseFixture()
{
Connection = new SqliteConnection("Filename=:memory:");
Connection.Open();
lock (_lock)
{
if (!_initialized)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(Connection)
.Options;
using var context = new AppDbContext(options);
context.Database.EnsureCreated();
_initialized = true;
}
}
}
public AppDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(Connection)
.Options;
return new AppDbContext(options);
}
public void Dispose()
{
Connection.Close();
Connection.Dispose();
}
}
// xUnit集合 fixture
public class DatabaseCollection : ICollectionFixture<SharedDatabaseFixture>
{
}
[Collection("DatabaseCollection")]
public class ProductServiceTests
{
private readonly SharedDatabaseFixture _fixture;
public ProductServiceTests(SharedDatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Test1()
{
using var context = _fixture.CreateContext();
// 测试代码...
}
}5. Mocking技术
5.1 使用Moq框架
安装NuGet包:
bash
dotnet add package MoqMock DbSet:
csharp
public class ProductServiceWithMockTests
{
[Fact]
public async Task GetProductByIdAsync_CallsRepository()
{
// Arrange
var mockSet = new Mock<DbSet<Product>>();
var mockContext = new Mock<AppDbContext>();
mockContext.Setup(c => c.Products).Returns(mockSet.Object);
var product = new Product { Id = 1, Name = "Test" };
mockSet.Setup(m => m.FindAsync(1))
.ReturnsAsync(product);
// Act
var service = new ProductService(mockContext.Object);
var result = await service.GetProductByIdAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("Test", result.Name);
mockSet.Verify(m => m.FindAsync(1), Times.Once);
}
}5.2 使用NSubstitute(更简洁)
安装NuGet包:
bash
dotnet add package NSubstituteMock示例:
csharp
using NSubstitute;
public class OrderServiceTests
{
[Fact]
public async Task CreateOrderAsync_ValidatesInventory()
{
// Arrange
var mockContext = Substitute.For<AppDbContext>();
var mockInventoryService = Substitute.For<IInventoryService>();
var product = new Product { Id = 1, Name = "Laptop", Stock = 10 };
mockContext.Products.FindAsync(1).Returns(product);
mockInventoryService.CheckStockAsync(1, 5).Returns(true);
// Act
var service = new OrderService(mockContext, mockInventoryService);
var order = await service.CreateOrderAsync(new CreateOrderRequest
{
ProductId = 1,
Quantity = 5
});
// Assert
Assert.NotNull(order);
await mockInventoryService.Received(1).CheckStockAsync(1, 5);
}
}5.3 Mock异步方法
csharp
// ❌ 错误: 不能直接Setup异步方法
mockSet.Setup(m => m.ToListAsync()).ReturnsAsync(list);
// ✅ 正确: Setup底层方法
mockSet.As<IQueryable<Product>>()
.Setup(m => m.Provider)
.Returns(new TestAsyncQueryProvider<Product>(list.AsQueryable().Provider));
mockSet.As<IQueryable<Product>>()
.Setup(m => m.Expression)
.Returns(list.AsQueryable().Expression);
mockSet.As<IQueryable<Product>>()
.Setup(m => m.ElementType)
.Returns(list.AsQueryable().ElementType);
mockSet.As<IQueryable<Product>>()
.Setup(m => m.GetEnumerator())
.Returns(list.AsQueryable().GetEnumerator());测试异步查询提供器(辅助类):
csharp
public class TestAsyncEnumerable<T> : EnumerableQuery<T>, IAsyncEnumerable<T>, IQueryable<T>
{
public TestAsyncEnumerable(IEnumerable<T> enumerable) : base(enumerable) { }
public TestAsyncEnumerable(Expression expression) : base(expression) { }
public IAsyncEnumerator<T> GetEnumerator()
{
return new TestAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
}
IAsyncEnumerator<T> IAsyncEnumerable<T>.GetAsyncEnumerator(CancellationToken cancellationToken)
{
return GetEnumerator();
}
}
public class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
{
private readonly IEnumerator<T> _inner;
public TestAsyncEnumerator(IEnumerator<T> inner)
{
_inner = inner;
}
public void Dispose() => _inner.Dispose();
public T Current => _inner.Current;
public ValueTask<bool> MoveNextAsync()
{
return new ValueTask<bool>(_inner.MoveNext());
}
public ValueTask DisposeAsync()
{
Dispose();
return default;
}
}6. 测试仓储模式
6.1 仓储接口
csharp
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<List<Product>> GetAllAsync();
Task<List<Product>> SearchAsync(string searchTerm);
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product> GetByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
public async Task<List<Product>> GetAllAsync()
{
return await _context.Products.ToListAsync();
}
public async Task<List<Product>> SearchAsync(string searchTerm)
{
return await _context.Products
.Where(p => p.Name.Contains(searchTerm))
.ToListAsync();
}
public async Task AddAsync(Product product)
{
_context.Products.Add(product);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(Product product)
{
_context.Entry(product).State = EntityState.Modified;
await _context.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var product = await GetByIdAsync(id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
}
}6.2 测试仓储实现
csharp
public class ProductRepositoryTests : SqliteTestBase
{
[Fact]
public async Task GetByIdAsync_ReturnsProduct()
{
// Arrange
var product = new Product { Id = 1, Name = "Test" };
Context.Products.Add(product);
await Context.SaveChangesAsync();
var repository = new ProductRepository(Context);
// Act
var result = await repository.GetByIdAsync(1);
// Assert
Assert.NotNull(result);
Assert.Equal("Test", result.Name);
}
[Fact]
public async Task SearchAsync_FindsMatchingProducts()
{
// Arrange
Context.Products.AddRange(
new Product { Name = "Laptop Pro" },
new Product { Name = "Laptop Basic" },
new Product { Name = "Phone" }
);
await Context.SaveChangesAsync();
var repository = new ProductRepository(Context);
// Act
var results = await repository.SearchAsync("Laptop");
// Assert
Assert.Equal(2, results.Count);
Assert.All(results, p => Assert.Contains("Laptop", p.Name));
}
}7. 测试异步代码
7.1 异步测试最佳实践
csharp
public class AsyncTests
{
// ✅ 好: 使用 async Task
[Fact]
public async Task AsyncMethod_ReturnsResult()
{
var result = await SomeAsyncMethod();
Assert.NotNull(result);
}
// ❌ 坏: 使用 async void
[Fact]
public async void AsyncMethod_Bad() // 不要这样做!
{
var result = await SomeAsyncMethod();
Assert.NotNull(result);
}
// ❌ 坏: 阻塞等待
[Fact]
public void AsyncMethod_Blocked() // 可能死锁!
{
var result = SomeAsyncMethod().Result; // 或 .Wait()
Assert.NotNull(result);
}
}7.2 测试并发场景
csharp
[Fact]
public async Task ConcurrentUpdates_HandleConflicts()
{
// Arrange
var product = new Product { Id = 1, Name = "Original", Stock = 100 };
Context.Products.Add(product);
await Context.SaveChangesAsync();
// Act - 模拟并发更新
var task1 = UpdateProductStockAsync(1, -10);
var task2 = UpdateProductStockAsync(1, -20);
await Task.WhenAll(task1, task2);
// Assert
var updated = await Context.Products.FindAsync(1);
Assert.Equal(70, updated.Stock); // 100 - 10 - 20
}
private async Task UpdateProductStockAsync(int productId, int change)
{
using var context = CreateNewContext(); // 新上下文模拟并发
var product = await context.Products.FindAsync(productId);
product.Stock += change;
await context.SaveChangesAsync();
}8. 最佳实践与常见陷阱
8.1 最佳实践清单
✅ 应该做的:
每个测试使用独立数据库
csharpUseInMemoryDatabase(Guid.NewGuid().ToString())清理测试数据
csharppublic void Dispose() => Context.Database.EnsureDeleted();使用有意义的测试名
csharpGetProductsAsync_WhenNoData_ReturnsEmptyList()AAA模式(Arrange-Act-Assert)
csharp// Arrange - 准备 // Act - 执行 // Assert - 验证测试边界条件
csharp[Theory] [InlineData(0)] [InlineData(-1)] [InlineData(int.MaxValue)] public async Task HandleEdgeCases(int value) { ... }
8.2 常见陷阱
❌ 不应该做的:
不要在测试间共享状态
csharp// ❌ 坏: 静态数据 private static List<Product> _products; // ✅ 好: 每个测试独立 [Fact] public async Task Test1() { var products = BuildTestData(); }不要测试框架功能
csharp// ❌ 无意义 [Fact] public async Task SaveChanges_PersistsData() { Context.Products.Add(product); await Context.SaveChangesAsync(); Assert.NotNull(product.Id); // 这是EF的功能,不需要测试 }不要忘记异步Dispose
csharp// ✅ 好 public class TestFixture : IAsyncDisposable { public async ValueTask DisposeAsync() { await Context.DisposeAsync(); } }
8.3 测试覆盖率目标
目标覆盖率:
- 业务逻辑: 80%+
- 数据访问: 60-70%
- 基础设施: 40-50%
注意: 100%覆盖率不现实,关注关键路径总结
EF Core单元测试是质量保证的核心:
策略选择
- InMemory - 快速原型,简单测试
- SQLite内存 - 推荐,平衡速度与真实性
- Mocking - 隔离外部依赖
关键原则
- 独立性 - 每个测试隔离
- 可重复 - deterministic结果
- 快速 - 毫秒级执行
- 清晰 - AAA模式,易读
掌握这些技巧,你可以构建可靠的测试套件!