如何在C#中避免Mock DbSet<TData>的引用问题?
解决Mock DbContext/DbSet时修改查询实体导致原数据自动更新的问题
当前你的Mock实现直接返回原始List中实体的引用,所以通过FirstOrDefault查询到的对象就是原集合里的实例,修改它的属性自然会同步到Mock DbSet对应的原数据集合里,这和真实EF Core的行为不符(真实场景下,DbContext会跟踪实体,但只有调用SaveChanges才会持久化到数据库,而内存中的实体虽然会被修改,但如果你需要模拟"未调用Update/SaveChanges时原数据不变"的场景,就需要调整Mock逻辑)。
方案一:返回实体副本(简单场景)
如果只需要避免修改查询到的实体影响原集合,最直接的方式是让每次查询都返回实体的深拷贝,而不是原引用。
修改你的GetMock方法中GetEnumerator的Setup逻辑,添加克隆步骤:
public Mock<TContext> GetMock<TData, TContext>(List<TData> data, Expression<Func<TContext, DbSet<TData>>> dbSetSelectionExpression) where TData : class where TContext : DbContext { var queryableData = data.AsQueryable(); var stubSet = new Mock<DbSet<TData>>(); var stubContext = new Mock<TContext>(); stubSet.As<IQueryable<TData>>().Setup(s => s.Provider).Returns(queryableData.Provider); stubSet.As<IQueryable<TData>>().Setup(s => s.Expression).Returns(queryableData.Expression); stubSet.As<IQueryable<TData>>().Setup(s => s.ElementType).Returns(queryableData.ElementType); // 修改这里:返回每个实体的副本 stubSet.As<IQueryable<TData>>().Setup(s => s.GetEnumerator()) .Returns(() => queryableData.Select(item => Clone(item)).GetEnumerator()); stubContext.Setup(dbSetSelectionExpression).Returns(stubSet.Object); return stubContext; } // 实现深拷贝方法(示例用Json序列化,适合大多数简单实体) private TData Clone<TData>(TData item) where TData : class { var json = System.Text.Json.JsonSerializer.Serialize(item); return System.Text.Json.JsonSerializer.Deserialize<TData>(json); }
这样每次查询都会生成新的实体实例,修改查询到的对象不会影响原集合中的数据。
方案二:模拟EF Core跟踪机制(贴近真实场景)
如果需要模拟真实EF Core的Update/SaveChanges行为(即只有调用Update后才同步到原数据),可以维护一个跟踪集合,记录被查询的实体,仅在调用Update时更新原数据。
修改后的GetMock方法如下:
public Mock<TContext> GetMock<TData, TContext>(List<TData> data, Expression<Func<TContext, DbSet<TData>>> dbSetSelectionExpression) where TData : class where TContext : DbContext { // 保存原始数据作为"数据库"存储 var originalData = new List<TData>(data); // 跟踪被查询的实体(假设实体有Id属性作为主键) var trackedEntities = new Dictionary<object, TData>(); var stubSet = new Mock<DbSet<TData>>(); var stubContext = new Mock<TContext>(); // 配置查询逻辑:返回实体副本并跟踪 stubSet.As<IQueryable<TData>>().Setup(s => s.Provider).Returns(originalData.AsQueryable().Provider); stubSet.As<IQueryable<TData>>().Setup(s => s.Expression).Returns(originalData.AsQueryable().Expression); stubSet.As<IQueryable<TData>>().Setup(s => s.ElementType).Returns(originalData.AsQueryable().ElementType); stubSet.As<IQueryable<TData>>().Setup(s => s.GetEnumerator()) .Returns(() => originalData.Select(item => { var clone = Clone(item); // 获取实体主键(这里假设主键名为Id,可根据实际调整) var primaryKey = typeof(TData).GetProperty("Id")?.GetValue(clone); if (primaryKey != null) { trackedEntities[primaryKey] = clone; } return clone; }).GetEnumerator()); // 模拟DbSet.Update方法:仅当调用Update时,才同步修改到原始数据 stubSet.Setup(s => s.Update(It.IsAny<TData>())) .Callback<TData>(updatedEntity => { var primaryKey = typeof(TData).GetProperty("Id")?.GetValue(updatedEntity); if (primaryKey == null || !trackedEntities.ContainsKey(primaryKey)) return; // 找到原始数据中对应的实体 var originalEntity = originalData.FirstOrDefault(e => typeof(TData).GetProperty("Id")?.GetValue(e).Equals(primaryKey) == true); if (originalEntity == null) return; // 将修改后的属性值同步到原始实体 foreach (var prop in typeof(TData).GetProperties() .Where(p => p.CanRead && p.CanWrite && p.Name != "Id")) { prop.SetValue(originalEntity, prop.GetValue(updatedEntity)); } }); stubContext.Setup(dbSetSelectionExpression).Returns(stubSet.Object); return stubContext; } // 复用之前的Clone方法 private TData Clone<TData>(TData item) where TData : class { var json = System.Text.Json.JsonSerializer.Serialize(item); return System.Text.Json.JsonSerializer.Deserialize<TData>(json); }
使用这个方案时:
- 查询到的实体是副本,修改它不会影响原集合
- 只有调用
dbContextMock.Object.MyEntity.Update(data)后,原集合中的数据才会被更新 - 更贴近真实EF Core的工作流程
注意事项
- 如果实体包含复杂嵌套对象,Json序列化的克隆方式可能无法处理循环引用,此时可以改用AutoMapper配置深拷贝映射,或者手动实现实体的克隆方法。
- 如果实体的主键不是
Id,需要修改代码中获取主键的逻辑,比如通过反射自定义主键属性名。
内容的提问来源于stack exchange,提问作者Ashok Ananthan
相关产品推荐
相关产品推荐

