如何让ExpectedObjects仅按公共接口对比对象?
解决ExpectedObjects仅按公共接口对比对象的问题
首先,我完全理解你的痛点:ExpectedObjects默认会对比对象的所有公共属性,不管它实现的接口,所以混合了SimpleEntity和ComplexEntity的结果集,会因为额外属性(比如ComplexEntity的Domain、非零的Id)不匹配导致断言失败;而手动投影到匿名类型的方式,又会在接口新增属性时无法自动验证,埋下漏测的隐患。
一、开箱即用的半自动化方案:自定义属性投影扩展
虽然ExpectedObjects没有直接提供“按接口对比”的内置功能,但我们可以写一个通用的扩展方法,动态提取接口的所有公共属性,把对象投影成仅包含这些属性的结构。这样后续接口新增属性时,测试代码不需要任何修改,就能自动验证新属性。
步骤1:编写通用投影扩展方法
这个方法会反射获取接口的所有公共实例属性,然后把每个对象转换为包含这些属性的ExpandoObject(ExpectedObjects可以正常识别这种动态结构):
using System.Dynamic; using System.Reflection; public static class InterfaceProjectionExtensions { public static IEnumerable<object> ProjectToInterface<TInterface>(this IEnumerable<TInterface> items) where TInterface : class { var interfaceType = typeof(TInterface); var properties = interfaceType.GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var item in items) { if (item == null) { yield return null; continue; } var expando = new ExpandoObject() as IDictionary<string, object>; foreach (var prop in properties) { expando[prop.Name] = prop.GetValue(item); } yield return expando; } } }
步骤2:修改测试断言
现在只需要把预期集和实际结果都通过这个扩展方法投影,再用ExpectedObjects对比:
// ARRANGE IEnumerable<INamed> expected = new [] { new SimpleEntity { Name = "NAM1" }, new SimpleEntity { Name = "NAM2" } }; // ACT var result = systemUnderTest.GetNames(); // ASSERT expected.ProjectToInterface<INamed>().ToExpectedObject().ShouldMatch(result.ProjectToInterface<INamed>());
这样做的好处:
- 仅对比
INamed接口定义的所有属性,忽略实现类的额外属性 - 后续给
INamed新增属性(比如Id)时,扩展方法会自动提取新属性,测试会自动验证,不会遗漏
二、进阶方案:自定义ExpectedObjects比较器
如果你希望直接使用ExpectedObjects的链式API,而不是投影,可以自定义一个比较器,强制仅对比指定接口的属性:
步骤1:实现接口属性比较器
using System.Reflection; public class InterfaceEqualityComparer<TInterface> : IEqualityComparer { public new bool Equals(object x, object y) { if (ReferenceEquals(x, y)) return true; if (x == null || y == null) return false; // 确保两个对象都实现了目标接口 if (!typeof(TInterface).IsAssignableFrom(x.GetType()) || !typeof(TInterface).IsAssignableFrom(y.GetType())) { return false; } var interfaceProperties = typeof(TInterface).GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var prop in interfaceProperties) { var xValue = prop.GetValue(x); var yValue = prop.GetValue(y); // 处理值类型和引用类型的相等性 if (!object.Equals(xValue, yValue)) { return false; } } return true; } public int GetHashCode(object obj) { // 基于接口属性生成哈希码(可选,用于集合比较的性能优化) var interfaceProperties = typeof(TInterface).GetProperties(BindingFlags.Public | BindingFlags.Instance); int hash = 17; foreach (var prop in interfaceProperties) { var value = prop.GetValue(obj); hash = hash * 31 + (value?.GetHashCode() ?? 0); } return hash; } }
步骤2:在测试中使用自定义比较器
// ARRANGE IEnumerable<INamed> expected = new [] { new SimpleEntity { Name = "NAM1" }, new SimpleEntity { Name = "NAM2" } }; // ACT var result = systemUnderTest.GetNames(); // ASSERT expected.ToExpectedObject() .WithCustomComparer(new InterfaceEqualityComparer<INamed>()) .ShouldMatch(result);
这个方案更贴近ExpectedObjects的原生用法,比较逻辑完全封装在比较器中,测试代码更简洁。
三、临时过渡方案:使用接口专属Stub类
如果不想写反射代码,可以创建一个仅实现INamed接口的Stub类,用它来构造预期集:
public class NamedStub : INamed { public string Name { get; set; } // 后续INamed新增属性时,同步添加到这里 }
测试时用Stub构造预期:
IEnumerable<INamed> expected = new [] { new NamedStub { Name = "NAM1" }, new NamedStub { Name = "NAM2" } }; expected.ToExpectedObject().ShouldMatch(result);
这个方案缺点是需要手动维护Stub类和接口的一致性,但优点是实现简单,适合接口变更不频繁的场景。
内容的提问来源于stack exchange,提问作者V0ldek
相关产品推荐
相关产品推荐

