[Unit Test Tricks] Compare Object Equality

实际上撰写的测试程序中,几乎都需要针对 reference type 的对象或集合进行比对,然而大部分的 test framework 所提供的 Equals function 都是调用对象的 Equals(),也就是若没有额外覆写,仍然是比较 reference 的位址是否相等。

这篇文章介绍一个 Nuget 套件: ExpectedObjects,让我们可以用简单的 API 来针对对象、对象集合、组合式对象比较是否相等,还额外提供了部分比较的功能来因应实际上的需求。


前言

在撰写单元测试进行验证时,都需要验证执行结果是否符合预期。然而,不论是验证测试方法的回传值对象状态的改变、或是与外部相依对象之间的交互,回传值、状态或传递给外部相依对象的参数,很常都是以对象(这边指的是 reference type)的方式设计。 在 MsTest 中,验证两个值相同Assert.AreSame() ,验证两个值相等则用 Assert.AreEqual()

  1. AreSame() 其实就等同于调用 Assert.IsTrue(Object.RefrenceEquals(a,b))Object.RefrenceEquals(a,b)代表的就是比较 a 与 b 两个对象的参考位址。
  2. AreEqual() 则是等同于调用 Assert.IsTrue(Object.Equals(a,b)),因为 Object 为 reference type ,因此 Equals() 默认仍然是比较对象是否为同一个参考位址。但 Equals() 是定义为 virtual 以便各个继承自 Object 的 type 能自行定义相等的规则。

但绝大部分实际的测试情境下,expected 的对象往往是 new 一个新的 instance/colletion ,来与实际的 actual instance/collection 进行“各个属性值的比较”,验证是否为同一个对象的需求反而较为罕见。这也就代表只要 reference type 没有 override Equals() 的话,Assert.AreEqaul() 便不适用于比较两个对象值是否相等。范例程序(github版本)如下:

[TestClass]
public class ComparingObjectTests
{
    [TestMethod]
    public void Test_Order_Equals_by_Assert_Equals()
    {
        var expected = new Order
        {
            Id = 1,
            Price = 10,
        };
 
        var actual = new Order
        {
            Id = 1,
            Price = 10,
        };
 
        //this would be failed because of "Order" is a reference type; if Order didn't override Equals(), AreEqual() will invoke Object.Equals()
        Assert.AreEqual(expected, actual); 
    }
}
 
internal class Order
{
    public int Price { get; set; }
 
    public int Id { get; set; }
}
程序说明:因为 Order 并未覆写 Equals() ,所以 Assert.AreEqual() 仍是比较 expected 与 actual 是否指向同一个参考位址,因此测试会是 failed 。

Override Equals()

若要定义两个 Order 的 instance 相等的条件为:“当它们的 Id 与 Price 值相等时,这两个 instance 才算相等”,那么就需要这一段逻辑覆写到 Equals() 中。而且往往会搭配实践 IEquatable,程序(github版本)如下:

internal class Order : IEquatable
{        
    public int Price { get; set; }
 
    public int Id { get; set; }
 
    // remind: when you override Equals(), you should override GetHashCode() too.
    public override bool Equals(object obj)
    {
        var order = obj as Order;
        if (order != null)
        {
            return this.Equals(order);
        }
 
        return false;
    }
    public bool Equals(Order other)
    {
        //define Equals of Order type between two Order instances
        return this.Id == other.Id && this.Price == other.Price;
    }
}
 
[TestClass]
public class ComparingObjectTests
{
    [TestMethod]
    public void Test_Order_Equals_by_Assert_Equals()
    {
        var expected = new Order
        {
            Id = 1,
            Price = 10,
        };
 
        var actual = new Order
        {
            Id = 1,
            Price = 10,
        };
 
        //this test will pass; when you override Equals(), AreEqual will invoke Order's Equals(), rather than Object's Equals()
        Assert.AreEqual(expected, actual); 
    }
}
程序说明:Order 实践了 IEquatable 并覆写了 Equals() ,此时 AreEqual() 调用的就是 Order 的 Equals() ,因此测试会 pass 。

看起来好像解决了问题,但其实还是有些缺点:

  1. Order class 是 production code 而不是测试项目中的 class ,不能因为在测试的情境中,想要比较 Order 的 Id 与 Price ,就硬把这样称为相等的定义加诸于 production code 上
  2. 同上,当不同测试情境或需求异动,所需要验证的 property 不一样时,除了无法在测试程序中,动态地设定 Order 相等的定义,且 Order 在 production code 的设计中,也很有可能已经自己定义好相等的条件。
  3. 当透过 AreEqual(expected, actual) 测试 failed 时,从错误消息中无法判读是什么 property 的值不相同,也无法得知两个对象不相等的值为何

Flat Properties to Compare

比较常见的方式,其实是把对象的 property 都摊开来比较,这样就可以确保比较的是值,而不是参考位址。程序(github版本)如下:

internal class Person //Person didn't override Equals
{
    public string Name { get; set; }
 
    public int Age { get; set; }
 
    public int Id { get; set; }
 
    public DateTime Birthday { get; set; }
 
    public Order Order { get; set; }
}
 
[TestMethod]
public void Test_Person_Equals_Flat_all_properties_by_Assert_Equals()
{
    var expected = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    };
 
    var actual = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    };
 
    Assert.AreEqual(expected.Id, actual.Id);
    Assert.AreEqual(expected.Name, actual.Name);
    Assert.AreEqual(expected.Age, actual.Age);
}

摊平 property 来比较值,会有哪些问题呢?

  1. 浪费时间,当要比较的属性一多时,developer 需要撰写的程序就会变多,尤其是 composed 型的对象更是花费加倍的时间。
  2. 复制贴上就是最容易出错的地方。
  3. 抽成共用?万一不同测试案例要测试的 property 不一样呢?
  4. 比较两个对象是否相等,跟比较两个对象的某些 property 值是否相等,在语义上是不一样的。而测试案例就是描述需求的规格,语义与可读性的重要性相当高。
  5. 倘若要比的,是两个 Person 的集合,那么测试程序中就会出现 for 循环,测试程序中应尽量避免逻辑判断、流程与循环,以避免一旦含逻辑,又需要另外一个测试程序来验证这个测试程序是否正确。而且一样有语义的问题,透过 for 循环的验证里面的每一笔,并不完全等同于验证两个集合,不容易让阅读的人一目了然,在这 scenario 底下,实际的集合应该符合预期。

Project to Anonymous Type for Comparing

C# 在 3.0 之后,提供了匿名类型(Anonymous Type),它除了是种可由 developer 自行定义有哪些 property ,并在执行期间产生一个随用即抛的 type 以外,它还具备一个特色,就是其 Equals()GetHashCode() 的内容是依据每一个 property 的 Equals()GetHashCode() 来决定。

换言之,两个相同匿名类型的 instance 只有在其所有 property 都相等时,才代表相等。

这不就是我们一直要的吗?可以在不同测试案例中决定以哪些 property 的值来进行比较,也不需要额外定义许多用不到几次就丢的 class 或因此影响 production code 的实践内容。

而且只要搭配 LINQ to objects 的 Select() 就能支持将待测的集合转换成匿名类型的集合,再搭配 CollectionAssert 就能验证集合。程序(github版本)如下:

[TestMethod]
public void Test_Person_Equals_with_AnonymousType()
{
    var expected = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    };
 
    var actual = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    };
 
    //project expected Person to anonymous type
    var expectedAnonymous = new
    {
        Id = expected.Id,
        Name = expected.Name,
        Age = expected.Age
    };
 
    //project actual Person to anonymous type
    var actualAnonymous = new
    {
        Id = actual.Id,
        Name = actual.Name,
        Age = actual.Age,
    };
 
    Assert.AreEqual(expectedAnonymous, actualAnonymous);
}
 
[TestMethod]
public void Test_PersonCollection_Equals_with_AnonymousType_by_CollectionAssert()
{
    //project collection from List to List by Select()
    var expected = new List
    {
        new Person { Id=1, Name="A",Age=10},
        new Person { Id=2, Name="B",Age=20},
        new Person { Id=3, Name="C",Age=30},
    }.Select(x => new { Id = x.Id, Name = x.Name, Age = x.Age }).ToList();
 
    //project collection from List to List by Select()
    var actual = new List
    {
        new Person { Id=1, Name="A",Age=10},
        new Person { Id=2, Name="B",Age=20},
        new Person { Id=3, Name="C",Age=30},
    }.Select(x => new { Id = x.Id, Name = x.Name, Age = x.Age }).ToList();
 
    CollectionAssert.AreEqual(expected, actual);
}

就测试程序验证的可行性、容易撰写的程度、扩充的弹性,匿名类型都达到了期望。连验证 failed 时,其错误资讯都显示地相当清楚,如下图所示:

那使用匿名类型来验证对象相等,还会有什么问题呢?

  1. 当验证的两个对象或集合,最后都转一手变成匿名类型时,多少会对验证的 scenario 语义造成一点点干扰。
  2. 建立匿名类型的 property 时,没有充分享受到 intellisense 的好处,打错字时,就会被认为是不同的 anonymouse type 。

Table Extension Method of Specflow

NET 有个 BDD 开发套件 Specflow 相当有名,是 Cucumber 在 .NET 的分支,其开发方式是采用 gherkin style 的 Given/When/Then 来描述 scenario ,每个 scenario 由 step 组成,每个 step 的 definition 则系结到测试程序的内容。

也因为这样的开发方式,在 gherkin style 中支持以 table 的形式来描述一个 model ,除了自动产生成文档时能被解析成 html table 以外,在 specflow 中还为这样的 table 类型定义了几个扩充方法,让 steps definition 更加好写易懂。同样的例子,在 specflow 的 feature 档中,比较 actual person 应与 expected person 相等的 scenario,如下图所示: 

其 steps definition 的程序(github版本)如下:

[When(@"I got a acutal person")]
public void WhenIGotAAcutalPerson(Table table)
{
    var actual = table.CreateInstance();
    ScenarioContext.Current.Set(actual);
}
 
[Then(@"I hope actual person should be equal to expected person")]
public void ThenIHopeActualPersonShouldBeEqualToExpectedPerson(Table expected)
{
    var actual = ScenarioContext.Current.Get();
    expected.CompareToInstance(actual);
}

在 When 的 step 中, steps definition 可直接透过 table.CreateInstance() 将 scenario 上的 table 自动映射成类型 T 的 instance ,在上述的例子就是把 scenario 上面的 table 转换成一个 Person 的对象,并透过 ScenarioContext 将 actual 暂存起来,供 Then 的 step 进行验证。 在 Then 的 step 中,则是将预期的结果定义在 scenario 上。因此,可以直接用 Then step 上的 table ,来与 actual 直接进行验证是否相等。只需要透过table.CompareToInstance() 这个扩充方法,就可以一行搞定。

table 的 column 对应的就是 property name,row 则是存放 property 的值,其中类型的转换,都在 specflow 中自动处理掉了。 请注意,要使用 table 的扩充方法,请记得引用 TechTalk.SpecFlow.Assist 这个命名空间

当然,能透过 table 来进行单一对象的取得与比较,一定也要支持集合的取得与比较,否则就太对不起 table 这个字眼了。

比较 actual 的 person collection 是否与 expected person collection 相等的 scenario 如下图:

程序(github版本)如下:

[When(@"I got a actual person collection")]
public void WhenIGotAActualPersonCollection(Table table)
{
    var actual = table.CreateSet();
    ScenarioContext.Current.Set>(actual);
}
 
[Then(@"I hope actual person collection should be equal to expected person collection")]
public void ThenIHopeActualPersonCollectionShouldBeEqualToExpectedPersonCollection(Table expected)
{
    var actual = ScenarioContext.Current.Get>();
    expected.CompareToSet(actual);
}

验证集合是否相等,竟如此简单,跟验证单一对象几乎一模一样。差异只有:

  1. Scenario 上的 table 是多笔数据
  2. 取得数据的部分,从 table.CreateToInstane() 改成 table.CreateToSet() ,语义超级清楚
  3. 验证集合的部分,从 table.CompareToInstance() 改成 table.CompareToSet() ,比起循环一笔一笔验证,比较集合是否相等的语义相对清楚许多

而且,当单一对象与集合验证 failed 时,其错误消息更是清楚。 单一对象验证相等失败时,如下图所示: 

会将不同的 field 名称显示出来,也会将 expected value 与 actual value 显示出来为何不相等。 当两个集合验证相等失败时,如下图所示: 

从上图可以看到,错误消息会 highlight 出不一样的那几笔数据,并在 row 前面加上 + 的符号,有点像 compare diff 的呈现。- 的部分代表 expected,而 + 的部分代表 actual 。在错误消息中,两个集合究竟哪几笔的哪几个 property 值不一样,预期的值与实际的值分别为何,一目了然。 Specflow 这么完美的使用与呈现,实际上还会有什么问题呢?

  1. 不是每一种测试情境都适合使用 specflow ,例如已经存在的一般 unit test 程序,可能只是为了要增加验证不同的情境,需要比较对象或集合是否相等,此时为了验证对象而翻写或针对这个 test case 新写成 specflow 的形式,个人不是很建议。除非,团队成员都对 specflow 相当熟悉,且这份文档不只是给 developer 看,还需要给相关的 PO 或 stakeholders 使用或讨论。
  2. Table 的形式只能以二维来呈现,针对 composed object 的情况,只能想办法摊平成 N 个 property,这通常需要使用到 Linq to Objects 的 SelectMany() 并转型成匿名类型来做比较,这样做时同样会有匿名类型的一些小问题。
Specflow CompareToInstance()CompareToSet() 更详尽的介绍请见:[SpecFlow]在测试程序中比较单一对象与对象集合

Expected Objects

一直很纳闷,这种大家都有的问题,也有一堆人用一些方式克服了,以 specflow 来说,也是 open source 的,怎么可能每个人碰到这问题都绕路做,一定有人写好 package 了才对。(但在 stackoverflow 上查到问这问题的人,大部分的解答还是建议要走正规的解法去override Equals() ,或是偷懒的解法直接序列化对象后比较字符串。)

因缘际会下看到一篇 2011 年的文章 Introducing the Expected Objects Library ,天啊,这不就是我想要的吗?马上就把这个 nuget package 加入我目前的测试项目中,用它来验证单一对象、 composed object 、集合、 Dictionary 是否相等,还不只这样,对象部分 property 的比较,在实际需求也相当常见(只想比较跟 scenario 相关的 key property)。 事不宜迟,马上来看程序范例(github版本)。

Compare Instance

比较两个 Person 的 instance 程序如下:

[TestMethod]
public void Test_Person_Equals_with_ExpectedObjects()
{
    var expected = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    }.ToExpectedObject();
 
    var actual = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
    };
 
    expected.ShouldEqual(actual);
}

跟最原先的 Assert.AreEqual() 比较,Expected Objects 只需要做一点点改变:

  1. Expected 对象要调用一个扩充方法: ToExpectedObject()
  2. Expected 对象就会被转型为 ExpectedObject 这个 type ,接着把原本的 AreEqual() 替换成 ShouldEqual() 即可。

只需如此就能轻松写意的比较两个对象,一整个就是威!

来看一下验证 failed 时的资讯,也相当清楚。如下图所示: 

Compare Collection

比较两个 person collection 的程序如下:

[TestMethod]
public void Test_PersonCollection_Equals_with_ExpectedObjects()
{        
    var expected = new List
    {
        new Person { Id=1, Name="A",Age=10},
        new Person { Id=2, Name="B",Age=20},
        new Person { Id=3, Name="C",Age=30},
    }.ToExpectedObject();
 
    var actual = new List
    {
        new Person { Id=1, Name="A",Age=10},
        new Person { Id=2, Name="B",Age=20},
        new Person { Id=3, Name="C",Age=30},
    };
 
    expected.ShouldEqual(actual);
}

是的,就和 specflow 类似,但 Expected Objects 更加简洁,让 developer 不管对 instance 还是 collection 都一样, expected 只要调用 ToExpectedObject() 扩充方法,一样透过 ShouldEqual() 就能比较两个集合。 来看一下错误资讯的呈现方式,如下图所示: 

一样会呈现集合的哪一笔 item 不相等,不相等的原因是因为哪一个 property 值不一样,期望是什么值,实际是什么值。

Compare Composed Object

Composed Object 我习惯称它为巢状对象,要比较巢状对象里面各个 property 是否相等,就不只是运用 reflection 与 generic 这么单纯了,因为会牵扯到递归(recursive)。既然标榜比较对象是否相等, Expected Objects 当然也有支持巢状对象的比较。范例程序如下:

[TestMethod]
public void Test_ComposedPerson_Equals_with_ExpectedObjects()
{
    var expected = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
        Order = new Order { Id = 91, Price = 910 },
    }.ToExpectedObject();
 
    var actual = new Person
    {
        Id = 1,
        Name = "A",
        Age = 10,
        Order = new Order { Id = 91, Price = 910 },
    };
 
    expected.ShouldEqual(actual);
}

完全无违和感,不论是比较单一对象、集合或巢状对象,都是同样的方式。这替 developer 节省了不少需求异动时要花的功夫,还要节省脑袋的内存用量,因为不需要记忆太多 API 也不需要使用太多参数。

赶快来看验证 failed 时消息的呈现是否一样完美,如下图所示:

很好!一样会呈现巢状对象一路的属性名称,以及期望值与实际值的对照。

Partial Compare

Expected Objects 同样支持部分比较,什么叫做部分比较?就是我只想验证某一些 property ,所以只想定义验证 expected 上的某些 property ,actual 上其他的 property 我不在乎是否相等。程序如下所示:

[TestMethod]
public void Test_PartialCompare_Person_Equals_with_ExpectedObjects()
{        
    var expected = new
    {
        Id = 1,
        Age = 10,
        Order = new { Id = 91 }, 
    }.ToExpectedObject();
 
    var actual = new Person
    {
        Id = 1,
        Name = "B",
        Age = 10,
        Order = new Order { Id = 91, Price = 910 },
    };
 
    expected.ShouldMatch(actual);
}

上面的范例只会检查 expected 有的 property ,actual 多余的 property 则不列入比较。写起来还是一样简洁有力,跟一般的巢状对象比较,差异在:

  1. 最重要的一点,expected 对象需要完全以匿名类型来定义。这相当合理,因为只有匿名类型可以因需求来决定,只存在哪一些 property ,而有存在的 property 都需要相等。请留意 expected 的匿名类型包含 Order 属性也是匿名类型。另外,在撰写测试程序的时候,可以先把原本类型放上去,这样挑选 property 时才有 intellisense 可以用。写完 expected 之后,再把相关类型移除成为匿名类型即可。
  2. 因为是部分比较,不是完全相等,所以要将 ShouldEqual() 改为使用 ShouldMatch() ,这样语义更加清楚与精准。

其验证 failed 的消息就跟巢状对象一样,这边就不再赘述。

Partial Comparing Elements of Collection

想要针对一个集合中的各个 element 进行部分比较,可以结合上面的两种作法,如下图所示:

请留意匿名类型集合的声明,直接使用 new[] 即可。

Compare DataTable

既然标题有提到针对 legacy code 来撰写单元测试,在 legacy code 中很常还会遇到是使用弱类型的 DataTable 类型来传递数据。很不幸地,DataTable 是不能直接使用 ExpectedObjects 来比较的。因为 ExpectedObject 会将 expected 对象的所有 property recursive 的扫出来检查,而 DataTable 我们要比较的其实是 Row 的数据,而不是其他 property 。在验证失败时,我们也想知道是哪个字段的数据不一样,这也不同于原本对象使用 property 可以直接透过 reflection 取得,所以我透过 ExpectedObjects 针对 DataTable 写了个范例,希望对大家有帮助。程序(github版本)如下:

[TestMethod]
public void Test_DataTable_Equals_with_ExpectedObjects_and_ItemArray()
{
    var expected = new DataTable();
    expected.Columns.Add("Id");
    expected.Columns.Add("Name");
    expected.Columns.Add("Age");
 
    expected.Rows.Add(1, "A", 10);
    expected.Rows.Add(2, "B", 20);
    expected.Rows.Add(3, "C", 30);
 
    var actual = new DataTable();
    actual.Columns.Add("Id");
    actual.Columns.Add("Name");
    actual.Columns.Add("Age");
 
    actual.Rows.Add(1, "A", 10);
    actual.Rows.Add(2, "B", 20);
    actual.Rows.Add(3, "C", 30);
 
    //compare by ItemArray, just compare the value without caring column name; the disadvantage is that error information didn't show what column's value is different;
    var expectedItemArrayCollection = expected.AsEnumerable().Select(dr => dr.ItemArray);
    var actualItemArrayCollection = actual.AsEnumerable().Select(dr => dr.ItemArray);
 
    expectedItemArrayCollection.ToExpectedObject().ShouldEqual(actualItemArrayCollection);
}

因为 ItemArray 存放的就是 DataRow 的值,透过 ItemArray 与 column index 的对应而组出整个 DataTable 的数据结构,错误结果类似下图:

结论

撰写单元测试程序,势必会碰到比较对象、集合是否相等,甚至于巢状对象或部分比较的需求,在原生 C# 的定义中无法简单达成目的,这么多种作法,不管是哪一种,我们都应该考量:

  1. 测试 failed 时错误资讯能否迅速且精准地呈现预期与实际之间的落差
  2. 测试程序写起来是否具备可读性
  3. 测试程序语义是否精准
  4. 测试程序是否具备扩充的弹性
  5. 测试程序是否写起来简单明了且不容易出现 bug
  6. Developer 是否能快速地完成测试程序
  7. Developer 是否能简单使用,不用记忆太多 API 与参数

不得不说,跟 ExpectedObjects 真的是相见恨晚,我已经晚了四年才发现这个威力十足的好物,希望这一篇完整的对象比较文,也能节省读者们更多个四年。

Reference

  1. Sample code github位置
  2. Expected Objects Nuget
  3. Expected Objects github位置

或许您会对下列培训课程感兴趣:

  1. 2019/7/27(六)~2019/7/28(日):演化式设计:测试驱动开发与持续重构 第六梯次(中国台北)
  2. 2019/8/16(五)~2019/8/18(日):【C#进阶设计-从重构学会高易用性与高弹性API设计】第二梯次(中国台北)
  3. 2019/9/21(六)~2019/9/22(日):Clean Coder:DI 与 AOP 进阶实战 第二梯次(中国台北)
  4. 2019/10/19(六):【针对遗留代码加入单元测试的艺术】第七梯次(中国台北)
  5. 2019/10/20(日):【极速开发】第八梯次(中国台北)

想收到第一手公开培训课程资讯,或想询问企业内训、顾问、教练、咨询服务的,请洽 Facebook 粉丝专页:91敏捷开发之路。