【问题标题】:How to test for equality of complex object graphs?如何测试复杂对象图的相等性?
【发布时间】:2009-09-11 15:27:22
【问题描述】:

假设我有一个单元测试想要比较两个复杂对象的相等性。这些对象包含许多其他深度嵌套的对象。所有对象的类都正确定义了equals() 方法。

这并不难:

@Test
public void objectEquality() {
    Object o1 = ...
    Object o2 = ...

    assertEquals(o1, o2);
}

问题是,如果对象不相等,你得到的只是失败,没有迹象表明对象图的哪一部分不匹配。调试它可能会很痛苦和令人沮丧。

我目前的方法是确保一切都实现toString(),然后像这样比较是否相等:

    assertEquals(o1.toString(), o2.toString());

这使得跟踪测试失败变得更加容易,因为像 Eclipse 这样的 IDE 有一个特殊的可视比较器,用于显示失败测试中的字符串差异。本质上,对象图是以文本形式表示的,因此您可以看到差异在哪里。只要toString() 写得好,它就很好用。

不过,这有点笨拙。有时您想为其他目的设计 toString(),例如日志记录,也许您只想渲染一些对象字段而不是所有字段,或者根本没有定义 toString(),等等。

我正在寻找更好的方法来比较复杂对象图。有什么想法吗?

【问题讨论】:

  • +1 好你提出来了。我想看看其他人找到了哪些解决方案。

标签: java unit-testing


【解决方案1】:

Atlassian Developer Blog 有几篇关于这个主题的文章,以及 Hamcrest 库如何让调试这种测试失败变得非常简单:

基本上,对于这样的断言:

assertThat(lukesFirstLightsaber, is(equalTo(maceWindusLightsaber)));

Hamcrest 会给你这样的输出(其中只显示不同的字段):

Expected: is {singleBladed is true, color is PURPLE, hilt is {...}}  
but: is {color is GREEN}

【讨论】:

    【解决方案2】:

    您可以使用XStream 将每个对象呈现为XML,然后使用XMLUnit 对XML 执行比较。如果它们不同,那么您将获得上下文信息(以 XPath、IIRC 的形式)告诉您对象的不同之处。

    例如来自 XMLUnit 文档:

    Comparing test xml to control xml [different] 
    Expected element tag name 'uuid' but was 'localId' - 
    comparing <uuid...> at /msg[1]/uuid[1] to <localId...> at /msg[1]/localId[1]
    

    注意指示不同元素位置的 XPath。

    可能不会很快,但这对于单元测试来说可能不是问题。

    【讨论】:

    • +1 我喜欢这个...类似于比较toString() 的方法,不需要toString()。不过,我怀疑坚持字符串比较和 IDE 支持会更容易。
    • 我认为您可以轻松地编写一个名为 assertSameDeeply() 或类似的实用方法,并且它完全是通用的。只需静态导入它并像所有其他 JUnit 一样使用它。
    • 我喜欢这个解决方案,因为比较结果的格式很好。但是,我相信 matt b 指向 Hamcrest 的指针非常好,而且解决方案闻起来更好。
    【解决方案3】:

    由于我倾向于设计复杂对象的方式,我在这里有一个非常简单的解决方案。

    在设计一个复杂的对象时,我需要编写一个 equals 方法(因此也是一个 hashCode 方法),我倾向于编写一个字符串渲染器,并使用 String 类的 equals 和 hashCode 方法。

    渲染器,当然,不是 toString:它并不真的必须易于人类阅读,它包括所有且仅包含我需要比较的值,并且我习惯于将它们按控制顺序排列我希望他们排序的方式;对于 toString 方法,这些都不一定正确。

    当然,我会缓存这个呈现的字符串(以及 hashCode 值)。它通常是私有的,但保留缓存的字符串 package-private 会让您从单元测试中看到它。

    顺便说一句,这并不总是我在交付的系统中最终得到的结果,当然 - 如果性能测试表明这种方法太慢,我准备替换它,但这种情况很少见。到目前为止,它只发生过一次,在一个可变对象被快速更改和频繁比较的系统中。

    我这样做的原因是writing a good hashCode isn't trivial,并且需要测试(*),而使用String中的那个可以避免测试。

    (* 考虑到 Josh Bloch 编写一个好的 hashCode 方法的步骤 3 是测试它以确保“相等”的对象具有相等的 hashCode 值,并确保你已经涵盖了所有可能的变化是'这本身并不是微不足道的。更微妙,更难测试的是分布)

    【讨论】:

    • 如何处理无法保证子元素顺序的结构。例如地图或集合。
    • @EldarBudagov 请记住,我在 2009 年写了我的答案,现在可能有更好的解决方案(xml、deepcopy 等)可用。但我的方法是强制条目按顺序排列(通过使用键的自然顺序,例如地图),记住缓存字符串化的版本。另请记住,此方法仅适用于阻止修改或完全控制其子对象的对象,从而允许顶级对象在添加或删除条目时知道。
    【解决方案4】:

    这个问题的代码存在于http://code.google.com/p/deep-equals/

    使用 DeepEquals.deepEquals(a, b) 比较两个 Java 对象的语义相等性。这将使用它们可能具有的任何自定义 equals() 方法来比较对象(如果它们实现了 Object.equals() 以外的 equals() 方法)。如果不是,则此方法将继续递归地逐个字段比较对象。当遇到每个字段时,如果存在,它将尝试使用派生的equals(),否则将继续进一步递归。

    此方法适用于这样的循环对象图:A->B->C->A。它具有循环检测功能,因此可以比较任意两个对象,并且永远不会进入无限循环。

    使用 DeepEquals.hashCode(obj) 计算任何对象的 hashCode()。与 deepEquals() 一样,如果实现了自定义 hashCode() 方法(在 Object.hashCode() 下方),它将尝试调用 hashCode() 方法,否则它将逐个字段递归地计算 hashCode()。也像 deepEquals() 一样,此方法将处理带有循环的对象图。例如,A->B->C->A。在这种情况下,hashCode(A) == hashCode(B) == hashCode(C)。 DeepEquals.deepHashCode() 具有循环检测功能,因此适用于任何对象图。

    【讨论】:

    • 如果对象不匹配,是否可以看到有什么区别?我也可以从比较中排除某些字段吗?
    【解决方案5】:

    单元测试应该有明确的、单一他们测试的东西。这意味着最终您应该有明确定义的单一事物,这两个对象可能会有所不同。如果有太多不同的地方,我建议将此测试拆分为几个较小的测试。

    【讨论】:

    • 我不同意,这并不总是实用的。例如,假设我创建了一个 JPA 实体,将其持久化,然后检索它,并且我想测试检索到的对象是否与我存储的对象相同。我只能对顶级对象执行此操作。
    • 重点是他正在检查 2 个对象是否相同(单个比较)。这些对象可能很复杂,尽管断言是微不足道且正确的,但当它们不同时对问题的诊断却并非如此。
    • 他唯一的事情就是“这些对象是否相等”
    【解决方案6】:

    我跟着你走的同一条路。我也遇到了额外的麻烦:

    • 我们不能修改不属于我们的类(对于 equals 或 toString) (JDK)、数组等。
    • 平等有时在不同情况下有所不同

    例如,跟踪实体的相等性可能依赖于可用的数据库 ID(“同一行”概念),依赖于某些字段的相等性(业务键)(对于未保存的对象)。对于 Junit 断言,您可能希望所有字段都相等。


    所以我最终创建了通过图表运行的对象,边做边做。

    通常有一个超类 Crawling 对象:

    • 遍历对象的所有属性;停在:

      • 枚举,
      • 框架类(如果适用),
      • 在未加载的代理或远程连接处,
      • 在已访问的对象处(避免循环)
      • 在多对一关系中,如果它们指示父级(通常不包含在等于语义中)
      • ...
    • 可配置,使其可以在某个点停止(完全停止,或停止在当前属性内爬行):

      • 当 mustStopCurrent() 或 mustStopCompletely() 方法返回 true 时,
      • 当遇到 getter 或类上的一些注解时,
      • 当当前(类、getter)属于异常列表时
      • ...

    从那个 Crawling 超类中,子类是为满足许多需求而创建的:

    • 用于创建调试字符串(根据需要调用 toString,对于没有良好 toString 的集合和数组的特殊情况;处理大小限制等等)。
    • 用于创建 几个均衡器(如前所述,对于使用 id 的实体、所有字段或仅基于 equals ;)。这些均衡器通常也需要特殊情况(例如对于您无法控制的类)。

    回到问题:这些均衡器可以记住不同值的路径,这对于您的 JUnit 案例理解差异非常有用。

    • 用于创建 订购者。例如,需要完成的保存实体是一个特定的顺序,而效率将决定将相同的类保存在一起会带来巨大的提升。
    • 用于收集一组可以在图表中的各个级别找到的对象。循环 Collector 的结果非常容易。

    作为补充,我必须说,除了真正关注性能的实体之外,我确实选择了该技术来在我的实体上实现 toString()、hashCode()、equals() 和 compareTo()。

    例如,如果在 Hibernate 中通过类上的 @UniqueConstraint 定义了一个或多个字段上的业务键,那么假设我的所有实体都有一个在公共超类中实现的 getIdent() 属性。 我的实体超类有这 4 种方法的默认实现,这些方法依赖于这些知识,例如(需要注意空值):

    • toString() 打印“myClass(key1=value1, key2=value2)”
    • hashCode() 是“value1.hashCode() ^ value2.hashCode()”
    • equals() 是“value1.equals(other.value1) && value2.equals(other.value2)”
    • compareTo() 是结合类、value1 和 value2 的比较。

    对于关注性能的实体,我只是重写这些方法以不使用反射。我可以在回归 JUnit 测试中测试这两个实现的行为是否相同。

    【讨论】:

      【解决方案7】:

      我们使用一个名为 junitx 的库来测试我们所有“通用”对象的 equals 合约: http://www.extreme-java.de/junitx/

      我能想到的测试 equals() 方法不同部分的唯一方法是将信息分解成更细粒度的信息。如果您正在测试一个深度嵌套的对象树,那么您所做的并不是真正的单元测试。您需要使用针对该类型对象的单独测试用例来测试图中每个单独对象的 equals() 合约。对于被测对象上的类类型字段,您可以使用带有简单 equals() 实现的存根对象。

      HTH

      【讨论】:

        【解决方案8】:

        我不会使用toString(),因为正如您所说,它通常更有助于创建对象的良好表示以用于显示或记录目的。

        在我看来,您的“单元”测试并未隔离被测单元。例如,如果您的对象图是A--&gt;B--&gt;C,并且您正在测试A,那么您对A 的单元测试不应该关心C 中的equals() 方法是否有效。您对C 的单元测试将确保它有效。

        所以我会在Aequals()方法的测试中测试以下内容: - 在两个方向上比较两个具有相同B 的 A 对象,例如a1.equals(a2)a2.equals(a1)。 - 在两个方向上比较两个具有不同BA 对象

        通过这种方式,每次比较都有一个 JUnit 断言,您将知道失败的位置。

        显然,如果您的班级有更多的孩子是确定平等的一部分,您将需要测试更多的组合。不过,我试图理解的是,您的单元测试不应该关心它直接接触的类之外的任何东西的行为。在我的示例中,这意味着您会假设 C.equals() 工作正常。

        如果您在比较集合,可能会出现问题。在这种情况下,我会使用一个实用程序来比较集合,例如 commons-collections CollectionUtils.isEqualCollection()。当然,仅适用于被测单元中的集合。

        【讨论】:

          【解决方案9】:

          如果你愿意用 Scala 编写测试,你可以使用matchete。它是一个匹配器的集合,可以与 JUnit 一起使用,并提供compare objects graphs 的能力:

          case class Person(name: String, age: Int, address: Address)
          case class Address(street: String)
          
          Person("john",12, Address("rue de la paix")) must_== Person("john",12,Address("rue du bourg"))
          

          会产生以下错误信息

          org.junit.ComparisonFailure: Person(john,12,Address(street)) is not equal to Person(john,12,Address(different street))
          Got      : address.street = 'rue de la paix'
          Expected : address.street = 'rue du bourg'
          

          正如您在此处看到的,我一直在使用 case 类,这些类由 matchete 识别,以便深入研究对象图。 这是通过一个名为Diffable 的类型类完成的。我不打算在这里讨论类型类,所以假设它是这种机制的基石,它比较给定类型的 2 个实例。不是 case-classes 的类型(基本上是 Java 中的所有类型)都会获得一个使用 equals 的默认 Diffable。这不是很有用,除非您为您的特定类型提供Diffable

          // your java object
          public class Person {
             public String name;
             public Address address;
          }
          
          // you scala test code
          implicit val personDiffable : Diffable[Person] = Diffable.forFields(_.name,_.address)
          
          // there you go you can now compare two person exactly the way you did it
          // with the case classes
          

          所以我们已经看到 matchete 可以很好地与 java 代码库配合使用。事实上,我上次在一个大型 Java 项目中工作时一直在使用 matchete。

          免责声明:我是火柴作者 :)

          【讨论】:

            猜你喜欢
            • 2011-06-11
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2013-10-16
            • 1970-01-01
            相关资源
            最近更新 更多