テストを簡単にするほんの少しの拡張メソッド
- 2010-08-02
テストドリブンしてますか?私は勿論してません。え……。別に赤が緑になっても嬉しくないし。コード先でテスト後のほうが書きやすくていいなあ。でもそうなると、テスト書かなくなってしまって、溜まるともっと書かなくなってしまっての悪循環。
そんな普段あまりテスト書かないクソッタレな人間なわけですが(レガシーコード殺害ガイドが泣いている)、普段テスト書かないだけに書こうとすると単純なものですらイライライライラしてしまって大変よくない。しかし、それはそもそもテストツールが悪いんじゃね?という気だってする。言い訳じゃなく、ふつーにバッチイですよ、テストコード。こんなの書くのはそりゃ苦痛ってものです。
Before
例えば、こういうどうでもいいクラスがあったとします。
public class MyClass
{
public string GetString(string unya)
{
return (unya == "unya") ? null : "hoge";
}
public IEnumerable<int> GetEnumerable()
{
yield return 1;
yield return 2;
yield return 3;
}
}
ウィザードで生成されたのをベースに書くとこうなる(MSTestを使っています)
[TestMethod()]
public void GetStringTest()
{
MyClass target = new MyClass();
string unya = "unya";
string expected = null;
string actual;
actual = target.GetString(unya);
Assert.AreEqual(expected, actual);
expected = "hoge";
actual = target.GetString("aaaaa");
Assert.AreEqual(expected, actual);
}
[TestMethod()]
public void GetEnumerableTest()
{
MyClass target = new MyClass();
IEnumerable<int> expected = new[] { 1, 2, 3 };
IEnumerable<int> actual;
actual = target.GetEnumerable();
CollectionAssert.AreEqual(expected.ToArray(), actual.ToArray());
}
何だこりゃ。超面倒くさい。むしろテストがレガシーすぎて死にたい。CollectionAssertはIEnumerableに対応してないし。泣きたい。こんなの書いてられない。吐き気がする。
After
JavaScriptのQUnitは、大抵EqualとDeepEqualで済む簡単さで、それがテストへの面倒くささを大いに下げてる。見習いたい。シンプルイズベスト。ごてごてしたAssert関数なんて悪しき伝統にすぎないのではなかろうか?と思ったので、もうアサート関数なんてIsだけでいいぢゃん、ついでにactualの後ろに拡張メソッドでそのままexpected書けると楽ぢゃん、と開き直ることにしました。
[TestMethod()]
public void GetStringTest()
{
// 1. 全オブジェクトに対して拡張メソッドIsが定義されててAssert.AreEqualされる
// 2. ラムダ式も使えるので、andやorや複雑な比較などはラムダ式でまかなえる
// 3. nullはIs()で(本当はIs(null)でやりたかったのだけど、都合上断念)
new MyClass().GetString("aaaaa").Is("hoge");
new MyClass().GetString("aaaaa").Is(s => s.StartsWith("h") && s.EndsWith("e"));
new MyClass().GetString("unya").Is();
}
[TestMethod()]
public void GetEnumerableTest()
{
// 対象がIEnumerableの場合はCollectionAssert.Equalsで比較されます
// 可変長配列を受け入れることが出来るので直書き可
new MyClass().GetEnumerable().Is(1, 2, 3);
}
すんごく、すっきり。メソッドはIsだけ、ですがそれなりのオーバーロードが仕込まれているので、ほとんどのことが一つだけで表現出来ます。IsNullはIs()でいいし(表現的には分かりにくくて嫌なのですが、Is(null)だとオーバーロードの解決ができなくてIs((型)null)と書かなくて面倒くさいので、泣く泣く引数無しをIsNullとしました)し、IsTrueはIs(true)でいい。複雑な条件で比較したいときはラムダ式を渡せばいい。Is.EqualTo().Within().And() とか、全然分かりやすくないよね。流れるようなインターフェイスは悪くないけれど、別に自然言語的である必要なんて全然なくて、ラムダ一発で済ませられるならそちらのほうがずっと良い。.Should().Not.Be.Null()なんてまで来ると、もう馬鹿かと思った。
大事なのはシンプルに気持良く書けることであって、形式主義に陥っちゃいけないのさあ。
コレクション比較もIsだけですませます。IEnumerableを渡すことも出来るし、可変長引数による値の直書きも出来る。なお、Isのみなのでコレクション同士の参照比較はありません。コレクションだったら有無をいわさず要素比較にします。だって、別に参照比較したいシーンなんてほとんどないでしょ?そういう例外的な状況は素直にAssert.AreEqual使えばいい。また、CollectionAssertには色々なメソッドがありますが、それ全部Linqで前処理すればいいよね?例えばCollectionAsert.IsEmptyはAny().Is(false)で済ませられるので不要。他のも大体はLinqで何とかできるので大概不要です。
ところで、このぐらいだとウィザードが冗長というだけで
Assert.AreEqual(new MyClass().GetString("aaaaa"), "hoge");
って書けるじゃないかって突っ込みは、そのとおり。でも、少し長くなると、引数に押し込めるの大変になってきますよね。そうなると
var expected = "hoge";
var actual = new MyClass().GetString("aaaaa")
Assert.AreEqual(expected, actual);
といった具合に、変数名が必要になって大変かったるい。ので、余計な一時変数なしで流し込める方が圧倒的に楽です。そもそもに、Assert.AreEqualだと、毎回どっちがactualでどっちがexpectedだか悩むのがイライラしてしまって良くない。まあ、逆でもいいんですが。よくないんですが。
パラメータ違いのテストケース
ついでに面倒くさいのは、パラメータが違うだけにすぎない、同じようなAssertの量産。テストなんてとっとと書いてナンボなので大体コピペで取り回しちゃうわけですが、どう考えてもクソ対応です本当にありがとうございました。そういうことやると、テストの書き直しが出来なくなって身重になってしまって良くない。コードはサクッと書き直せるべきだし、テストもサクッと書き直せるべきだ。といったわけで、NUnitには属性を足すだけでパラメータ違いのテストを実行出来るそうですがMSTestにはなさそう。うーん、でも、Linqがあれば何でも出来るよ?Linq万能神理論。ということで、Linqをベースにしてパラメータ違いを渡せるクラスを書いてみました。
// コレクション初期化子を使ってパラメータを生成します
new Test.Case<int, int, int>
{
{1, 2, 3},
{100, 200, 500},
{10000, 20, 30}
}
.Select(t => t.Item1 + t.Item2 + t.Item3)
.Is(6, 800, 10050);
複数の値はTupleに突っ込めばいい。あとはSelectでactualを作って、最後にIsの可変長引数使って期待値と比較させれば出来上がり。Tupleは、C#には匿名型があるため、あまり活用のシーンがないのですが、こういうところでは便利。このTest.Caseは7引数のTupleまで対応しています(それ以上?そもそも標準のTupleの限界がそれまでなので)。使い方はnewしてコレクション初期化子でパラメータを並べるだけ。
つまるところTest.CaseクラスはただのTupleCollectionです。Tupleの配列を作るには、普通だと new[]{Tuple.Create, Tuple.Create...} と書かなければならず、死ぬほど面倒。そこで出てくるのがコレクション初期化子。これなら複数引数を受け入れるのが楽に記述できる。というわけで、コレクション初期化子を使いたいがためだけに、クラスを立てました。唯一の難点はnewしなければならない、つまりジェネリクスの型引数を書かなければならない、ということでしょうか。
そうそう、コレクション初期化子のおさらいをすると、IEnumerable<T>かつAddメソッド(名前で決め打ちされてる)があると呼び出せます。複数引数時も、波括弧で要素をくくることで対応できます(Dictionaryなどで使えるね)。
ソースコード
長々と長々してましたがソースを。Test.CaseのTupleの量産が面倒なのでT4 Templateにしました。Test.ttとかって名前にしてテストプロジェクトに突っ込んでください。中は完全に固定だから、取り回すなら生成後のTest.csを使っていくと良いかもですね。ご利用はご自由にどうぞ。パブリックドメインで。
<#@ assembly Name="System.Core.dll" #>
<#@ import namespace="System.Linq" #>
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.VisualStudio.TestTools.UnitTesting
{
public static class Test
{
// extensions
/// <summary>IsNull</summary>
public static void Is<T>(this T value)
{
Assert.IsNull(value);
}
public static void Is<T>(this T actual, T expected, string message = "")
{
Assert.AreEqual(expected, actual, message);
}
public static void Is<T>(this T actual, Func<T, bool> expected, string message = "")
{
Assert.IsTrue(expected(actual), message);
}
public static void Is<T>(this IEnumerable<T> actual, IEnumerable<T> expected, string message = "")
{
CollectionAssert.AreEqual(expected.ToArray(), actual.ToArray(), message);
}
public static void Is<T>(this IEnumerable<T> actual, params T[] expected)
{
Is(actual, expected.AsEnumerable());
}
public static void Is<T>(this IEnumerable<T> actual, IEnumerable<Func<T, bool>> expected)
{
var count = 0;
foreach (var cond in actual.Zip(expected, (v, pred) => pred(v)))
{
Assert.IsTrue(cond, "Index = " + count++);
}
}
public static void Is<T>(this IEnumerable<T> actual, params Func<T, bool>[] expected)
{
Is(actual, expected.AsEnumerable());
}
// generator
<#
for(var i = 1; i < 8; i++)
{
#>
public class Case<#= MakeT(i) #> : IEnumerable<Tuple<#= MakeT(i) #>>
{
List<Tuple<#= MakeT(i) #>> tuples = new List<Tuple<#= MakeT(i) #>>();
public void Add(<#= MakeArgs(i) #>)
{
tuples.Add(Tuple.Create(<#= MakeParams(i) #>));
}
public IEnumerator<Tuple<#= MakeT(i) #>> GetEnumerator() { return tuples.GetEnumerator(); }
IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
}
<#
}
#>
}
}
<#+
string MakeT(int count)
{
return "<" + String.Join(", ", Enumerable.Range(1, count).Select(i => "T" + i)) + ">";
}
string MakeArgs(int count)
{
return String.Join(", ", Enumerable.Range(1, count).Select(i => "T" + i + " item" + i));
}
string MakeParams(int count)
{
return String.Join(", ", Enumerable.Range(1, count).Select(i => "item" + i));
}
#>
オプション引数のお陰で、こういうちょっとしたのが書くの楽になりましたね(C#4.0 からの新機能)。あとは、可変長引数が配列だけじゃなくてIEnumerableも受け付けてくれれば、AsEnumerableで渡すだけの余計なオーバーロードを作らないで済むんだよね。C# 5.0に期待しますか。
まとめ
テストのないコードはレガシーコード。と、名著が言ってる(1/4ぐらいしかまだ読んでませんが!)のでテストは書いたほうがいいっす。
でも、コード書きってのは気持良くなければならない。気持ち良ければ自然に書くんです。書かない、抵抗感があるってのは、環境が悪いんです。「テスト書きは苦痛だけど良いことだから、赤が緑に変わると嬉しいから書こうぜ!」とかありえない。そんな自己啓発っぽいのは無理。というわけで、拡張メソッドで環境を変えて、気持よく生きましょうー。
JsUnit(非常にイマイチ)もそうだったんだけど、Java由来(xUnitはSmalltalkのー、とかって話は分かってます)のライブラリとかは、Java的な思考に引き摺られすぎ。もっと言語に合わせたしなやかなAPIってものがあると思うんですよね。MSTestはVS2010で、色々刷新してLinqや拡張メソッドを生かしたものを用意すべきだったと思います。C#2.0的なコードは読むのも書くのも、もう苦痛。レガシーコードとは何か?C#2.0的なコードです。いやほんと。生理的な問題で。
追記
ここで例として出したものを、より洗練させてライブラリとしてまとめました。Chaining Assertion for MSTest よければこちらもどうぞ。