关于c#:使用反射来进行单元测试的复杂对象断言是不好的做法?

Is it bad practice to use reflection to do complex object assertion for a unit test?

我正在阅读这个主题,这是关于使用反射来测试私有变量...

但我的单元测试中没有这样的问题,我的代码完全可以测试。

唯一的问题是我想通了,对具有预期结果的复杂对象的每个属性进行断言时非常耗时;特别是对于复杂对象的列表。

由于它是一个复杂的对象,除非我为每个对象实现IEquality,否则执行普通的Assert.AreEqual不会给我一个正确的结果。

但即使我这样做,也不会告诉我在断言期间哪个属性/字段的名称,期望值和实际值。

正确地说,我们手动将每个属性值放入一个列表并执行单个CollectionAssertion,但这仍然非常耗时,并且当断言发生时它只告诉我元素值的索引不相等;它不会告诉我属性名称。这使得调试变得非常困难(我必须进入调试模式并查看集合中的元素)。

所以我想知道,如果我编写一个递归反射方法,它将对两个复杂对象进行断言,它将告诉我每个属性名称,期望值,实际值。

这是一种好的做法还是不好的做法?


我发现很多人甚至不会考虑反思,但它有它的位置。它在性能,类型安全等方面肯定存在缺陷,正如其他海报所述,但我认为单元测试是一个很好的使用场所。只要它成功完成。

当您不拥有在属性中使用的所有类型时,尝试在所有对象上强制执行等同实现会进入墙。实现一百个迷你比较器类与手动写出断言一样耗时。

在过去,我编写了一个扩展方法,可以执行您所描述的操作:

  • 比较两个相同类型的对象(或实现一个通用接口)
  • 反射用于查找所有公共属性。
  • 如果属性是值类型,则完成Assert.AreEquals
  • 对于引用类型,它执行递归调用

我的测试在任何时候都不关心属性名称,因此重构重构并不重要。实际上,会自动找到新属性,而忘记删除的属性。

我从来没有将它用于非常复杂的物体,但是它与我拥有的物体配合得很好,而不会减慢我的测试速度。

所以我认为在单元测试中可以随意使用Reflection。

编辑:我会尝试为你挖掘我的方法。


我想说使用反射进行简单的单元测试有很多正当理由。引用https://github.com/kbilsted/StatePrinter

手动单元测试的问题

这很费劲。

当我一遍又一遍地打字并重新输入时:Assert.This,Assert。那,......不禁想知道为什么计算机无法为我自动化这些东西。所有那些不必要的打字需要时间并耗尽我的精力。

使用Stateprinter时,只要预期值和实际值不匹配,就会为您生成断言。

代码和测试不同步

当代码更改时,例如通过向类添加字段,您需要在某些测试中添加断言。但是,找到一个完全手动的过程。在没有人对所有类进行完整概述的较大项目中,所需的更改不会在所有应该执行的地方执行。

将代码从一个分支合并到另一个分支时会出现类似的情况。假设您将发布分支中的错误修复或功能合并到开发分支,我一遍又一遍地观察到代码被合并,所有测试都运行然后提交合并。人们忘记重新访问并仔细检查整个测试套件,以确定开发分支上是否存在测试,而不是合并发生的分支上的测试,相应地调整这些测试。

使用Stateprinter时,会比较对象图而不是单个字段。因此,当创建新字段时,所有相关测试都会失败。您可以将打印调整到特定字段,但是您无法自动检测图表中的更改。

可读性差我

通过对测试类,测试方法和测试元素的标准命名的良好命名,您可以获得很长的成功。但是,没有命名约定可以弥补断言创建的视觉混乱。当索引用于从列表或词典中挑选元素时,会添加进一步的混乱。并且在将它与for,foreach循环或LINQ表达式结合使用时,不要让我开始。

使用StatePrinter时,会比较对象图而不是单个字段。因此,测试中不需要逻辑来挑选数据。

可读性差二

当我读下面的测试时。想想这里真正重要的是什么

1
2
3
4
5
6
7
8
9
Assert.IsNotNull(result,"result");
Assert.IsNotNull(result.VersionData,"Version data");
CollectionAssert.IsNotEmpty(result.VersionData)
var adjustmentAccountsInfoData = result.VersionData[0].AdjustmentAccountsInfo;
Assert.IsFalse(adjustmentAccountsInfoData.IsContractAssociatedWithAScheme);
Assert.AreEqual(RiskGroupStatus.High, adjustmentAccountsInfoData.Status);
Assert.That(adjustmentAccountsInfoData.RiskGroupModel, Is.EqualTo(RiskGroupModel.Flexible));
Assert.AreEqual("b", adjustmentAccountsInfoData.PriceModel);
Assert.IsTrue(adjustmentAccountsInfoData.IsManual);

什么时候蒸馏我们想要表达的是什么

1
2
3
4
5
adjustmentAccountsInfoData.IsContractAssociatedWithAScheme = false
adjustmentAccountsInfoData.Status = RiskGroupStatus.High
adjustmentAccountsInfoData.RiskGroupModel = RiskGroupModel.Flexible
adjustmentAccountsInfoData.PriceModel ="b"
adjustmentAccountsInfoData.IsManual = true

可怜的可怜

当业务对象的字段数量增加时,相反的情况对于测试的可靠性来说也是如此。是否覆盖了所有领域?字段是否被错误地多次比较?还是反对错误的领域?当你必须在一个对象上做25个断言时,你就会知道痛苦,并且精心确保在正确的字段中检查正确的字段。然后审稿人必须经历相同的练习。为什么这不是自动化的?

使用StatePrinter时,会比较对象图而不是单个字段。您知道所有字段都已覆盖,因为所有字段都已打印。


在正常情况下,您不应该需要反射来做与测试相关的任何事情。在回答您链接的问题时提到了这一点:

Reflection should really only be a last resort

如果需要检查复杂对象是否相等,请在单元测试中实现此类等式检查。纯粹用于单元测试目的的额外代码没有错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void ComplexObjectsAreEqual()
{
    var first = // ...
    var second = // ...

    AssertComplexObjectsAreEqual(first, second);
}

private void AssertComplexObjectsAreEqual(ComplexObject first,
    ComplexObject second)
{
    Assert.That(first.Property1, Is.EqualTo(second.Property1),
      "Property1 differs: {0} vs {1}", first.Property1, second.Property1);
    // ...
}

您不应该将单元测试视为其他代码。如果需要编写某些东西以使它们更具可读性,清洁,可维护 - 写下来。它与其他地方的代码相同。您是否会通过生产代码中的反射来比较对象?


在我看来,使用Reflection不是一个很好的选择。使用Reflection意味着我们在编译时失去了类型安全性。而且,在使用Reflection之后,可能(通常)通过程序集的元数据进行不区分大小写的字符串搜索。这导致性能下降。考虑到这些方面,我认为拆分原始类型(如oleksii所推荐)是一种很好的方法。

另一种方法可以是使用纯访问器方法编写单独的测试,以测试单独的属性集。这可能不适用于所有情况。但是,在某些情况下确实如此。

例如:如果我有一个Customer类,我可以编写一个测试来检查Address-type字段;我可以编写另一个测试来检查订单类型字段等等。


恕我直言,这是一个不好的做法,因为:

  • 反射代码很慢,很难正确编写
  • 维护起来更加困难,而且这些代码可能不是重构友好的
  • 反射很慢,单元测试应该很快
  • 它也感觉不对

对我来说,这看起来好像是在试图堵塞一个洞,而不是解决问题。为了解决这个问题,我可以建议将一个大而复杂的类分成一组较小的类。如果您有许多属性 - 将它们分组到单个类中

这样的课

1
2
3
4
5
6
7
class Foo
{
    T1 Prop1 {get; set;}
    T2 Prop2 {get; set;}
    T3 Prop3 {get; set;}
    T4 Prop4 {get; set;}
}

会成为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Foo
{
    T12 Prop12 {get; set;}  
    T34 Prop34 {get; set;}  
}
class T12
{
    T1 Prop1 {get; set;}
    T2 Prop2 {get; set;}
}
class T34
{
    T3 Prop3 {get; set;}
    T4 Prop4 {get; set;}
}

注意,Foo现在只有一个属性(即"分组"表示)。如果您可以以某种方式对属性进行分组,那么任何状态更改都将本地化为特定组 - 您的任务将变得更加简化。然后,您可以断言"分组"属性等于预期状态。