Help us understand the problem. What is going on with this article?

面倒な等値アサートを簡潔に書く

本記事は C# でテストを記述する人を対象に、しばしば複合型の等値アサートで生じる「面倒さ」に対するソリューションの一つを紹介します。テストフレームワークはなんでも。

その前に、先ずはシンプルな (そして理想的な) 等値アサートから:

public string GetFoo() {
    return "Foo";
}

...

string actual = GetFoo();
string expected = "Foo";

Assert.AreEqual(expected, actual);  // 成功!

アサート対象が intstring といった基本型なら、たいてい困る事もなく素直に書けます。
しかし、対象がユーザー定義型のような複合型だとそうは問屋が卸さない。

面倒な等値アサート

例えば Account というユーザー定義クラスと、それを返すクエリ サービス AccountQuery があったとします:

class Account {
  public int Id { get; set; }
  public string Name { get; set; }
  public List<string> Tags { get; } = new List<string>();
}

class AccountQuery {
  /// 例なので固定データを返すだけ。
  public Account Find(int id) {
    return new Account { Id = id, Name = "Foo" + id, Tags = { "tag1" } };
  }
}

次に、AccountQuery が返す Account が期待通りか調べるテストを書いてみます:

Account actual = new AccountQuery().Find(id: 1);
Account expected = new Account { Id = 1, Name = "Foo1", Tags = { "tag1" } };

//Assert.AreEqual(expected, actual);  // 大抵のテストフレームワークで参照比較となり失敗する。ので、
Assert.AreEqual(expected.Id, actual.Id);  // データメンバー毎に等値アサートが必要
Assert.AreEqual(expected.Name, actual.Name);
CollectionAssert.AreEqual(expected.Tags, actual.Tags);  // コレクションには専用の Assert が必要な事が殆ど
// Account に Enabled プロパティが足されたらアサート忘れそう・・・

等値アサートしたい actual は一つだけなのに、わざわざデータメンバーの数だけ Assert.AreEqual() を書くのは大変です。しかもデータメンバーの型に応じて専用の Assert メソッドを使い分ける必要もあったり。 Assert.AreEqual() で全部いい感じにやってくれYO

更に問題なのが、Account に新しいプロパティが追加された場合でも、上記のテストは追加プロパティをアサートする事なく成功してしまうという事。これはマズイ。

テストフレームワークによっては IEuqalityComparer<T> 等のカスタム等値性を Assert に与える事でフォローできる場合もありますが、あまりにも自明な等値アサートをしたいだけなのに新たに型を実装するとか面倒すぎます。

このように、等値アサートの書き方に時間を取られるのはもうウンザリ :confounded:

PrimitiveAssert で簡潔に書く

https://www.nuget.org/packages/Inasync.PrimitiveAssert/

PrimitiveAssert はテスト対象データをいくつかの基本型(プリミティブ データ)に分解し、個別に比較します。
API は基本的に次の拡張メソッド一つだけです。

actual.AssertIs(expected);

これで書き換えてみましょう:

Account actual = new AccountQuery().Find(id: 1);
Account expected = new Account { Id = 1, Name = "Foo1", Tags = { "tag1" } };

actual.AssertIs(expected);  // 成功!

今度はいい感じに等値アサートしてくれます! 見た目もシンプルになりました!!
データメンバーにコレクションがあっても要素に分解して等値アサートします。

API も一つだけなので、基本的にはこれだけです。
そう、これぐらい簡単でいいんだよ :sob:

もう少し詳解

コレクションの等値アサートはできる?

Yes!
汎用的なコレクション (System.Collections 以下にあるような型) 同士であれば、型に関わらずコレクションとして等値アサートされます。

List<string> actual = new List<string> { "foo", "bar" };
string[] expected = new[] { "foo", "bar" };

actual.AssertIs(expected);  // 成功!

タプルの等値アサートはできる?

Yes!
Tuple 同士、ValueTuple 同士はもちろん、TupleValueTuple の等値アサートでも問題ありません。

var actual = Tuple.Create("foo", "bar");
var expected = ("foo", "bar");

actual.AssertIs(expected);  // 成功!

循環参照の等値アサートはできる?

Yes!
ウロボロスに囚われる事はありません。

class Foo {
    public Foo Self => this;
}

...

Foo actual = new Foo();
Foo expected = new Foo();

actual.AssertIs(expected);  // 成功!

本当に全てのデータメンバーが等値アサートされている?

という場合は、コンソールにログを出力してみることもできます。

// AssertIs() を呼び出す前、テストのセットアップ処理とかに書いておく。
// 既定値は false
PrimitiveAssert.ConsoleLogging = true;

だいたい下記のような出力が得られます。

Console
actual: Account = {
    Id: Int32 = 1    // actual と expected は数値型として等しいです。
    Name: String = "Foo1"    // actual と expected は String 型として等しいです。
    Tags: List<String> = {
        [0]: String = "tag1"    // actual と expected は String 型として等しいです。
    }
}

expected は匿名型でも良い?

Yes!
PrimitiveAssert は基本的に型を比較しません。代わりにターゲット型として指定した型のデータメンバーを全て満たしているか否かを検証します。その為、expected は匿名型でも、全く関係のない HogeHoge 型でも問題ありません。

また、ターゲット型の指定は任意で、省略時には actual の型(ここでは Account)が設定されます。

Account actual = new AccountQuery().Find(id: 1);
var expected = new { Id = 1, Name = "Foo1", Tags = new[] { "tag1" } };

actual.AssertIs(expected);  // 成功!
actual.AssertIs<Account>(expected);  // 型パラメーターでターゲット型を指定できる

expected にはターゲット型の全てのデータメンバーが必要

前述の通り、expected は型に縛られない代わりに、ターゲット型の全てのデータメンバーを満たす必要があります。
これにより、後々 AccountEnabled プロパティが追加された場合、expected にも Enabled が無いと失敗するようにもなりました。これでデータメンバーがアサートから漏れている状況を検知する事ができます。

class Account {
  ...
  public bool Enabled { get; set; }  // 新たに追加したプロパティ
}

class AccountQuery {
  public Account Find(int id) {
    return new Account { Id = id, Name = "Foo" + id, Tags = { "tag1" }, Enabled = true };
  }
}

...

var actual = new AccountQuery().Find(id: 1);
//actual.AssertIs(new { Id = 1, Name = "Foo1", Tags = new[] { "tag1" } });  // Enabled が無いので失敗
actual.AssertIs(new { Id = 1, Name = "Foo1", Tags = new[] { "tag1" }, Enabled = true });  // 成功!

More info

README.md には PrimitiveAssert の詳細とより多くの事例が載っています。ぜひ参照してみてください。

つまり?

PrimitiveAssert を使えばたいていの等値アサートは簡潔に書けます!

var actual = new { Foo = "Foo1", Bar = new List<int>{ 1, 2 } };

actual.AssertIs(new { Foo = "Foo1", Bar = new[] { 1, 2 } });  // 成功!

参照

inasync
🎨 A simple C#er
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away