Skip to content

单元测试 ​

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 --> H

3. 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 Moq

Mock 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 NSubstitute

Mock示例:

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 最佳实践清单 ​

✅ 应该做的:

  1. 每个测试使用独立数据库

    csharp
    UseInMemoryDatabase(Guid.NewGuid().ToString())
  2. 清理测试数据

    csharp
    public void Dispose() => Context.Database.EnsureDeleted();
  3. 使用有意义的测试名

    csharp
    GetProductsAsync_WhenNoData_ReturnsEmptyList()
  4. AAA模式(Arrange-Act-Assert)

    csharp
    // Arrange - 准备
    // Act - 执行
    // Assert - 验证
  5. 测试边界条件

    csharp
    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(int.MaxValue)]
    public async Task HandleEdgeCases(int value) { ... }

8.2 常见陷阱 ​

❌ 不应该做的:

  1. 不要在测试间共享状态

    csharp
    // ❌ 坏: 静态数据
    private static List<Product> _products;
    
    // ✅ 好: 每个测试独立
    [Fact]
    public async Task Test1()
    {
        var products = BuildTestData();
    }
  2. 不要测试框架功能

    csharp
    // ❌ 无意义
    [Fact]
    public async Task SaveChanges_PersistsData()
    {
        Context.Products.Add(product);
        await Context.SaveChangesAsync();
        
        Assert.NotNull(product.Id);  // 这是EF的功能,不需要测试
    }
  3. 不要忘记异步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 - 隔离外部依赖

关键原则 ​

  1. 独立性 - 每个测试隔离
  2. 可重复 - deterministic结果
  3. 快速 - 毫秒级执行
  4. 清晰 - AAA模式,易读

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

基于 MIT 许可发布