EF Core内存数据库单元测试重复键问题及代码重构咨询
问题解答与代码重构
1. 错误产生的原因
这个问题的核心在于两点:
- EF Core内存数据库的共享特性:同名的内存数据库本质是同一个实例,所有使用该名称的
DbContext都会连接到同一个数据库。 - xUnit默认并行执行测试:当两个测试同时运行时,它们会同时往同一个内存数据库插入主键为
1、2、3的Movie记录——而数据库的主键约束不允许重复键值,因此抛出An item with the same key has already been added的错误。
单独运行测试时,数据库是独占的,不会有冲突;使用不同数据库名称时,每个测试对应独立的数据库实例,自然也不会有问题。
2. 代码重构方案
我们可以通过消除重复代码和避免测试间数据污染两个方向来重构,下面提供两种实用的方案:
方案一:为每个测试生成唯一内存数据库(推荐,简单无依赖)
这种方式让每个测试拥有独立的数据库实例,彻底避免并行冲突,同时提取重复的初始化代码到辅助方法,让测试更简洁:
public class MovieRepositoryTest { // 生成唯一的内存数据库名称,确保每个测试用独立数据库 private DbContextOptions<MovieDbContext> CreateUniqueInMemoryDbOptions() { return new DbContextOptionsBuilder<MovieDbContext>() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; } // 提取重复的测试数据初始化逻辑 private void SeedTestMovies(MovieDbContext context) { context.Movies.AddRange(new List<Movie> { new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" }, new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" }, new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" } }); context.SaveChanges(); } [Fact] public void GetAll_WhenCalled_ReturnsAllItems() { var options = CreateUniqueInMemoryDbOptions(); // 初始化测试数据 using (var context = new MovieDbContext(options)) { SeedTestMovies(context); } // 执行测试逻辑 using (var context = new MovieDbContext(options)) { var sut = new MovieRepository(context); var movies = sut.GetAll(); Assert.Equal(3, movies.Count()); } } [Fact] public void Search_ValidTitlePassed_ReturnsOneMovie() { var filters = new MovieFilters { Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" }; var options = CreateUniqueInMemoryDbOptions(); // 初始化测试数据 using (var context = new MovieDbContext(options)) { SeedTestMovies(context); } // 执行测试逻辑 using (var context = new MovieDbContext(options)) { var sut = new MovieRepository(context); var movies = sut.Search(filters); Assert.Single(movies); } } }
方案二:使用xUnit Fixture共享数据库(适合初始化成本高的场景)
如果测试数据初始化成本较高,可以用IClassFixture共享数据库实例,但需要注意测试间的数据隔离,并且通过集合标记避免并行冲突:
第一步:创建Fixture类(负责数据库初始化与清理)
public class MovieDbFixture : IDisposable { public DbContextOptions<MovieDbContext> DbOptions { get; } public MovieDbFixture() { // 创建共享的内存数据库 DbOptions = new DbContextOptionsBuilder<MovieDbContext>() .UseInMemoryDatabase(databaseName: "SharedMovieTestDb") .Options; // 初始化基础测试数据 using (var context = new MovieDbContext(DbOptions)) { SeedTestMovies(context); } } private void SeedTestMovies(MovieDbContext context) { context.Movies.AddRange(new List<Movie> { new Movie { Id = 1, Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" }, new Movie { Id = 2, Title = "Movie 2", YearOfRelease = 2018, Genre = "Action" }, new Movie { Id = 3, Title = "Movie 3", YearOfRelease = 2019, Genre = "Action" } }); context.SaveChanges(); } // 每个测试后清理数据,避免污染后续测试 public void Dispose() { using (var context = new MovieDbContext(DbOptions)) { context.Movies.RemoveRange(context.Movies); context.SaveChanges(); } } } // 定义测试集合,确保集合内的测试串行执行 [CollectionDefinition("MovieTestCollection")] public class MovieTestCollection : ICollectionFixture<MovieDbFixture> { // 空类,仅用于标记集合 }
第二步:重构测试类使用Fixture
[Collection("MovieTestCollection")] public class MovieRepositoryTest { private readonly MovieDbFixture _fixture; public MovieRepositoryTest(MovieDbFixture fixture) { _fixture = fixture; } [Fact] public void GetAll_WhenCalled_ReturnsAllItems() { using (var context = new MovieDbContext(_fixture.DbOptions)) { var sut = new MovieRepository(context); var movies = sut.GetAll(); Assert.Equal(3, movies.Count()); } } [Fact] public void Search_ValidTitlePassed_ReturnsOneMovie() { var filters = new MovieFilters { Title = "Movie 1", YearOfRelease = 2018, Genre = "Action" }; using (var context = new MovieDbContext(_fixture.DbOptions)) { var sut = new MovieRepository(context); var movies = sut.Search(filters); Assert.Single(movies); } } }
内容的提问来源于stack exchange,提问作者MarcosF8
相关产品推荐
相关产品推荐

