Moq单元测试以自定义类为GroupBy键分组失效的解决方法
问题背景
现有业务实现代码如下:
var data = context.GetAll<UserRole>() .GroupBy(x => new RoleGroup { RoleId = x.RoleId, AccessEnum = x.AccessEnum }) .Where(x => x.Count() > 1);
对应单元测试的模拟数据代码如下:
var userRoles = new List<UserRole> { new UserRole { Id = 1, RoleId = 1, AccessEnum = AccessEnum.Full }, new UserRole { Id = 2, RoleId = 1, AccessEnum = AccessEnum.Full }, new UserRole { Id = 3, RoleId = 2, AccessEnum = AccessEnum.ReadOnly }, new UserRole { Id = 4, RoleId = 2, AccessEnum = AccessEnum.Full }, new UserRole { Id = 5, RoleId = 2, AccessEnum = AccessEnum.ReadOnly } }; mockContext.Setup(x => x.GetAll<UserRole>()).Returns(userRoles.AsQueryable());
上述代码存在的现象:
- 系统正式运行连接真实数据库时,GroupBy逻辑可以正常完成分组
- 执行单元测试时,分组逻辑会逐条返回独立记录,无法正确聚合
- 如果把GroupBy的键替换为匿名对象,即写成
.GroupBy(x => new { x.RoleId, x.AccessEnum }),单元测试的分组功能可以正常生效 - 由于业务需要将分组结果传入其他方法,必须使用
RoleGroup类型作为分组键,需要在保留自定义类作为GroupBy键的前提下修复单元测试分组异常的问题。
问题根因
两种运行场景下GroupBy的执行逻辑存在差异:
- 正式环境运行时,GroupBy会被EF Core这类ORM翻译成SQL语句在数据库端执行,数据库分组只判断字段值是否匹配,和C#端的对象相等逻辑无关,所以能正常分组。
- 单元测试时用
List.AsQueryable()模拟数据源,GroupBy走的是LINQ to Objects的内存计算逻辑,内存分组判断两个键是否相等,默认依赖对象类型的Equals()和GetHashCode()方法。 - 自定义的
RoleGroup类默认没有重写这两个方法,默认判断逻辑是两个对象的引用地址相同才判定为相等。分组时每条记录都会new一个新的RoleGroup实例,所有实例的引用地址都不相同,所以每条记录都会被判定为独立分组。 - 匿名类型之所以能正常分组,是因为C#编译器会自动为匿名类型按属性值重写
Equals()和GetHashCode(),只要属性值一致就判定为相等,符合分组预期。
解决方案
核心是让RoleGroup类型的相等判断逻辑和匿名类型保持一致,按属性值判断相等,有两种可选实现方式:
方案1:直接为RoleGroup重写相等判断方法(推荐)
直接修改RoleGroup类,重写Equals和GetHashCode方法,修改后所有场景下的相等判断逻辑统一,不会出现测试和生产行为不一致的问题:
public class RoleGroup { public int RoleId { get; set; } public AccessEnum AccessEnum { get; set; } public override bool Equals(object obj) { if (obj is not RoleGroup other) return false; return RoleId == other.RoleId && AccessEnum == other.AccessEnum; } public override int GetHashCode() { return HashCode.Combine(RoleId, AccessEnum); } }
修改后原有业务代码不需要做任何调整,单元测试和生产环境的分组逻辑都能正常运行。
方案2:GroupBy时显式传入自定义相等比较器
如果不方便修改RoleGroup类的默认相等逻辑,可以单独实现一个RoleGroup的相等比较器,在调用GroupBy的时候传入:
// 自定义相等比较器 public class RoleGroupEqualityComparer : IEqualityComparer<RoleGroup> { public bool Equals(RoleGroup x, RoleGroup y) { if (x == null && y == null) return true; if (x == null || y == null) return false; return x.RoleId == y.RoleId && x.AccessEnum == y.AccessEnum; } public int GetHashCode(RoleGroup obj) { return HashCode.Combine(obj.RoleId, obj.AccessEnum); } } // 调整业务代码的GroupBy调用,传入比较器 var data = context.GetAll<UserRole>() .GroupBy( keySelector: x => new RoleGroup { RoleId = x.RoleId, AccessEnum = x.AccessEnum }, comparer: new RoleGroupEqualityComparer() ) .Where(x => x.Count() > 1);
注意:大部分ORM不支持在GroupBy中传入自定义比较器,会翻译失败,如果用这个方案需要确认正式环境的ORM兼容这个写法,否则还是优先选择方案1。
内容的提问来源于stack exchange,提问作者Ben
相关产品推荐
相关产品推荐

