You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 17:45:04