本記事は C# でテストを記述する人を対象に、しばしば複合型の等値アサートで生じる「面倒さ」に対するソリューションの一つを紹介します。テストフレームワークはなんでも。
その前に、先ずはシンプルな (そして理想的な) 等値アサートから:
publicstringGetFoo(){return"Foo";}...stringactual=GetFoo();stringexpected="Foo";Assert.AreEqual(expected,actual);// 成功!
アサート対象が int
や string
といった基本型なら、たいてい困る事もなく素直に書けます。
しかし、対象がユーザー定義型のような複合型だとそうは問屋が卸さない。
面倒な等値アサート
例えば Account
というユーザー定義クラスと、それを返すクエリ サービス AccountQuery
があったとします:
classAccount{publicintId{get;set;}publicstringName{get;set;}publicList<string>Tags{get;}=newList<string>();}classAccountQuery{/// 例なので固定データを返すだけ。publicAccountFind(intid){returnnewAccount{Id=id,Name="Foo"+id,Tags={"tag1"}};}}
次に、AccountQuery
が返す Account
が期待通りか調べるテストを書いてみます:
Accountactual=newAccountQuery().Find(id:1);Accountexpected=newAccount{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 に与える事でフォローできる場合もありますが、あまりにも自明な等値アサートをしたいだけなのに新たに型を実装するとか面倒すぎます。
このように、等値アサートの書き方に時間を取られるのはもうウンザリ
PrimitiveAssert で簡潔に書く
https://www.nuget.org/packages/Inasync.PrimitiveAssert/
PrimitiveAssertはテスト対象データをいくつかの基本型(プリミティブ データ)に分解し、個別に比較します。
API は基本的に次の拡張メソッド一つだけです。
actual.AssertIs(expected);
これで書き換えてみましょう:
Accountactual=newAccountQuery().Find(id:1);Accountexpected=newAccount{Id=1,Name="Foo1",Tags={"tag1"}};actual.AssertIs(expected);// 成功!
今度はいい感じに等値アサートしてくれます! 見た目もシンプルになりました!!
データメンバーにコレクションがあっても要素に分解して等値アサートします。
API も一つだけなので、基本的にはこれだけです。
そう、これぐらい簡単でいいんだよ
もう少し詳解
コレクションの等値アサートはできる?
Yes!
汎用的なコレクション (System.Collections
以下にあるような型) 同士であれば、型に関わらずコレクションとして等値アサートされます。
List<string>actual=newList<string>{"foo","bar"};string[]expected=new[]{"foo","bar"};actual.AssertIs(expected);// 成功!
タプルの等値アサートはできる?
Yes!Tuple
同士、ValueTuple
同士はもちろん、Tuple
と ValueTuple
の等値アサートでも問題ありません。
varactual=Tuple.Create("foo","bar");varexpected=("foo","bar");actual.AssertIs(expected);// 成功!
循環参照の等値アサートはできる?
Yes!
ウロボロスに囚われる事はありません。
classFoo{publicFooSelf=>this;}...Fooactual=newFoo();Fooexpected=newFoo();actual.AssertIs(expected);// 成功!
本当に全てのデータメンバーが等値アサートされている?
という場合は、コンソールにログを出力してみることもできます。
// AssertIs() を呼び出す前、テストのセットアップ処理とかに書いておく。// 既定値は falsePrimitiveAssert.ConsoleLogging=true;
だいたい下記のような出力が得られます。
actual と expected は数値型として等しいです。
{
path: ./Id:Int32
target: System.Int32
actual: 1
expected: 1
}
actual と expected は String 型として等しいです。
{
path: ./Name:String
target: System.String
actual: Foo1
expected: Foo1
}
actual と expected は String 型として等しいです。
{
path: ./Tags:List`1/0:String
target: System.String
actual: tag1
expected: tag1
}
expected は匿名型でも良い?
Yes!
PrimitiveAssertは基本的に型を比較しません。代わりにターゲット型として指定した型のデータメンバーを全て満たしているか否かを検証します。その為、expected は匿名型でも、全く関係のない HogeHoge 型でも問題ありません。
また、ターゲット型の指定は任意で、省略時には actual の型(ここでは Account
)が設定されます。
Accountactual=newAccountQuery().Find(id:1);varexpected=new{Id=1,Name="Foo1",Tags=new[]{"tag1"}};actual.AssertIs(expected);// 成功!actual.AssertIs<Account>(expected);// 型パラメーターでターゲット型を指定できる
expected にはターゲット型の全てのデータメンバーが必要
前述の通り、expected は型に縛られない代わりに、ターゲット型の全てのデータメンバーを満たす必要があります。
これにより、後々 Account
に Enabled
プロパティが追加された場合、expected にも Enabled
が無いと失敗するようにもなりました。これでデータメンバーがアサートから漏れている状況を検知する事ができます。
classAccount{...publicboolEnabled{get;set;}// 新たに追加したプロパティ}classAccountQuery{publicAccountFind(intid){returnnewAccount{Id=id,Name="Foo"+id,Tags={"tag1"},Enabled=true};}}...varactual=newAccountQuery().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を使えばたいていの等値アサートは簡潔に書けます!
varactual=new{Foo="Foo1",Bar=newList<int>{1,2}};actual.AssertIs(new{Foo="Foo1",Bar=new[]{1,2}});// 成功!