xUnit内存数据库测试中实体跟踪冲突问题排查与解决
使用xUnit和内存数据库测试更新服务时,运行UpdateContractPriceListGoodsItem_WhenEverythingIsOk_ShouldBeSucceeded测试用例,执行await _contractPriceListGoodsItemRepository.Update(contractPriceListGoodsItem);时触发以下错误:
The instance of entity type 'ContractPriceListGoodsItem' cannot be tracked because another instance with the key value '{Id: 2}' is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached.
这个错误是EF Core实体跟踪机制冲突导致的:
- 通过
GetContractPriceListGoodsItemById查询实体时,EF Core会自动跟踪返回的实体实例 - 调用
DbContext.Update(entity)时,EF Core尝试将该实体附加到上下文,但此时上下文已在跟踪同一主键(Id=2)的实例,触发跟踪冲突 - 测试类使用全局上下文,所有仓储共享同一个
PMOracleDbContext实例,种子数据或重复操作可能导致上下文残留跟踪的实体,加剧冲突
以下是按优先级排序的可行解决方式:
方案一:移除多余的Update调用(最优)
从仓储查询出的实体已处于EF Core跟踪状态,修改属性后直接保存即可,无需调用Update:
public async Task Update(TEntity entity,CancellationToken cancellationToken = default(CancellationToken)) { // 移除DbContext.Update(entity); 实体已被跟踪,修改后直接保存 await DbContext.SaveChangesAsync(cancellationToken); }
方案二:查询时禁用跟踪
如果查询实体仅用于更新,不需要保留原始跟踪实例,可在查询方法中添加AsNoTracking():
// 修改GetContractPriceListGoodsItemById的实现 public async Task<ContractPriceListGoodsItem> GetContractPriceListGoodsItemById(long id) { return await DbContext.ContractPriceListGoodsItems .AsNoTracking() // 禁用跟踪 .SingleOrDefaultAsync(x => x.Id == id); }
方案三:每个测试使用独立上下文
测试类当前使用全局上下文,可能导致跨测试的跟踪残留。改为在每个测试方法内创建上下文和仓储,保证测试隔离:
[Theory] [InlineData(2,35000)] public async Task UpdateContractPriceListGoodsItem_WhenEverythingIsOk_ShouldBeSucceeded(long contractPriceListGoodsItemId,long price) { // Arrange - 每个测试重新初始化上下文和仓储 using var dbContextMocker = new DbContextMocker(); var dbContext = dbContextMocker.SetDbContext(); var contractRepo = new ContractRepository(dbContext); var priceListOpItemRepo = new PriceListOperationItemRepository(dbContext); var priceListGoodsItemRepo = new PriceListGoodsItemRepository(dbContext); var contractPriceListGoodsItemRepo = new ContractPriceListGoodsItemRepository(dbContext); var contractPriceListOpItemRepo = new ContractPriceListOperationItemRepository(dbContext); var serviceOpRepo = new ServiceOperationRepository(dbContext); var featureGoodsRepo = new FeatureGoodsRepository(dbContext); var visitProgramContractPriceListItemRepo = new VisitProgramContractPriceListItemRepository(dbContext); var visitProgramPriceListItemRepo = new VisitProgramPriceListItemRepository(dbContext); var visitRepo = new VisitRepository(dbContext); var _contractService = new ContractService( contractRepo, _mapper, priceListOpItemRepo, priceListGoodsItemRepo, contractPriceListGoodsItemRepo, contractPriceListOpItemRepo, serviceOpRepo, featureGoodsRepo, visitProgramContractPriceListItemRepo, visitProgramPriceListItemRepo, visitRepo); // Act await _contractService.UpdateContractPriceListGoodsItem(contractPriceListGoodsItemId, price); // Assert var result = contractPriceListGoodsItemRepo.GetAll().SingleOrDefault(a => a.Id == contractPriceListGoodsItemId); result.GoodsPrice.ShouldBeEquivalentTo(price); }
方案四:手动解除现有跟踪(备选)
如果必须使用Update方法,可先检查并解除上下文对同一实体的跟踪:
public async Task Update(TEntity entity,CancellationToken cancellationToken = default(CancellationToken)) { // 检查是否已有同主键的实体被跟踪 var trackedEntity = DbContext.ChangeTracker.Entries<TEntity>() .FirstOrDefault(e => e.Entity.Id == entity.Id); if (trackedEntity != null) { // 解除现有跟踪 DbContext.Entry(trackedEntity.Entity).State = EntityState.Detached; } DbContext.Update(entity); await DbContext.SaveChangesAsync(cancellationToken); }
ContractServiceTests
public class ContractServiceTests { private readonly IContractRepository _contractRepository; private readonly IPriceListOperationItemRepository _priceListOperationItemRepository; private readonly IPriceListGoodsItemRepository _priceListGoodsItemRepository; private readonly IContractPriceListGoodsItemRepository _contractPriceListGoodsItemRepository; private readonly IContractPriceListOperationItemRepository _contractPriceListOperationItemRepository; private readonly IServiceOperationRepository _serviceOperationRepository; private readonly IFeatureGoodsRepository _featureGoodsRepository; private readonly IVisitProgramContractPriceListItemRepository _visitProgramContractPriceListItemRepository; private readonly IVisitProgramPriceListItemRepository _visitProgramPriceListItemRepository; private readonly IVisitRepository _visitRepository; private readonly PMOracleDbContext _dbContextOptions; private readonly IMapper _mapper; public ContractServiceTests() { _dbContextOptions = new DbContextMocker().SetDbContext(); _contractRepository = new ContractRepository(_dbContextOptions); _priceListOperationItemRepository = new PriceListOperationItemRepository(_dbContextOptions); _priceListGoodsItemRepository = new PriceListGoodsItemRepository(_dbContextOptions); _contractPriceListGoodsItemRepository = new ContractPriceListGoodsItemRepository(_dbContextOptions); _contractPriceListOperationItemRepository = new ContractPriceListOperationItemRepository(_dbContextOptions); _serviceOperationRepository = new ServiceOperationRepository(_dbContextOptions); _featureGoodsRepository = new FeatureGoodsRepository(_dbContextOptions); _visitProgramContractPriceListItemRepository = new VisitProgramContractPriceListItemRepository(_dbContextOptions); _visitProgramPriceListItemRepository = new VisitProgramPriceListItemRepository(_dbContextOptions); _visitRepository = new VisitRepository(_dbContextOptions); var mappingConfig = new MapperConfiguration(mc => { mc.AddProfile(new ContractAutoMapperConfiguration()); }); _mapper = mappingConfig.CreateMapper(); } [Theory] [InlineData(2,35000)] public async Task UpdateContractPriceListGoodsItem_WhenEverythingIsOk_ShouldBeSucceeded(long contractPriceListGoodsItemId,long price) { // Arrange var _contractService = new ContractService( _contractRepository , _mapper, _priceListOperationItemRepository, _priceListGoodsItemRepository, _contractPriceListGoodsItemRepository, _contractPriceListOperationItemRepository, _serviceOperationRepository, _featureGoodsRepository, _visitProgramContractPriceListItemRepository, _visitProgramPriceListItemRepository, _visitRepository); // Act await _contractService.UpdateContractPriceListGoodsItem(contractPriceListGoodsItemId, price); // Assert var result = _contractPriceListGoodsItemRepository.GetAll().SingleOrDefault(a => a.Id == contractPriceListGoodsItemId); result.GoodsPrice.ShouldBeEquivalentTo(price); } }
服务类
public async Task<IdDto> UpdateContractPriceListGoodsItem(long contractPriceListGoodsItemId, decimal price) { var contractPriceListGoodsItem = await _contractPriceListGoodsItemRepository .GetContractPriceListGoodsItemById(contractPriceListGoodsItemId); if (contractPriceListGoodsItem is null) throw new NotFoundException(Resources.Resources.ContractPriceListItemNotFoundException); contractPriceListGoodsItem.GoodsPrice = price; await _contractPriceListGoodsItemRepository.Update(contractPriceListGoodsItem); // Response return new IdDto { Id = contractPriceListGoodsItem.Id }; }
DbContextMocker
public class DbContextMocker : IDisposable { protected readonly PMOracleDbContext _dbContext; public DbContextMocker() { var options = new DbContextOptionsBuilder<PMOracleDbContext>() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .EnableSensitiveDataLogging() .Options; _dbContext = new PMOracleDbContext(options); _dbContext.Database.EnsureCreated(); } public void Dispose() { _dbContext.Database.EnsureDeleted(); _dbContext.Dispose(); } public PMOracleDbContext SetDbContext() { SeedDatabase.Initialize(_dbContext); return _dbContext; } }
更新方法
public async Task Update(TEntity entity,CancellationToken cancellationToken = default(CancellationToken)) { DbContext.Update(entity); await DbContext.SaveChangesAsync(cancellationToken); }
种子数据类
public class SeedDatabase { public static void Initialize(PMOracleDbContext context) { Seed(context); } private static void Seed(PMOracleDbContext context) { context.Contracts.AddRange(FakeListContract()); context.SaveChanges(); } private static IEnumerable<Contract> FakeListContract() { return new List<Contract> { new Contract { Id = 1, Number = "20000", Title = "First Test", Price = 10000, StartDate = DateTime.Now.AddDays(-10), EndDate = DateTime.Now.AddDays(10), IsActive = true, ContractorId = 1, ProgramType = ProgramType.ServiceProgram, Description = "Test Description 1" }, new Contract { Id = 2, Number = "21000", Title = "Second Test", Price = 20000, StartDate = DateTime.Now.AddDays(-15), EndDate = DateTime.Now.AddDays(15), IsActive = true, ContractorId = 1, ProgramType = ProgramType.VisitProgram, Description = "Test Description 2" } }; } }
内容的提问来源于stack exchange,提问作者Masoud H

