Skip to content

模拟 DbContext 的替代方案 ​

概述 ​

在单元测试中,有时我们并不想真正使用数据库(即使是内存数据库),而是希望通过 Mock(模拟)技术来隔离业务逻辑,只测试特定的功能点。本节介绍如何使用 Moq、NSubstitute 等 Mocking 框架来模拟 EF Core 的 DbContext 和 DbSet。

何时使用 Mock? ​

场景推荐方案原因
纯业务逻辑测试✅ Mock不需要数据库
简单 CRUD 测试❌ Mock直接测数据库更简单
复杂查询测试❌ MockLINQ to Objects ≠ LINQ to SQL
服务层集成测试⚠️ 混合Mock 外部依赖,真实 DB
领域逻辑测试✅ Mock关注点在业务规则

使用 Moq 模拟 DbContext ​

1. 安装 NuGet 包 ​

bash
dotnet add package Moq

2. 基础 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 NSubstitute

2. 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 真实数据库对比 ​

维度MockInMemorySQLiteTestcontainers
速度⚡ 最快🚀 快🚀 快🐢 慢
真实性❌ 低⚠️ 中✅ 高✅ 最高
配置复杂度⚠️ 中✅ 简单✅ 简单⚠️ 复杂
查询测试❌ 差⚠️ 一般✅ 好✅ 完美
关系测试❌ 差⚠️ 一般✅ 好✅ 完美
并发测试⚠️ 需手动❌ 不支持✅ 支持✅ 支持
隔离性✅ 完美✅ 好✅ 好✅ 好
维护成本⚠️ 高✅ 低✅ 低⚠️ 中

最佳实践 ​

✅ 推荐做法 ​

  1. 优先测试行为而非实现: 不要过度验证 Mock 调用次数
  2. 使用集成测试补充: Mock 测试不能替代集成测试
  3. 保持 Mock 简单: 复杂的 Mock 逻辑容易出错
  4. 命名清晰的 Mock: mockRepository, mockPaymentGateway
  5. 验证关键交互: 只验证重要的方法调用

❌ 避免的陷阱 ​

  1. 不要 Mock 一切: 简单的 CRUD 不需要 Mock
  2. 不要 Mock LINQ 查询: LINQ to Objects ≠ LINQ to SQL
  3. 不要过度指定 Mock: 使用 It.IsAny<T>() 而非具体值
  4. 不要忘记验证: Mock 的优势在于可以验证交互

总结 ​

Mock 的适用场景 ​

  • ✅ 单元测试: 隔离业务逻辑,不依赖数据库
  • ✅ 外部服务: Mock API 客户端、消息队列等
  • ✅ 快速反馈: 比数据库测试快得多
  • ✅ 边界情况: 轻松模拟异常场景

Mock 的局限性 ​

  • ❌ 查询逻辑: 无法测试 LINQ to SQL 转换
  • ❌ 数据库行为: 不测试约束、触发器、索引
  • ❌ 迁移测试: 无法测试数据库迁移
  • ❌ 性能测试: Mock 的性能特征完全不同

建议的测试策略 ​

单元测试 (60%): 使用 Mock 测试纯业务逻辑
集成测试 (30%): 使用 SQLite/Testcontainers 测试数据库交互
端到端测试 (10%): 使用真实环境测试完整流程

Mock 是强大的工具,但要明智地使用!结合多种测试策略,才能构建可靠的 EF Core 应用。

基于 MIT 许可发布