【C# Advent Calendar 2011 12/19】 Windows.FormsだってTDD!

このエントリはC# Advent Calendar 2011の19日目のエントリとなります。
18日目はkkamegawaさんの.NET 4.5でのTPL改良についてです
20日目はzoetroさんのReactive Extensionsでセンサプログラミングです


今月に入って、テストのないレガシーなWindows.Formsのやっつけコード(とバグ)を量産しているmasakitkです。
11月にはAdventCalendarのネタを考える時間があったのですが、忙しくなってどこかに吹き飛んでしまいました。

ということで、NUnitFormsをつかったWindows.FormsのGUIまわりのTDDについて書きます。
手順としては、

といった進め方になります。
UIがないと、まずテストを書いて、そこからプロダクトコード(クラス、メソッド)を生成して、といった流れになるかと思いますが、UIに関してはやはり最初にデザインだけお絵かきをしたほうが手間がないかと思います。
なお、事前にインストールが必要なものは、VisualStudio2010Express、Nunit2.5.10です。

  • まずはUIのデザインを行う。

プロジェクト名は何も考えずに、「TddFizzBuzz」とでもしておきます。
Windowsフォームアプリケーションのプロジェクトを作成してください。
で、Form1を削除して、FizzBuzzFormクラスを追加し、以下のような画面デザインを作成します。
DataGridViewには、列の設定などは行いません。

お題としては、XP祭り2011のTDDBCミニに参加した際のお題だった、FizzBuzzにしたいと思います。
アプリの仕様としては、テキストボックスに数値を入力し、ボタンをクリックするとグリッドに1からその数値までのFizzBuzz変換結果を表示する、といった仕様とします。
DataGridViewは、自分自身がFizzBuzz結果を取得できるよう、カスタムコントロールにします。
ユーザーコントロールを追加し、継承元のクラスをUserControlからDataGridViewに変更し、Designer.csの33行目(AutoScaleModeに代入している行)を削除する、というほんとにこんな手順でいいのか?という感じで作成します。
(ソースはこのへん)

名前は以下のようにしておきます


 TextBox:maxNumberTextBox
 Button:fizzBuzzButton
 DataGridView:fizzBuzzDataGridView


これでテストを書く準備が出来ました。

  • テストを書く(Nunit、NunitForms)

テストプロジェクトを作成しましょう。
クラスライブラリのプロジェクトで、名前は「TddFizzBuzzTest」とします。
先ほどのTddFizzBuzzプロジェクトへの参照と、System.Windows.Formsの参照、
およびNunit、NunitFormsの参照を追加します。
ソリューションの下にlibディレクトリを作成して、そこにライブラリを格納します。
NunitFormsはダウンロードサイトに行くとやたら古いですが、最新のソースからビルド
するか、ビルドしたものがgithubに上げたサンプルに含まれていますので、それを使ってみてください。


Nunit
http://www.nunit.org/?p=download


NunitForms
バイナリ(古い)
http://sourceforge.net/projects/nunitforms/files/nunitforms/
ソース(svn)
https://nunitforms.svn.sourceforge.net/svnroot/nunitforms


参照設定が終わったら、もともと出来てるクラスを削除して、「FizzBuzzFormTest」クラスを追加し、以下のコードを記述します。

using System.Windows.Forms;
using NUnit.Extensions.Forms;
using NUnit.Framework;
using TddFizzBuzz;

namespace TddFizzBuzzTest
{
    [TestFixture]
    public class TddFizzBuzzTest
    {
        [Test]
        public void Test01_10までのFizzBuzz結果の確認()
        {
            var target = new FizzBuzzForm();
            target.Show();

            new TextBoxTester("maxNumberTextBox", target).Enter("10");
            new ButtonTester("fizzBuzzButton", target).Click();
            var dataGrid = new Finder<DataGridView>("fizzBuzzDataGridView", target).Find();
            AssertForOneRow(dataGrid, 1, "1");
            AssertForOneRow(dataGrid, 2, "2");
            AssertForOneRow(dataGrid, 3, "Fizz");
            AssertForOneRow(dataGrid, 4, "4");
            AssertForOneRow(dataGrid, 5, "Buzz");
            AssertForOneRow(dataGrid, 6, "Fizz");
            AssertForOneRow(dataGrid, 7, "7");
            AssertForOneRow(dataGrid, 8, "8");
            AssertForOneRow(dataGrid, 9, "Fizz");
            AssertForOneRow(dataGrid, 10, "Buzz");
        }

        private static void AssertForOneRow(DataGridView dataGrid, int rowIndex, string expected)
        {
            Assert.That(dataGrid["FizzBuzzValue", ToZeroBased(rowIndex)].Value, Is.EqualTo(expected));
        }

        private static int ToZeroBased(int rowIndex)
        {
            return rowIndex - 1;
        }
    }
}

デバッグビルドをして、(構成マネージャの出し方は、ツール→オプション→左下の「すべての設定を表示」をチェック、その後、プロジェクトおよびソリューションの全般「ビルド構成の詳細を表示」にチェック)Nunitランナーでテストを実行しましょう。(64bitOSの方は、nunit-x86.exeから実行)
すると、

TddFizzBuzzTest.TddFizzBuzzTest.Test01_10までのFizzBuzz結果の確認:
System.ArgumentOutOfRangeException : インデックスが範囲を超えています。負でない値で、コレクションのサイズよりも小さくなければなりません。
パラメーター名: index

と表示されるかと思います。
グリッドに1行も表示されていないので当然です。
これから実装に入りましょう。

  • 実装を行う

FizzBuzzForm上のfizzBuzzButtonのClickイベントに、以下のコードを書きましょう。

        private void fizzBuzzButton_Click(object sender, EventArgs e)
        {
            fizzBuzzDataGridView.ShowFizzBuzzRows(int.Parse(maxNumberTextBox.Text));
        }

上記コードを記入したあと、ShowFizzBuzzRowsのメソッドスタブを作成し、FizzBuzzDataGridViewに以下のコードを追記します。
メソッドスタブの作成等の補完は、VisualStudioだと「Ctrl+.(ピリオド)」が便利ですね。(今はReSharperのAlt+Enterに慣れてしまっていますが)

        internal void ShowFizzBuzzRows(int maxNumber)
        {
            DataSource = FizzBuzzService.GetInstance().GetFizzBuzzList(maxNumber); 
        }

そしてFizzBuzzServiceクラスは、こんな感じにします。

using System.Collections.Generic;
using System.Linq;

namespace TddFizzBuzz
{
    class FizzBuzzService
    {
        internal static FizzBuzzService GetInstance()
        {
            return new FizzBuzzService();
        }

        internal List<FizzBuzz> GetFizzBuzzList(int maxNumber)
        {
            return (from i in Enumerable.Range(1, maxNumber)
                    select new FizzBuzz(i)).ToList();
        }

        public class FizzBuzz
        {
            int _number;
            internal FizzBuzz(int number)
            {
                _number = number;
            }

            public string FizzBuzzValue
            {
                get
                {
                    return _number % 15 == 0 ? "FizzBuzz"
                        : _number % 5 == 0 ? "Buzz"
                        : _number % 3 == 0 ? "Fizz"
                        : _number.ToString();
                }
            }
        }
    }
}


この状態で、テストを実行すると緑になっているはずです。


実際には、DataGridViewで無名クラスのListを返すようにしてテストを緑にしてしまったのですが、そのあとリファクタしてサービスクラスを切り出しました。
ここまででRed-Green-Refactoringの1サイクルです。
それほど手間なく、UIのテストがかける感じがしませんか?
メッセージボックスや、モーダルウィンドウも割りとスマートにテストできます。
http://d.hatena.ne.jp/masakitk/20110516/1305548197 参照


ここまでのソースは(なれないgitを使って)githubにあげましたので良かったら見てください。
https://github.com/masakitk/TDDSample


画面周りは仕様がよく変わるし、UIテストはコストに見合わないとよく言われますが、Windows.Formsの業務アプリなんかだとそこまで頻繁に変わるものでもないかと思います。
WebだとSeleniumとかを回帰テストとしてメリットを見出して使っているところもそこそこあるのではないでしょうか。Windows.Formsだって同様のことは可能ですし、同様のメリットはあると思います。
UIAutomationを使えば、操作を記録して自動回帰テストとして利用できるようですが、エンジニア的には先にテストを書きたいですよね。
ただ、TDDの肝はテストとコードを素早いフィードバックサイクルで回していくというところかと思いますが、GUIが絡むとサイクルは遅くなる印象があります。
が、機能テストとして先にテストを書いて、実装を書く、という進め方はありなんじゃないかなーと思っています。


また、今回の例でいえば、FizzBuzzServiceをモックにしてしまえば、プレゼンテーションレイヤの単体テストとすることも出来るかと思いますが、やはりUIを絡めたテストはエンドtoエンドテストとしての活用が有効ではないかと感じています。


C# Advent Calendar 2011、次は、@zoetro さんです