xUnit Assert.Equivalent测试协变返回类型抽象属性时抛ArgumentException
xUnit Assert.Equivalent 因继承属性重复键报错的无修改类结构解决方案
问题场景
在.NET 8项目中使用xUnit 2.6.3测试继承结构的对象时,调用Assert.Equivalent(expected, result, true)抛出System.ArgumentException,提示"已添加相同键的项。键: Data",异常来自AssertHelper.GetGettersForType方法。
具体结构:
- 基类
Widget包含抽象只读属性Data(类型WidgetData) - 派生类
QueryCountWidget重写Data属性,返回QueryCountWidgetData(WidgetData的子类) - 调试确认返回对象与预期一致,但整体断言失败。
相关类定义
Widget 类
public abstract class Widget { public virtual string Title { get; set; } public int RowSpan { get; set; } public int ColumnSpan { get; set; } public int Row { get; set; } public int Column { get; set; } public string BackgroundColor { get; set; } = "DarkGray"; public int DefaultFontSize { get; set; } public abstract WidgetData Data { get; } public abstract void SetData(object data); }
QueryWidget 类
public abstract class QueryWidget : Widget { public Guid QueryId { get; set; } public List<SelectableQueryParameter> Parameters { get; set; } public DateSearchContext SearchContext { get; set; } }
QueryCountWidget 类
public class QueryCountWidget : QueryWidget { private QueryCountWidgetData _data; public override QueryCountWidgetData Data => _data; public override void SetData(object data) { if (data is QueryCountWidgetData widgetData) { _data = widgetData; } } }
WidgetData 派生类
public abstract class WidgetData { } public class QueryCountWidgetData : WidgetData { public int? Count { get; set; } }
测试代码
[Fact] public void DeserializeQueryCountWidget() { var result = DashboardXmlDeserializer.GetWidgetFromXmlNode(_queryCountWidgetNode); Assert.NotNull(result); Assert.IsType<QueryCountWidget>(result); var expected = new QueryCountWidget { Title = "QueryCount with Param", BackgroundColor = "DarkGray", Row = 0, RowSpan = 1, Column = 8, ColumnSpan = 1, SearchContext = new DateSearchContext { Name = "", DateFieldId = "", DateSearchShortcut = "All" }, Parameters = new List<SelectableQueryParameter> { new SelectableQueryParameter { Name = "@TestParam", Value = "Scrap", DataTypeString = "System.String", Hidden = false, } }, QueryId = Guid.Parse("f545ad33-647c-4596-ad7d-0b0a113e7e98"), }; Assert.Equivalent(expected, result, true); }
已尝试的无效/破坏性方案
- 将
Widget.Data改为virtual而非abstract:仍抛出相同异常 - 使用
new关键字覆盖Widget.Data:仍抛出相同异常 - 移除
Widget.Data仅保留派生类属性:测试成功,但破坏类结构 - 修改
Widget.Data类型为QueryCountWidgetData:测试成功,但无法适配其他Widget类型
无修改类结构的解决方案
方案1:转换为派生类类型后再断言
测试中已经通过Assert.IsType<QueryCountWidget>(result)确认对象类型,直接将结果转换为派生类后调用断言,xUnit会以派生类类型解析属性,避免基类与派生类属性重复:
[Fact] public void DeserializeQueryCountWidget() { var result = DashboardXmlDeserializer.GetWidgetFromXmlNode(_queryCountWidgetNode); Assert.NotNull(result); var typedResult = Assert.IsType<QueryCountWidget>(result); // 直接获取派生类实例 var expected = new QueryCountWidget { // ... 预期对象初始化代码不变 }; Assert.Equivalent(expected, typedResult, true); // 比较派生类类型对象 }
方案2:自定义等价比较器
实现IEquivalenceComparer,在比较时过滤重复的Data属性,同时支持所有Widget类型:
public class WidgetEquivalenceComparer : IEquivalenceComparer { public bool Equals(object? x, object? y, IEquivalenceComparerContext context) { if (x is null || y is null) return x == y; var type = x.GetType(); if (type != y.GetType()) return false; // 获取当前类型的所有公共实例属性,排除基类Widget中定义的Data属性 var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => !(p.DeclaringType == typeof(Widget) && p.Name == "Data")); foreach (var prop in properties) { var xValue = prop.GetValue(x); var yValue = prop.GetValue(y); if (!context.Equals(xValue, yValue)) return false; } // 单独比较派生类的Data属性(已重写的版本) var dataProp = type.GetProperty(nameof(Widget.Data), BindingFlags.Public | BindingFlags.Instance); if (dataProp != null) { var xData = dataProp.GetValue(x); var yData = dataProp.GetValue(y); if (!context.Equals(xData, yData)) return false; } return true; } public int GetHashCode(object? obj, IEquivalenceComparerContext context) { return context.DefaultGetHashCode(obj); } }
在测试中使用该比较器:
Assert.Equivalent(expected, result, options => options.Using(new WidgetEquivalenceComparer()));
方案3:排除基类的Data属性
通过断言选项排除基类中定义的Data属性,仅比较派生类的重写属性:
Assert.Equivalent(expected, result, options => options.Exclude(p => p.DeclaringType == typeof(Widget) && p.Name == "Data"));
内容的提问来源于stack exchange,提问作者jvance
相关产品推荐
相关产品推荐

