Appearance
内存数据库(InMemory)的使用与局限
概述
EF Core InMemory 提供程序是一个轻量级的、基于内存的数据库实现,专为测试和原型开发设计。它将数据存储在应用程序的内存中,无需安装任何外部数据库引擎。
核心特点
- 零配置: 无需安装数据库服务器
- 快速执行: 所有操作都在内存中完成
- 跨平台: 支持所有 .NET 运行时平台
- 完全兼容 EF Core: 支持大多数 EF Core 功能
- 局限性: 不模拟关系型数据库的所有行为
快速开始
1. 安装 NuGet 包
bash
dotnet add package Microsoft.EntityFrameworkCore.InMemory2. 配置 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
✅ 适合的场景
- 纯逻辑测试: 测试业务逻辑而非数据库交互
- 快速原型: 快速验证概念,不需要持久化
- UI演示: 演示应用功能,无需配置数据库
- 简单CRUD: 不涉及复杂关系和约束
❌ 不适合的场景
- 集成测试: 需要测试真实的数据库行为
- 性能测试: InMemory 的性能特征与真实数据库完全不同
- 并发测试: 需要测试乐观/悲观并发
- 迁移测试: 数据库迁移必须在真实数据库上测试
- 复杂查询: 涉及 SQL 特定功能的查询
总结
优势
- ✅ 快速设置和执行
- ✅ 无需外部依赖
- ✅ 适合简单的单元测试
- ✅ 易于隔离测试数据
局限
- ❌ 不模拟关系型数据库的所有行为
- ❌ 不强制执行参照完整性
- ❌ 不支持真正的并发控制
- ❌ 事务语义不完整
- ❌ 不生成真实SQL
建议
- 单元测试: 优先使用 InMemory 或 Mock
- 集成测试: 使用 SQLite In-Memory 或 Testcontainers
- 生产环境: 始终使用真实数据库
InMemory 是测试工具箱中有用的工具,但不能完全替代使用真实数据库的集成测试。根据测试目标选择合适的策略!