Appearance
模拟 DbContext 的替代方案
概述
在单元测试中,有时我们并不想真正使用数据库(即使是内存数据库),而是希望通过 Mock(模拟)技术来隔离业务逻辑,只测试特定的功能点。本节介绍如何使用 Moq、NSubstitute 等 Mocking 框架来模拟 EF Core 的 DbContext 和 DbSet。
何时使用 Mock?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 纯业务逻辑测试 | ✅ Mock | 不需要数据库 |
| 简单 CRUD 测试 | ❌ Mock | 直接测数据库更简单 |
| 复杂查询测试 | ❌ Mock | LINQ to Objects ≠ LINQ to SQL |
| 服务层集成测试 | ⚠️ 混合 | Mock 外部依赖,真实 DB |
| 领域逻辑测试 | ✅ Mock | 关注点在业务规则 |
使用 Moq 模拟 DbContext
1. 安装 NuGet 包
bash
dotnet add package Moq2. 基础 Mock 示例
csharp
using Moq;
using Microsoft.EntityFrameworkCore;
public class ProductServiceTests
{
[Fact]
public async Task GetProductById_ShouldReturnCorrectProduct()
{
// Arrange: 准备测试数据
var products = new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 999.99m },
new Product { Id = 2, Name = "Phone", Price = 599.99m },
new Product { Id = 3, Name = "Tablet", Price = 399.99m }
}.AsQueryable();
// 创建 Mock DbSet
var mockSet = new Mock<DbSet<Product>>();
mockSet.As<IAsyncEnumerable<Product>>()
.Setup(m => m.GetAsyncEnumerator(CancellationToken.None))
.Returns(new TestAsyncEnumerator(products.GetEnumerator()));
mockSet.As<IQueryable<Product>>()
.Setup(m => m.Provider)
.Returns(products.Provider);
mockSet.As<IQueryable<Product>>()
.Setup(m => m.Expression)
.Returns(products.Expression);
mockSet.As<IQueryable<Product>>()
.Setup(m => m.ElementType)
.Returns(products.ElementType);
mockSet.As<IQueryable<Product>>()
.Setup(m => m.GetEnumerator())
.Returns(products.GetEnumerator());
// 创建 Mock DbContext
var mockContext = new Mock<AppDbContext>();
mockContext.Setup(c => c.Products).Returns(mockSet.Object);
// Act: 调用被测试的方法
var service = new ProductService(mockContext.Object);
var result = await service.GetProductByIdAsync(2);
// Assert: 验证结果
Assert.NotNull(result);
Assert.Equal("Phone", result.Name);
Assert.Equal(599.99m, result.Price);
}
}
// 辅助类: 异步枚举器
public class TestAsyncEnumerator<T> : IAsyncEnumerator<T>
{
private readonly IEnumerator<T> _inner;
public TestAsyncEnumerator(IEnumerator<T> inner)
{
_inner = inner;
}
public T Current => _inner.Current;
public ValueTask<bool> MoveNextAsync()
{
return new ValueTask<bool>(_inner.MoveNext());
}
public ValueTask DisposeAsync()
{
_inner.Dispose();
return default;
}
}3. 封装 Mock 辅助方法
csharp
public static class MockHelper
{
/// <summary>
/// 创建可查询的 Mock DbSet
/// </summary>
public static Mock<DbSet<T>> CreateMockDbSet<T>(IQueryable<T> data) where T : class
{
var mockSet = new Mock<DbSet<T>>();
mockSet.As<IAsyncEnumerable<T>>()
.Setup(m => m.GetAsyncEnumerator(It.IsAny<CancellationToken>()))
.Returns(new TestAsyncEnumerator<T>(data.GetEnumerator()));
mockSet.As<IQueryable<T>>()
.Setup(m => m.Provider)
.Returns(data.Provider);
mockSet.As<IQueryable<T>>()
.Setup(m => m.Expression)
.Returns(data.Expression);
mockSet.As<IQueryable<T>>()
.Setup(m => m.ElementType)
.Returns(data.ElementType);
mockSet.As<IQueryable<T>>()
.Setup(m => m.GetEnumerator())
.Returns(data.GetEnumerator());
return mockSet;
}
/// <summary>
/// 支持 Add/Remove/Find 的 Mock DbSet
/// </summary>
public static Mock<DbSet<T>> CreateWritableMockSet<T>(List<T> data) where T : class
{
var queryable = data.AsQueryable();
var mockSet = CreateMockDbSet(queryable);
// 模拟 Add
mockSet.Setup(s => s.Add(It.IsAny<T>()))
.Callback<T>(data.Add);
// 模拟 Remove
mockSet.Setup(s => s.Remove(It.IsAny<T>()))
.Callback<T>(item => data.Remove(item));
// 模拟 Find
mockSet.Setup(s => s.Find(It.IsAny<object[]>()))
.Returns<object[]>(ids => data.FirstOrDefault(d =>
d.GetType().GetProperty("Id")?.GetValue(d)?.ToString() == ids[0]?.ToString()));
return mockSet;
}
}
// 使用示例
public class SimplifiedMockTests
{
[Fact]
public async Task GetAllProducts_ShouldReturnAllItems()
{
// Arrange
var products = new List<Product>
{
new Product { Id = 1, Name = "Product A" },
new Product { Id = 2, Name = "Product B" }
}.AsQueryable();
var mockSet = MockHelper.CreateMockDbSet(products);
var mockContext = new Mock<AppDbContext>();
mockContext.Setup(c => c.Products).Returns(mockSet.Object);
// Act
var service = new ProductService(mockContext.Object);
var result = await service.GetAllProductsAsync();
// Assert
Assert.Equal(2, result.Count);
}
}使用 NSubstitute 模拟
1. 安装 NuGet 包
bash
dotnet add package NSubstitute2. NSubstitute 示例
csharp
using NSubstitute;
public class OrderServiceWithNSubstituteTests
{
[Fact]
public async Task PlaceOrder_ShouldCalculateTotalCorrectly()
{
// Arrange: 准备数据
var products = new List<Product>
{
new Product { Id = 1, Name = "Item 1", Price = 100m },
new Product { Id = 2, Name = "Item 2", Price = 200m }
}.AsQueryable();
// 创建 Mock DbSet (NSubstitute 语法更简洁)
var mockSet = Substitute.For<DbSet<Product>, IQueryable<Product>, IAsyncEnumerable<Product>>();
((IQueryable<Product>)mockSet).Provider.Returns(products.Provider);
((IQueryable<Product>)mockSet).Expression.Returns(products.Expression);
((IQueryable<Product>)mockSet).ElementType.Returns(products.ElementType);
((IQueryable<Product>)mockSet).GetEnumerator().Returns(products.GetEnumerator());
((IAsyncEnumerable<Product>)mockSet)
.GetAsyncEnumerator(Arg.Any<CancellationToken>())
.Returns(new TestAsyncEnumerator<Product>(products.GetEnumerator()));
// 创建 Mock DbContext
var mockContext = Substitute.For<AppDbContext>();
mockContext.Products.Returns(mockSet);
// Act
var service = new OrderService(mockContext);
var orderId = await service.PlaceOrderAsync(1, new List<int> { 1, 2 });
// Assert
Assert.NotEqual(0, orderId);
// 验证 SaveChangesAsync 被调用了一次
await mockContext.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}仓储模式 Mock
1. 定义仓储接口
csharp
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
Task SaveChangesAsync();
IQueryable<T> Query();
}
public class ProductRepository : IRepository<Product>
{
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 AddAsync(Product entity)
{
_context.Products.Add(entity);
await SaveChangesAsync();
}
public void Update(Product entity)
{
_context.Products.Update(entity);
}
public void Delete(Product entity)
{
_context.Products.Remove(entity);
}
public async Task SaveChangesAsync()
{
await _context.SaveChangesAsync();
}
public IQueryable<Product> Query()
{
return _context.Products.AsQueryable();
}
}2. Mock 仓储进行测试
csharp
public class ProductServiceWithRepositoryTests
{
[Fact]
public async Task GetExpensiveProducts_ShouldFilterByPrice()
{
// Arrange: Mock 仓储
var mockRepository = new Mock<IRepository<Product>>();
var products = new List<Product>
{
new Product { Id = 1, Name = "Cheap", Price = 10m },
new Product { Id = 2, Name = "Moderate", Price = 100m },
new Product { Id = 3, Name = "Expensive", Price = 1000m }
}.AsQueryable();
var mockSet = MockHelper.CreateMockDbSet(products);
mockRepository.Setup(r => r.Query()).Returns(mockSet.Object);
// Act: 测试服务(使用仓储)
var service = new ProductService(mockRepository.Object);
var expensiveProducts = await service.GetExpensiveProductsAsync(500);
// Assert
Assert.Single(expensiveProducts);
Assert.Equal("Expensive", expensiveProducts.First().Name);
}
[Fact]
public async Task AddProduct_ShouldCallRepository()
{
// Arrange
var mockRepository = new Mock<IRepository<Product>>();
var service = new ProductService(mockRepository.Object);
var newProduct = new Product { Name = "New", Price = 50m };
// Act
await service.AddProductAsync(newProduct);
// Assert: 验证仓储方法被调用
mockRepository.Verify(r => r.AddAsync(newProduct), Times.Once);
mockRepository.Verify(r => r.SaveChangesAsync(), Times.Once);
}
}部分 Mock: 混合真实与模拟
场景: Mock 外部 API,真实数据库
csharp
public class OrderProcessingService
{
private readonly AppDbContext _context;
private readonly IPaymentGateway _paymentGateway;
public OrderProcessingService(
AppDbContext context,
IPaymentGateway paymentGateway)
{
_context = context;
_paymentGateway = paymentGateway;
}
public async Task ProcessOrderAsync(int orderId)
{
var order = await _context.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
throw new InvalidOperationException("Order not found");
// 调用外部支付网关(Mock)
var paymentResult = await _paymentGateway.ChargeAsync(order.TotalAmount);
if (paymentResult.Success)
{
order.Status = OrderStatus.Paid;
await _context.SaveChangesAsync();
}
}
}
public class OrderProcessingTests
{
[Fact]
public async Task ProcessOrder_WithSuccessfulPayment_ShouldUpdateStatus()
{
// 使用真实的 SQLite 内存数据库
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var context = new AppDbContext(options);
await context.Database.EnsureCreatedAsync();
// 添加测试订单
var order = new Order
{
Id = 1,
TotalAmount = 100m,
Status = OrderStatus.Pending
};
context.Orders.Add(order);
await context.SaveChangesAsync();
// Mock 支付网关
var mockPaymentGateway = new Mock<IPaymentGateway>();
mockPaymentGateway.Setup(g => g.ChargeAsync(100m))
.ReturnsAsync(new PaymentResult { Success = true, TransactionId = "TXN123" });
// Act
var service = new OrderProcessingService(context, mockPaymentGateway.Object);
await service.ProcessOrderAsync(1);
// Assert
var updatedOrder = await context.Orders.FindAsync(1);
Assert.NotNull(updatedOrder);
Assert.Equal(OrderStatus.Paid, updatedOrder.Status);
// 验证支付网关被调用
mockPaymentGateway.Verify(g => g.ChargeAsync(100m), Times.Once);
}
}高级 Mock 技巧
1. 模拟并发冲突
csharp
[Fact]
public async Task UpdateProduct_WithConcurrencyConflict_ShouldThrowException()
{
// Arrange
var mockContext = new Mock<AppDbContext>();
var mockSet = new Mock<DbSet<Product>>();
mockContext.Setup(c => c.Products).Returns(mockSet.Object);
// 模拟 SaveChangesAsync 抛出并发异常
mockContext.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("Concurrency conflict"));
// Act & Assert
var service = new ProductService(mockContext.Object);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() =>
service.UpdateProductAsync(new Product { Id = 1, Price = 100m }));
}2. 模拟事务行为
csharp
[Fact]
public async Task TransactionalOperation_ShouldCommitOnSuccess()
{
var mockContext = new Mock<AppDbContext>();
var mockTransaction = new Mock<IDbContextTransaction>();
mockContext.Setup(c => c.Database.BeginTransactionAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(mockTransaction.Object);
var service = new TransactionalService(mockContext.Object);
await service.ExecuteTransactionalOperationAsync();
// 验证事务被提交
mockTransaction.Verify(t => t.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
mockTransaction.Verify(t => t.RollbackAsync(It.IsAny<CancellationToken>()), Times.Never);
}3. 模拟原始 SQL 执行
csharp
[Fact]
public async Task ExecuteRawSql_ShouldCallDatabase()
{
var mockContext = new Mock<AppDbContext>();
var mockDatabase = new Mock<DatabaseFacade>(mockContext.Object);
mockContext.Setup(c => c.Database).Returns(mockDatabase.Object);
var executedCommands = new List<string>();
mockDatabase.Setup(d => d.ExecuteSqlRawAsync(
It.IsAny<string>(),
It.IsAny<IEnumerable<object>>(),
It.IsAny<CancellationToken>()))
.Callback<string, IEnumerable<object>, CancellationToken>((sql, params, ct) =>
{
executedCommands.Add(sql);
})
.ReturnsAsync(1);
var service = new RawSqlService(mockContext.Object);
await service.ExecuteCustomSqlAsync("UPDATE Products SET Price = 0");
Assert.Contains(executedCommands, sql => sql.Contains("UPDATE Products"));
}Mock vs 真实数据库对比
| 维度 | Mock | InMemory | SQLite | Testcontainers |
|---|---|---|---|---|
| 速度 | ⚡ 最快 | 🚀 快 | 🚀 快 | 🐢 慢 |
| 真实性 | ❌ 低 | ⚠️ 中 | ✅ 高 | ✅ 最高 |
| 配置复杂度 | ⚠️ 中 | ✅ 简单 | ✅ 简单 | ⚠️ 复杂 |
| 查询测试 | ❌ 差 | ⚠️ 一般 | ✅ 好 | ✅ 完美 |
| 关系测试 | ❌ 差 | ⚠️ 一般 | ✅ 好 | ✅ 完美 |
| 并发测试 | ⚠️ 需手动 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 隔离性 | ✅ 完美 | ✅ 好 | ✅ 好 | ✅ 好 |
| 维护成本 | ⚠️ 高 | ✅ 低 | ✅ 低 | ⚠️ 中 |
最佳实践
✅ 推荐做法
- 优先测试行为而非实现: 不要过度验证 Mock 调用次数
- 使用集成测试补充: Mock 测试不能替代集成测试
- 保持 Mock 简单: 复杂的 Mock 逻辑容易出错
- 命名清晰的 Mock:
mockRepository,mockPaymentGateway - 验证关键交互: 只验证重要的方法调用
❌ 避免的陷阱
- 不要 Mock 一切: 简单的 CRUD 不需要 Mock
- 不要 Mock LINQ 查询: LINQ to Objects ≠ LINQ to SQL
- 不要过度指定 Mock: 使用
It.IsAny<T>()而非具体值 - 不要忘记验证: Mock 的优势在于可以验证交互
总结
Mock 的适用场景
- ✅ 单元测试: 隔离业务逻辑,不依赖数据库
- ✅ 外部服务: Mock API 客户端、消息队列等
- ✅ 快速反馈: 比数据库测试快得多
- ✅ 边界情况: 轻松模拟异常场景
Mock 的局限性
- ❌ 查询逻辑: 无法测试 LINQ to SQL 转换
- ❌ 数据库行为: 不测试约束、触发器、索引
- ❌ 迁移测试: 无法测试数据库迁移
- ❌ 性能测试: Mock 的性能特征完全不同
建议的测试策略
单元测试 (60%): 使用 Mock 测试纯业务逻辑
集成测试 (30%): 使用 SQLite/Testcontainers 测试数据库交互
端到端测试 (10%): 使用真实环境测试完整流程Mock 是强大的工具,但要明智地使用!结合多种测试策略,才能构建可靠的 EF Core 应用。