TestCase 属性などによるテストコードのリファクタリング
昨日のわんくまの昼休みに TDD 道場があったんですが、テストコードのリファクタリングについて賛否あったのでちょっと自分の考えをまとめておきます。
それに加え、C# と NUnit でどのようにテストコードをリファクタリングできるか、というのも紹介します。
というかこちらがメイン。
テストを追加した際に追加したテストだけを実行するか全部実行するかというのも意見が分かれたんですが、主に個別に指定するのが面倒という理由で全部実行する派です。
全部実行すると時間がかかる?それはもはや単体テストじゃないですね。重いテストは分割して分離しちゃいましょう。
これに関してはレガシーコード改善ガイド (Object Oriented SELECTION)をどうぞ。機会があればここら辺についても書きたいところです。
テストコードのリファクタリングについて
まず、テストコードのリファクタリングはありだと思います。
というか、やらないといけないでしょう。
重複が多く汚いテストコードに対して新しいテストの追加や不要なテストを削除するのは面倒です。
テストの追加や削除が面倒になると、テストをサボってしまう原因にもなってしまいます。
なので、テストコードは綺麗であるべきなのです。
テストコードもリファクタリングしましょう。
ここで、リファクタリングの定義を確認しておきましょう。
- リファクタリング (名詞)
- 外部から見たときの振る舞いを保ちつつ、理解や修正が簡単になるように、ソフトウェアの内部構造を変化させること。
〜略〜
リファクタリング プログラミングの体質改善テクニック (P. 53, 54)
このように、リファクタリングでは外部から見た振る舞いが重要です。
では、テストコードの外部から見た振る舞いとはなんでしょうか?
大ざっぱに言うと、テストが成功するか失敗するか、でいいと思います。
テストコードのリファクタリングを「テストがすべて通っているときのみ行っていい」という制限を付けると、テストコードを変更してもどのテストも失敗しない状態が保たれればいいということになるでしょう*1。
NUnit によるテストコードのリファクタリング
さて本題。
会場では Test 属性によるテストで、確かこんな感じのコードでした。
// 実際はVBだったけど、よく分からない(ことになっている)のでC#で [Test] public void TestFizzBuzz01() { var fizzBuzz = new FizzBuzz(); Assert.AreEqual("1", fizzBuzz.Say(1)); } [Test] public void TestFizzBuzz03() { var fizzBuzz = new FizzBuzz(); Assert.AreEqual("Fizz", fizzBuzz.Say(3)); }
で、これをリファクタリングして確かこうしていたはず。
FizzBuzz fizzBuzz = new FizzBuzz(); [Test] public void TestFizzBuzz01() { Assert.AreEqual("1", fizzBuzz.Say(1)); } [Test] public void TestFizzBuzz03() { Assert.AreEqual("Fizz", fizzBuzz.Say(3)); }
一部重複は消えましたけど、これに新しいテストを追加するのはちょっと面倒ですよね。
TestCase 属性によるリファクタリング
そこで TestCase 属性の出番です。こうしちゃいましょう。
[TestCase(1, "1")] [TestCase(3, "Fizz")] public void TestFizzBuzz(int input, string expected) { // 個人的な嗜好によりAssert.Thatを使用 var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz(input), Is.EqualTo(expected)); }
これで、テストの追加・削除は楽勝です。
[TestCase(1, "1")] [TestCase(3, "Fizz")] [TestCase(5, "Buzz")] public void TestFizzBuzz(int input, string expected) { var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz(input), Is.EqualTo(expected)); }
ですが、実際は入力も出力もコンパイル時定数でいい状況というのはあまりありません*2。
ではどうすればいいか。そこで TestCaseSource 属性の出番です。
TestCaseSource 属性によるリファクタリングその 1
[Test] public void TestFizzBuzz01() { var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz.Say(1), Is.EqualTo(new Result("1")); } [Test] public void TestFizzBuzz03() { var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz.Say(3), Is.EqualTo(new Fizz()); }
恣意的な例ですが、あくまで例ということでお願いします。
結果がコンパイル時定数ではなく、Result と Fizz という別の型 (Result と Fizz は共通の親を持つか、どちらかがどちらかを継承している) になっています。
TestFizzBuzz03 が
Assert.That(fizzBuzz.Say(3), Is.EqualTo(new Result("Fizz"));
のようになっていれば、
[TestCase(1, "1")] public void TestFizzBuzz(int input, string expected) { var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz.Say(input), Is.EqualTo(new Result(expected)); }
とできたのですが・・・
ここで、この例ならテストコードではなくプロダクトコードを変更するという手もあります。
が、あくまで例と言うことで以下略。
で、TestCaseSource 属性です。TestCaseSource 属性を使うと、このようになります。
[TestCaseSource("TestCases")] public void TestFizzBuzz(int input, Result expected) { var fizzBuzz = new FizzBuzz(); Assert.That(fizzBuzz.Say(input), Is.EqualTo(expected)); } static object[] TestCases = { new object[] { 1, new Result("1") }, new object[] { 3, new Fizz() }, };
テストを追加する場合、TestCases に追記するだけです。
static object[] TestCases = { new object[] { 1, new Result("1") }, new object[] { 3, new Fizz() }, new object[] { 5, new Buzz() }, };
TestCase 属性より少々面倒にはなりましたが、テストメソッドを増やす場合に比べればどうってことありません。
TestCaseSource 属性によるリファクタリングその 2
引数を 2 つ取る TestCaseSource 属性を使うと、テストを柔軟に記述できるようになります。
[TestFixture] class FizzBuzzTest { [TestCaseSource(typeof(FizzBuzzTest), "TestCases")] public Result TestFizzBuzz(int input) { var fizzBuzz = new FizzBuzz(); return fizzBuzz.Say(input); } public static IEnumerable<TestCaseData> TestCases { get { yield return new TestCaseData(1).Returns(new Result("1")); yield return new TestCaseData(3).Returns(new Fizz()); // 範囲外の値を渡すと例外が発生することを確認するテスト yield return new TestCaseData(0).Throws(typeof(ArgumentOutOfRangeException)); } } }
引数を 1 つ取るバージョンの TestCaseSource 属性や、TestCase 属性で例外が発生することを確認するテストを記述しようとした場合、どうしてもメソッドを分ける必要があります*3 *4。
例外のテスト以外にも、テストの名前を設定したり、カテゴリを設定したりできます。
TestCase 属性や引数を一つだけ指定する TestCaseSource 属性よりもテストの追加が少々面倒になりますが、この柔軟性は魅力です。
さらに、可読性向上のため、TestCaseData の生成部分をリファクタリングしてみましょう。
public static IEnumerable<TestCaseData> TestCases { get { yield return FizzBuzzSay(1).Returns(new Result("1")); yield return FizzBuzzSay(3).Returns(new Fizz()); // 範囲外の値を渡すと例外が発生することを確認するテスト yield return FizzBuzzSay(0).Throws(typeof(ArgumentOutOfRangeException)); } } static TestCaseData FizzBuzzSay(int input) { return new TestCaseData(input); }
読みやすくもなりましたね。
テストコードのリファクタリングでよくあるケース
テストコードのリファクタリングでよくあるケースは、
- テストコード自体の重複の削除目的
- テストコードの可読性の向上目的
- テストに使用するオブジェクトの構築の重複削除目的
の 3 つだと思います。
上 2 つは上の方法でかなりの部分重複を減らすことができました。
では、3 つ目はどうでしょう?
3 つ目は、オブジェクト構築用のヘルパクラスやヘルパメソッド*5を作ってしまいましょう。
このヘルパクラスやヘルパメソッドに対してもテストコードを作ることからはじめればいいでしょう。
例えば、結果として XML 形式を出力する場合、それをテストコードに埋め込むとテストコードが非常に見づらくなります。
そこで、XML 文字列を組み立てるヘルパクラスを作って、以下のように書けるようにします。
// <?xml version="1.0" encoding="utf-8"?> // <hoge> // <piyo> // <foo f='foo'> // <bar>hogehoge</bar> // </foo> // </piyo> // <fizz /> // </hoge> Root("hoge").Add("piyo", "foo@f='foo'", "bar;hogehoge") .Add("fizz") .ToString();
ようは言語内 DSL ですね。
これにより、2 つ目の、可読性の向上も達成できました。
最初はテストコード用に作っていたけど、これプロダクトコードでも便利に使えるな、ってときは、プロダクトコードに含めてしまってもいいでしょう。