例題:FizzBuzzアプリのビューモデル
前回、FizzBuzzのロジックを紹介しました(テストコードと製品コード)。今回は、そのUIを題材にしてみましょう
画面そのものをテストファーストで作るのは、(不可能ではないものの)そのコストが見合わないので行いません。かわりに、UIを画面(View)と画面のモデル(ViewModel)に分割し、ViewModelだけをテストファーストで作ります。
なお、ここでは「ViewModelとはXAMLで記述された画面にバインドされるもの」という程度の定義にしておきます。本来のMVVMパターンにおけるViewModelの意味は、MSDN blogs「Introduction to Model/View/ViewModel pattern for building WPF apps」(英語、2005/10/8)をご覧ください。
今回のUIは、次の図のようにしましょう。
画面には、数字を入力するTextBoxコントロールと、その数値から求められたFizzBuzzの結果を表示するTextBlockコントロールがあります。また、TextBoxコントロールの左右には、数値を1ずつ増減するためのButtonコントロールを配置します。
すると、この画面にバインドするViewModelには、図の右に示したようなプロパティ/イベント/コマンドが必要になります。なお、2つのコマンドは、それぞれ内部にCanExecuteプロパティを持っていますが、これはそのコマンドが実行できる状態か否かを表します。コマンドをバインドされたButtonコントロールは、CanExecuteプロパティを見て、自身の表示(ボタンを押せるかどうか)を切り替えます。また、2つのコマンドが発行するCanExecuteChangedイベントは、矢印を描いてありませんが、CanExecuteプロパティが変化したことをButtonコントロールに通知します。
このViewModelの簡単な説明と相互の影響関係を、簡単に示しておきます。
| パーツ | 説明 | 影響関係 |
|---|---|---|
|
Numberプロパティ (string) |
FizzBuzzへの入力となる数字。 画面に双方向バインド(読み書き可) |
独立した状態 |
|
Wordプロパティ (string) |
FizzBuzzの出力。 画面に一方向バインド(読み取りのみ) |
Numberに従属する状態 |
| PropertyChangedイベント | NumberとWordが変化したことを通知する | Numberの変化に従属するイベント |
| GoNextCommand/GoPreviousCommand | このコマンドを実行すると、 Numberが1ずつ増減する | Numberを変化させる |
| GoNextCommand/GoPreviousCommandのCanExecuteプロパティ | そのコマンドを実行可能かどうか | Numberに従属する状態 |
| GoNextCommand/GoPreviousCommandのCanExecuteChangedイベント | CanExecuteプロパティが変化したことを通知する | CanExecuteの変化に(従って、Numberの変化に)従属するイベント |
上記のスペックを、以降でテストファーストして作っていきます。「状態を持つクラスをテストファーストする戦略」で述べたように、独立した状態から手掛けて、だんだんと複雑なパーツに移っていきます。
Numberプロパティ(独立した状態)
最初は、もっとも作りやすいNumberプロパティから始めます。Numberプロパティは、他のプロパティに従属していません。上の表にあるように、スペックとしてNumberプロパティは読み書き可能ですから、前述の戦略1.が利用できて、シンプルにテストコードを書けます。プレーンなクラスに、単純なpublicプロパティを実装するだけですから、たやすく作れます(2つのテストコードをまとめて示しますが、実際には1つずつ作っていきます)。
using Microsoft.VisualStudio.TestPlatform.UnitTestFramework;
using CsTdd08App.UI; // ←ViewModelを配置する名前空間
namespace CsTdd08Tests.Shared.UI
{
[TestClass]
public class FizzBuzzViewModelTest
{
[TestMethod]
public void NumberプロパティTest01_既定値は1()
{
// FizzBuzzは1から始まるので、規定値は1とする
FizzBuzzViewModel vm = new FizzBuzzViewModel();
Assert.AreEqual<string>("1", vm.Number);
// → クラス定義を書く
// → Numberプロパティのgetterだけを仮実装(return "1";)
}
[TestMethod]
public void NumberプロパティTest02_書き込み可能()
{
FizzBuzzViewModel vm = new FizzBuzzViewModel();
vm.Number = "X"; //文字列なら何でも受け付けるものとする
Assert.AreEqual<string>("X", vm.Number);
// → Numberプロパティのsetterを仮実装(中身は空)してコンパイル可能に
// → メンバー変数を設置し、初期化するコードを追加
// → Numberプロパティのgetter/setterを本実装
}
}
}
namespace CsTdd08App.UI
{
public class FizzBuzzViewModel
{
private string _number = "1";
public string Number
{
get { return _number; }
set { _number = value; }
}
}
}
Wordプロパティ(従属する状態)
Wordプロパティは、読み取り専用です。Numberプロパティの変化に応じてWordプロパティの値が決まる(Numberプロパティに従属している)ので、前述の戦略2.を使い、Numberプロパティと一緒にテストコードを書きます。
Wordプロパティのスペックは次の表のように書けます。FizzBuzzの答が得られない条件のときは、文字列"???"を返すことにします。
| 作るもの |
Wordプロパティ |
||||||
|---|---|---|---|---|---|---|---|
| 置き場所 |
FizzBuzzViewModelクラス |
||||||
| 返値の型 |
string |
||||||
| 条件と得られる値 |
|
テストコードと、それによって実装した製品コードは、次のようになります。この製品コードは、リファクタリング後のものです。
[TestMethod]
public void WordプロパティTest01_既定値は1()
{
FizzBuzzViewModel vm = new FizzBuzzViewModel();
Assert.AreEqual<string>("1", vm.Word);
// → Wordプロパティのgetterを仮実装(return "1";)
}
[TestMethod]
public void WordプロパティTest02_3のときFizz()
{
FizzBuzzViewModel vm = new FizzBuzzViewModel();
vm.Number = "3";
Assert.AreEqual<string>("Fizz", vm.Word);
// → Wordプロパティのgetterを本実装(FizzBuzzPlayerを呼び出す)
// → GREENになったらリファクタリング
// ・GetNumberメソッド、GetWordメソッドの切り出し
// ・メンバー変数_wordを導入する
// ・_wordを算出するコードをNumberのsetter内に移動
// ※移動すると例外が出る。リファクタリングを中止し、先に次のテストへ進む
}
[TestMethod]
public void WordプロパティTest03_0のときは不明()
{
FizzBuzzViewModel vm = new FizzBuzzViewModel();
vm.Number = "0";
Assert.AreEqual<string>("???", vm.Word);
// → GetWordメソッドを改修(1以上の数字ではないときの分岐を追加)
}
public class FizzBuzzViewModel
{
private FizzBuzzPlayer _player = new FizzBuzzPlayer();
// FizzBuzzPlayerクラスについては前回を参照
private string _number = "1";
public string Number
{
get { return _number; }
set
{
_number = value;
_word = GetWord();
}
}
private string _word = "1";
public string Word
{
get { return _word; }
}
private int GetNumber()
{
int n;
int.TryParse(_number, out n);
return n;
}
private string GetWord()
{
int n = this.GetNumber();
if (n > 0)
return _player.Say(n);
else
return "???";
}
}
これでプロパティは実装できましたが、まだ画面にはバインドできません。プロパティの変化が、画面に伝わらないからです。そのためには、FizzBuzzViewModelクラスにINotifyPropertyChangedインターフェースを実装する必要があります。それが、次のPropertyChangedイベントの実装です。

