Skip to content

内存数据库(InMemory)的使用与局限 ​

概述 ​

EF Core InMemory 提供程序是一个轻量级的、基于内存的数据库实现,专为测试和原型开发设计。它将数据存储在应用程序的内存中,无需安装任何外部数据库引擎。

核心特点 ​

  • 零配置: 无需安装数据库服务器
  • 快速执行: 所有操作都在内存中完成
  • 跨平台: 支持所有 .NET 运行时平台
  • 完全兼容 EF Core: 支持大多数 EF Core 功能
  • 局限性: 不模拟关系型数据库的所有行为

快速开始 ​

1. 安装 NuGet 包 ​

bash
dotnet add package Microsoft.EntityFrameworkCore.InMemory

2. 配置 InMemory 数据库 ​

csharp
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// 方式 1: 基本配置
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("TestDb"));

// 方式 2: 使用唯一名称(推荐用于测试隔离)
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));

// 方式 3: 手动创建上下文(适合单元测试)
public class TestFixture : IDisposable
{
    public AppDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
            .Options;
        
        return new AppDbContext(options);
    }
    
    public void Dispose() { }
}

3. 定义 DbContext ​

csharp
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) 
        : base(options) { }
    
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

在单元测试中的应用 ​

场景 1: 基础 CRUD 测试 ​

csharp
using Xunit;
using Microsoft.EntityFrameworkCore;

public class ProductRepositoryTests
{
    private AppDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
            .Options;
        
        var context = new AppDbContext(options);
        context.Database.EnsureCreated(); // 确保数据库已创建
        return context;
    }
    
    [Fact]
    public async Task AddProduct_ShouldSaveToDatabase()
    {
        // Arrange
        using var context = CreateContext();
        var product = new Product 
        { 
            Name = "Test Product", 
            Price = 99.99m,
            CategoryId = 1
        };
        
        // Act
        context.Products.Add(product);
        await context.SaveChangesAsync();
        
        // Assert
        var savedProduct = await context.Products.FindAsync(product.Id);
        Assert.NotNull(savedProduct);
        Assert.Equal("Test Product", savedProduct.Name);
        Assert.Equal(99.99m, savedProduct.Price);
    }
    
    [Fact]
    public async Task UpdateProduct_ShouldModifyExistingRecord()
    {
        // Arrange
        using var context = CreateContext();
        var product = new Product { Name = "Original", Price = 50m, CategoryId = 1 };
        context.Products.Add(product);
        await context.SaveChangesAsync();
        
        // Act
        product.Name = "Updated";
        product.Price = 75m;
        await context.SaveChangesAsync();
        
        // Assert
        var updated = await context.Products.FindAsync(product.Id);
        Assert.Equal("Updated", updated!.Name);
        Assert.Equal(75m, updated.Price);
    }
    
    [Fact]
    public async Task DeleteProduct_ShouldRemoveFromDatabase()
    {
        // Arrange
        using var context = CreateContext();
        var product = new Product { Name = "ToDelete", Price = 30m, CategoryId = 1 };
        context.Products.Add(product);
        await context.SaveChangesAsync();
        
        // Act
        context.Products.Remove(product);
        await context.SaveChangesAsync();
        
        // Assert
        var deleted = await context.Products.FindAsync(product.Id);
        Assert.Null(deleted);
    }
}

场景 2: 查询逻辑测试 ​

csharp
public class QueryTests
{
    private AppDbContext SeedData()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase($"QueryTest_{Guid.NewGuid()}")
            .Options;
        
        var context = new AppDbContext(options);
        
        // 种子数据
        context.Categories.AddRange(
            new Category { Id = 1, Name = "Electronics" },
            new Category { Id = 2, Name = "Books" },
            new Category { Id = 3, Name = "Clothing" }
        );
        
        context.Products.AddRange(
            new Product { Id = 1, Name = "Laptop", Price = 999.99m, CategoryId = 1 },
            new Product { Id = 2, Name = "Phone", Price = 599.99m, CategoryId = 1 },
            new Product { Id = 3, Name = "C# Book", Price = 49.99m, CategoryId = 2 },
            new Product { Id = 4, Name = "T-Shirt", Price = 19.99m, CategoryId = 3 }
        );
        
        context.SaveChanges();
        return context;
    }
    
    [Fact]
    public async Task FilterByPrice_ShouldReturnCorrectProducts()
    {
        using var context = SeedData();
        
        var expensiveProducts = await context.Products
            .Where(p => p.Price > 100)
            .ToListAsync();
        
        Assert.Equal(2, expensiveProducts.Count);
        Assert.All(expensiveProducts, p => Assert.True(p.Price > 100));
    }
    
    [Fact]
    public async Task IncludeCategory_ShouldLoadRelatedData()
    {
        using var context = SeedData();
        
        var productsWithCategory = await context.Products
            .Include(p => p.Category)
            .ToListAsync();
        
        Assert.Equal(4, productsWithCategory.Count);
        Assert.All(productsWithCategory, p => Assert.NotNull(p.Category));
    }
    
    [Fact]
    public async Task GroupByCategory_ShouldReturnGroupedResults()
    {
        using var context = SeedData();
        
        var grouped = await context.Products
            .GroupBy(p => p.CategoryId)
            .Select(g => new 
            { 
                CategoryId = g.Key, 
                Count = g.Count(),
                AvgPrice = g.Average(p => p.Price)
            })
            .ToListAsync();
        
        Assert.Equal(3, grouped.Count);
        Assert.Contains(grouped, g => g.CategoryId == 1 && g.Count == 2);
    }
}

场景 3: 业务逻辑测试 ​

csharp
public class OrderService
{
    private readonly AppDbContext _context;
    
    public OrderService(AppDbContext context)
    {
        _context = context;
    }
    
    public async Task<int> PlaceOrderAsync(int customerId, List<int> productIds)
    {
        var products = await _context.Products
            .Where(p => productIds.Contains(p.Id))
            .ToListAsync();
        
        if (!products.Any())
            throw new ArgumentException("No valid products found");
        
        var order = new Order
        {
            CustomerId = customerId,
            OrderDate = DateTime.UtcNow,
            TotalAmount = products.Sum(p => p.Price),
            Status = OrderStatus.Pending
        };
        
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        
        return order.Id;
    }
}

public class OrderServiceTests
{
    private AppDbContext CreateContextWithData()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase($"OrderTest_{Guid.NewGuid()}")
            .Options;
        
        var context = new AppDbContext(options);
        
        context.Products.AddRange(
            new Product { Id = 1, Name = "Product A", Price = 100m, CategoryId = 1 },
            new Product { Id = 2, Name = "Product B", Price = 200m, CategoryId = 1 },
            new Product { Id = 3, Name = "Product C", Price = 300m, CategoryId = 2 }
        );
        
        context.SaveChanges();
        return context;
    }
    
    [Fact]
    public async Task PlaceOrder_ShouldCalculateCorrectTotal()
    {
        using var context = CreateContextWithData();
        var service = new OrderService(context);
        
        var orderId = await service.PlaceOrderAsync(1, new List<int> { 1, 2 });
        
        var order = await context.Orders.FindAsync(orderId);
        Assert.NotNull(order);
        Assert.Equal(300m, order.TotalAmount); // 100 + 200
    }
    
    [Fact]
    public async Task PlaceOrder_WithInvalidProducts_ShouldThrowException()
    {
        using var context = CreateContextWithData();
        var service = new OrderService(context);
        
        await Assert.ThrowsAsync<ArgumentException>(
            () => service.PlaceOrderAsync(1, new List<int> { 999 }));
    }
}

在集成测试中的应用 ​

测试 Web API ​

csharp
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class ProductsApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    
    public ProductsApiIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // 替换为 InMemory 数据库
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
                
                if (descriptor != null)
                    services.Remove(descriptor);
                
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("IntegrationTestDb"));
            });
        });
    }
    
    [Fact]
    public async Task GetProducts_ShouldReturnAllProducts()
    {
        var client = _factory.CreateClient();
        
        // 准备数据
        using var scope = _factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        context.Products.AddRange(
            new Product { Name = "Product 1", Price = 10m, CategoryId = 1 },
            new Product { Name = "Product 2", Price = 20m, CategoryId = 1 }
        );
        await context.SaveChangesAsync();
        
        // Act
        var response = await client.GetAsync("/api/products");
        response.EnsureSuccessStatusCode();
        
        var products = await JsonSerializer.DeserializeAsync<List<Product>>(
            await response.Content.ReadAsStreamAsync());
        
        // Assert
        Assert.NotNull(products);
        Assert.Equal(2, products.Count);
    }
    
    [Fact]
    public async Task CreateProduct_ShouldSaveToDatabase()
    {
        var client = _factory.CreateClient();
        
        var newProduct = new 
        {
            Name = "New Product",
            Price = 99.99m,
            CategoryId = 1
        };
        
        var content = new StringContent(
            JsonSerializer.Serialize(newProduct),
            Encoding.UTF8,
            "application/json");
        
        var response = await client.PostAsync("/api/products", content);
        response.EnsureSuccessStatusCode();
        
        // 验证数据已保存
        using var scope = _factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var savedProduct = await context.Products.LastAsync();
        
        Assert.Equal("New Product", savedProduct.Name);
        Assert.Equal(99.99m, savedProduct.Price);
    }
}

常见陷阱与解决方案 ​

陷阱 1: 关系约束不强制执行 ​

问题: InMemory 不会强制执行外键约束

csharp
[Fact]
public async Task InMemory_DoesNotEnforceFKConstraints()
{
    using var context = CreateContext();
    
    // 这不会抛出异常(即使 CategoryId=999 不存在)
    var product = new Product 
    { 
        Name = "Orphan", 
        Price = 10m, 
        CategoryId = 999 // 不存在的分类
    };
    
    context.Products.Add(product);
    await context.SaveChangesAsync(); // 成功!
    
    // 但在真实数据库中会失败
}

解决方案: 手动验证引用完整性

csharp
public async Task<int> AddProductAsync(Product product)
{
    // 手动检查外键是否存在
    var categoryExists = await _context.Categories
        .AnyAsync(c => c.Id == product.CategoryId);
    
    if (!categoryExists)
        throw new InvalidOperationException($"Category {product.CategoryId} not found");
    
    _context.Products.Add(product);
    await _context.SaveChangesAsync();
    return product.Id;
}

陷阱 2: 并发控制不起作用 ​

问题: InMemory 不支持真正的并发冲突检测

csharp
[Fact]
public async Task InMemory_DoesNotDetectConcurrencyConflicts()
{
    using var context = CreateContext();
    
    var product = new Product { Name = "Original", Price = 10m, CategoryId = 1 };
    context.Products.Add(product);
    await context.SaveChangesAsync();
    
    // 模拟并发更新(在真实数据库中会失败)
    product.RowVersion = Convert.FromBase64String("AAAAAAAABB8=");
    product.Price = 20m;
    
    await context.SaveChangesAsync(); // 成功!(应该抛出 DbUpdateConcurrencyException)
}

解决方案: 跳过并发测试或使用真实数据库

陷阱 3: 事务行为不同 ​

问题: InMemory 的事务语义与真实数据库不同

csharp
[Fact]
public async Task InMemory_TransactionBehaviorDiffers()
{
    using var context = CreateContext();
    
    using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        context.Products.Add(new Product 
        { 
            Name = "Product 1", 
            Price = 10m, 
            CategoryId = 1 
        });
        await context.SaveChangesAsync();
        
        // 即使回滚,InMemory 也可能保留数据
        await transaction.RollbackAsync();
        
        // 断言可能失败
        Assert.Equal(0, await context.Products.CountAsync());
    }
    catch (Exception)
    {
        await transaction.RollbackAsync();
    }
}

最佳实践 ​

1. 测试隔离 ​

csharp
public class IsolatedTests : IDisposable
{
    private readonly string _databaseName;
    
    public IsolatedTests()
    {
        // 每个测试实例使用唯一的数据库名
        _databaseName = $"TestDb_{Guid.NewGuid()}";
    }
    
    private AppDbContext CreateContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(_databaseName)
            .Options;
        
        return new AppDbContext(options);
    }
    
    public void Dispose()
    {
        // 清理数据库
        using var context = CreateContext();
        context.Database.EnsureDeleted();
    }
}

2. 种子数据辅助方法 ​

csharp
public static class TestDataSeeder
{
    public static AppDbContext SeedAllData(string dbName = null)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(dbName ?? $"SeedDb_{Guid.NewGuid()}")
            .Options;
        
        var context = new AppDbContext(options);
        
        // 添加分类
        var categories = new[]
        {
            new Category { Id = 1, Name = "Electronics" },
            new Category { Id = 2, Name = "Books" },
            new Category { Id = 3, Name = "Clothing" }
        };
        context.Categories.AddRange(categories);
        
        // 添加产品
        var products = new[]
        {
            new Product { Id = 1, Name = "Laptop", Price = 999.99m, CategoryId = 1 },
            new Product { Id = 2, Name = "Phone", Price = 599.99m, CategoryId = 1 },
            new Product { Id = 3, Name = "C# Book", Price = 49.99m, CategoryId = 2 },
            new Product { Id = 4, Name = "T-Shirt", Price = 19.99m, CategoryId = 3 }
        };
        context.Products.AddRange(products);
        
        context.SaveChanges();
        return context;
    }
}

3. 异步测试模板 ​

csharp
[Fact]
public async Task AsyncOperation_ShouldWorkCorrectly()
{
    await using var context = TestDataSeeder.SeedAllData();
    
    // 使用异步操作
    var products = await context.Products
        .Where(p => p.Price > 50)
        .ToListAsync();
    
    Assert.NotEmpty(products);
}

InMemory vs 真实数据库对比 ​

特性InMemory真实数据库
性能极快(毫秒级)较慢(网络+磁盘IO)
配置复杂度零配置需要安装和配置
关系约束❌ 不强制执行✅ 强制执行
并发控制❌ 不支持✅ 支持
事务语义⚠️ 简化版✅ 完整ACID
SQL生成❌ 不生成SQL✅ 生成SQL
索引优化❌ 无索引✅ 有索引
数据类型限制⚠️ 宽松✅ 严格
适用场景单元测试集成测试/生产

何时使用 InMemory ​

✅ 适合的场景 ​

  1. 纯逻辑测试: 测试业务逻辑而非数据库交互
  2. 快速原型: 快速验证概念,不需要持久化
  3. UI演示: 演示应用功能,无需配置数据库
  4. 简单CRUD: 不涉及复杂关系和约束

❌ 不适合的场景 ​

  1. 集成测试: 需要测试真实的数据库行为
  2. 性能测试: InMemory 的性能特征与真实数据库完全不同
  3. 并发测试: 需要测试乐观/悲观并发
  4. 迁移测试: 数据库迁移必须在真实数据库上测试
  5. 复杂查询: 涉及 SQL 特定功能的查询

总结 ​

优势 ​

  • ✅ 快速设置和执行
  • ✅ 无需外部依赖
  • ✅ 适合简单的单元测试
  • ✅ 易于隔离测试数据

局限 ​

  • ❌ 不模拟关系型数据库的所有行为
  • ❌ 不强制执行参照完整性
  • ❌ 不支持真正的并发控制
  • ❌ 事务语义不完整
  • ❌ 不生成真实SQL

建议 ​

  • 单元测试: 优先使用 InMemory 或 Mock
  • 集成测试: 使用 SQLite In-Memory 或 Testcontainers
  • 生产环境: 始终使用真实数据库

InMemory 是测试工具箱中有用的工具,但不能完全替代使用真实数据库的集成测试。根据测试目标选择合适的策略!

基于 MIT 许可发布