[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<Order>
{        
    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<Order> 並覆寫了 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<Person> to List<AnonymousType> by Select()
    var expected = new List<Person>
    {
        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<Person> to List<AnonymousType> by Select()
    var actual = new List<Person>
    {
        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<Person>();
    ScenarioContext.Current.Set<Person>(actual);
}
 
[Then(@"I hope actual person should be equal to expected person")]
public void ThenIHopeActualPersonShouldBeEqualToExpectedPerson(Table expected)
{
    var actual = ScenarioContext.Current.Get<Person>();
    expected.CompareToInstance<Person>(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<Person>();
    ScenarioContext.Current.Set<IEnumerable<Person>>(actual);
}
 
[Then(@"I hope actual person collection should be equal to expected person collection")]
public void ThenIHopeActualPersonCollectionShouldBeEqualToExpectedPersonCollection(Table expected)
{
    var actual = ScenarioContext.Current.Get<IEnumerable<Person>>();
    expected.CompareToSet<Person>(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<Person>
    {
        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<Person>
    {
        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位置

blog 與課程更新內容,請前往新站位置:http://tdd.best/