【C#, NUnit】NUnit で自前のクラスを比較するにはどうすればいいのかいろいろ調べたことのまとめ【IEquatable】


🙇‍♂️解釈違いなどがあればご指摘ください。
🙇‍♂️いずれも参考資料が詳しくそちらを参照していただくのみで充分な内容であるかもしれません。
🙇‍♂️同じような疑問からスタートされた方に対してどのような手順で調査して理解が得られたのかということを共有したいと思います。

※コメントでのご指摘を受けて本文中の Assert.That による比較という表現を EqualConstraint による比較に修正しました。
※さらに追記でタイトルの修正もしました

調べたこと

下記のようなクラス TestClass1に対しての値の検証 Test1 の結果が失敗❌するのは何故なのかを調べました。
最終的には Test1 を成功✅させるにはどうしたらよいのかということまでまとめます。

TestClass1
public class TestClass1
{
    public string Id { get; set; }
    public string Name { get; set; }

    public TestClass1(string id, string name)
    {
        Id = id;
        Name = name;
    }
}
Test1
[Test]
public void Test1()
{
    TestClass1 actual = new TestClass1("1", "takashi");
    TestClass1 expect = new TestClass1("1", "takashi");
    Assert.That(actual, Is.EqualTo(expect)); //失敗する
}

環境など

  • Visual Studio 2019
  • Microsoft.NET.Test.Sdk 16.5.0
  • NUnit 3.12.0
  • NUnit3TestAdapter 3.16.1

等価性について

なんとなくの知識として、オブジェクト同士で比較する場合にはそれらが同一のインスタンスであるかどうかでイコールかそうでないかが決まるというような理解でした。つまり下記のようになるということは知っていました。

TestClass1 actual = new TestClass1("1", "takashi");
TestClass1 expect = new TestClass1("1", "takashi");
var isEqual = actual == expect // False

ですので、Assert.That EqualConstraint (Is.EqualTo) による比較においてもこれと同じ要領で比較が行われた結果、テストは失敗したのだと考え、まずは等値演算子(==)と「等価性」について調べてみました。

まず、等価性を考えるにあたって、その対象には「値型」と「参照型」とがあり(リファレンスには他に「文字列」と「デリゲート」がありました)今回作成した TestClass1 のようなクラスは参照型となります。等価性を求めるためには等値演算子と Equals メソッドが用いられ、参照型は既定ではそれらにおいて「参照の等価性」の比較を行っています。また、ここでの既定の Equals メソッドとは Object.Equals メソッド であると理解しました。

ここまでで、私が求めている二つのオブジェクトの比較は「値の等価性」を求めるものであり Assert.That EqualConstraint は「参照の等価性」の比較を行っているのではないかということがわかりました。

参照型の等価性
==演算子とEqualsメソッドの違いとは?[C#]

EqualConstraint について

※章題変更しました。

では、Assert.That EqualConstraint による比較は参照の等価性の比較なのかということを調べてみました。
https://github.com/nunit/docs/wiki/EqualConstraint#notes にそのようなことが書いてありました。

When checking the equality of user-defined classes, NUnit first examines each class to determine whether it implements IEquatable (unless the AsCollection modifier is used). If either object implements the interface for the type of the other object, then that implementation is used in making the comparison. If neither class implements the appropriate interface, NUnit makes use of the Equals override on the expected object. If you neglect to either implement IEquatable or to override Equals, you can expect failures comparing non-identical objects. In particular, overriding operator == without overriding Equals or implementing the interface has no effect.

クラスに IEquatable <T> の実装がなければオーバーライドされた Equals メソッドを、Equals メソッドのオーバーライドがなければ既定の Object.Equals を用いて等価性の検証をするということでしょうか🤔
であれば、TestClass1 にはいずれもありませんから参照の等価性で比較を行った結果テストが失敗❌したのだと考えられます。

https://github.com/nunit/docs/wiki/EqualConstraint

IEquatable の実装

上記にて IEquatable というものについて言及されていたのでこちらについて調べました。
とはいえ IEquatableを完全に理解する という記事でまとめられており自分が改めてまとめる必要はないかと思います。
ただ、ここまで調べてきて「可変型(ミュータブル)」についてまだうまく理解しきれていません。「値」であるかという点に注目して等価性判定についてどのように実装するのかということはなんとなくわかったので、参照型である自前のクラスが値としての性質を持っている場合には不変型(イミュータブル)とするべきであると考えると良いのでしょうか🤔今回の実際のケース(※後述)においては「値」で「イミュータブル」なクラスと判断したので IEquatable を実装しました(自動生成)。

参考記事でも紹介されていましたが Visual Studio の クイック アクションとリファクタリング の機能でクラスに対して IEquatable の実装の自動生成ができます(Visual Studio 2019版)。また、同記事 配列等に注意 は実際に嵌っていたところを解決してくれました。

IEquatableを完全に理解する
Visual Studio で Equals および GetHashCode メソッドのオーバーライドを生成する
値型と参照型とミュータブルとイミュータブルと

まとめ

今回の疑問のスタート地点はテストがなぜ失敗するのかというところであり Assert.That EqualConstraint の挙動から IEquatableに辿り着きました。しかし、本来は等価性という考え方に基づいて IEquatable や等値演算子の実装のガイドラインやベストプラクティスが存在し Assert.That EqualConstraint においてもそちらに基づいているのだと理解しました。

自前のクラスにおいてもそうしたガイドラインに従い等価性判定のメソッドを実装する必要があります。今回のケースではその一つのパターンとして IEquatable の実装及び Equals メソッド、GetHashCode メソッドのオーバーライド、等値演算子のオーバーロードを行いました。

まだ完全に理解できていない部分、調査できていない部分あると思います。そうした部分については今後実際のケースを通して今回のように調査しながら深めていけたらと思っています。

TestClass1(IEquatableを実装)
public class TestClass1 : IEquatable<TestClass1>
{
    public string Id { get; }
    public string Name { get; }
    public TestClass1(string id, string name)
    {
        Id = id;
        Name = name;
    }
    public override bool Equals(object obj)
    {
        return Equals(obj as TestClass1);
    }
    public bool Equals([AllowNull] TestClass1 other)
    {
        return other != null &&
               Id == other.Id &&
               Name == other.Name;
    }
    public override int GetHashCode()
    {
        return HashCode.Combine(Id, Name);
    }
    public static bool operator ==(TestClass1 left, TestClass1 right)
    {
        return EqualityComparer<TestClass1>.Default.Equals(left, right);
    }
    public static bool operator !=(TestClass1 left, TestClass1 right)
    {
        return !(left == right);
    }
}


Test1 成功の図

補足など

実際のケースは、API層(Controller)、ドメイン層、データ層のような構成の Web API におけるドメイン層での EntityModel Class のデータを ViewModel Class のデータに変換するロジック部分のメソッドの検証です。

また、https://stackoverflow.com/questions/318210/compare-equality-between-two-objects-in-nunit でテストのためだけに Equals メソッドのオーバーライドなどはするべきではないという回答がありました。これについては、ドメインロジックに影響するから~というよりかは、等価性に関する検討を行い適切なパターンで等価性判定を実装してそれに従ってテストしなくてはいけないから(あるいはテストのほうが先にできている)と考える方が、今回調査してみた結果、自分なりにしっくりきます。

参考