June 2009
Volume 24 Number 06
テスト駆動型設計 - モックとテストを使用して役割に基づいたオブジェクトを設計する
Isaiah Perumalla | June 2009
コードは MSDN コード ギャラリーからダウンロードできます。
オンラインでのコードの参照
この記事では、次の内容について説明します。
|
この記事では、次のテクノロジを使用しています。 テスト駆動開発、NMock フレームワーク |
目次
実装ではなく対話が中心
バーコードを読み取る
役割を見極める
販売を完了する
相互のやり取りを抽象化する
領収金額を計算する
商品の説明を取得する
リファクタリング
まとめ
テスト駆動開発 (TDD: Test Driven Development) の世界には、モック オブジェクトを使用した技法が存在します。オブジェクトの内部構造ではなく、むしろ、オブジェクトどうしの相互関係に注目することによって、特定のシステム内でオブジェクトが果たす役割を見極めるために、モック オブジェクトが役立ちます。この技法を用いることにより、より良いオブジェクト指向設計が可能となります。モック オブジェクトは単にシステムを外部の依存関係から切り離す目的で使用されるのが一般的ですが、設計の支援手段としてのモック オブジェクトには、それよりも、はるかに興味深い価値があります。
TDD の最大の利点は、オブジェクトのインターフェイスを設計する際、具体的な実装ではなく、自然にその "用途" へと関心が向かうために、コードの総合的な設計の質が向上することです。あらゆる環境が既に整っているかのような状況で、オブジェクトのテスト コードを記述できるという点で、モック オブジェクトは、オブジェクト指向システムの TDD プロセスを補完する存在と言えます。"補完" は、オブジェクトのコラボレータをモックに置き換えることによって可能になります。そのため、具体的な実装が一切存在しない段階でも、オブジェクトのコラボレータのインターフェイスを、それらが果たす "役割" の観点から設計することができます。それが見極めのプロセスにつながり、オブジェクトのコラボレータのインターフェイスが、目の前の要件に基づいて (ニーズ主導で) 具現化されていくことになります。
このように、モック オブジェクトを使った、"テスト ファースト (最初にテストありき)" の開発プロセスでは、オブジェクトのインターフェイスをその用途に基づいて設計できるだけでなく、オブジェクトがそのコラボレータに対して必要とするインターフェイスを見極めて、設計することが可能です。
この記事では、クラス階層にオブジェクトを分類するという観点ではなく、TDD とモック オブジェクトを使用して、役割と責任の観点からオブジェクト指向コードを設計するにはどうすればよいかについて説明します。
実装ではなく対話が中心
オブジェクト指向プログラミングの基本原則の 1 つは、状態に基づいて動作するすべてのロジックを "状態を持ったオブジェクト" として局所限定的に表現することで、オブジェクトの内部的な構造とその状態遷移を隠すことです。大切なことは、何かのイベントによってきっかけが与えられたとき、その環境内でオブジェクトが相互にどのようにやり取りするかです。そこに重点を置く必要があります。実際にやってみると、決して簡単には実現できません。このようにして設計されたオブジェクトは、内部的な構造はもちろん、その状態を外部から見ることはできません。状態が露出されないため、内部的な構造や状態を照会し、その状態をアサートの条件にすることによって、オブジェクトの振る舞いをテストすることは不可能です。外部から見えるのは、オブジェクトが周囲の環境と対話している "ようす" だけです。しかし、これらの対話を追跡することによって、オブジェクトの振る舞いを確認することができます。
モック オブジェクトを使用すると、特定の状況において、オブジェクトから適切なメッセージがコラボレータに送信されている、ということをアサート (表明) することにより、オブジェクトがそのコラボレータとどのように対話しているかを見極めることができます。そのため、オブジェクトをどのように分類し、どのような構造にするか、という視点ではなく、むしろ、オブジェクトが互いにどのようにやり取りするかが設計の中心になります。システムの設計スタイルも変わります。つまり、個々のオブジェクトが、その周囲のオブジェクトの構造や状態について、ほとんど何も知らない "Tell, don't ask (求めるな、命じよ)" 型の設計スタイルです。オブジェクトの組み合わせを変えることで、システムの振る舞いを変えることができるため、この方が、システムの柔軟性がはるかに高くなります。
以降、この技法をわかりやすく説明するために、モック オブジェクトを使用した TDD の簡単な例を紹介します。
バーコードを読み取る
大規模なスーパーマーケット チェーン向けに販売時点情報管理 (POS) システムを構築することになったとします。商品カタログ システムは本社に置かれ、RESTful なサービスによってアクセスします。この機能の最も重要な点は、システムがバーコード情報 (手動で入力またはバーコード スキャナから読み取られる) を使用して商品を識別し、価格を取得して、その販売の合計領収金額を計算する点です。
このシステムがトリガされるのは、レジ係がタッチ スクリーンまたはバーコード スキャナを使用してコマンドを入力したときです。入力デバイスからのコマンドは、次の形式の文字列として送信されます。
- Command: NewSale;
- Command: EndSale;
- Input: Barcode=100008888559, Quantity =1;
すべてのバーコードは、UPC のコード体系に従います。つまり、バーコードの上位 6 桁がメーカー コードを表し、次の 5 桁が商品コードを表します。この商品情報システムは、メーカー コードと商品コードを基に、特定の品目に対する商品説明を取得できることを前提としています。
最初に入力デバイス (キーボードやスキャナ) からコマンドを受信し、その意味を解釈する必要があります。販売の完了時には、本社の商品カタログを使用して、商品の説明と価格を取得し、領収金額を計算して領収書を印刷します。
第 1 段階は、入力デバイスから送信された生のメッセージを、チェックアウト (精算) イベントを表す何らかの形式へとデコードします。まずは、最も単純なコマンドからです。新規販売コマンドを開始すると、システム内で新規販売イベントがトリガされます。入力デバイスから送られた生のメッセージをデコードし、アプリケーション ドメインの観点で特定のイベントを表す "何か" に変換するためのオブジェクトが必要です。最初のテストは、次のとおりです。
[TestFixture]
public class CommandParserTests {
[Test]
public void NotifiesListenerOfNewSaleEvent() {
var commandParser = new CommandParser();
var newSaleCommand= "Command:NewSale";
commandParser.Parse(newSaleCommand);
}
}
この時点で、CommandParser は存在すらしない点に注意してください。テストを使用しながら、このオブジェクトが備えるインターフェイスの姿を明らかにしていきます。
このオブジェクトが入力デバイスから送信されたコマンドを正しく解釈できたかどうかを知るには、どうすればよいでしょうか。"Tell, don't ask" の原則に従えば、CommandParser オブジェクトは、その相手のオブジェクト、つまり、自分と関係のあるオブジェクトに対し、新規販売が開始された旨を伝える必要があります。この時点では、その相手がだれなのか (何なのか) はわかりません。それは、これから見極めていくことになります。
役割を見極める
これまでのところ、CommandParser のコラボレータについてわかっていることは 1 つだけです。つまり、コラボレータは、システム内で販売に関連したイベントが検出されたタイミングを知る必要がある、という点です。この機能の名称として、SaleEventListener という名前を使用することにしました。それが、システムにおける役割を表している点に注目してください。ここでいう "役割" とは、ソフトウェア システムにおける肩書きのようなものと考えることができます。その役割の責任を果たすことができるものであれば、どのようなオブジェクトでも、そこに当てはめることができます。役割を見極めるには、システムの中で特定のアクティビティが実行されるときの、オブジェクトとそのコラボレータ間の目に見える対話を詳しく吟味する必要があります。その際、オブジェクトが持つさまざまな側面のうち、そのアクティビティを表現するうえで必要な側面にのみ注目するようにします。
C# では、インターフェイスによって役割を指定することができます。この例では、CommandParser の環境に、SaleEventListener の役割を果たすことのできるオブジェクトが必要です。ただし、インターフェイスだけでは、一連のオブジェクトが、どのようなやり取りを通じてタスクを遂行するかを十分に表現できません。モック オブジェクトを使用すると、図 1 に示したようなテストでこれを表現できます。
図 1 SaleEventListener という役割を定義する
[TestFixture]
public class CommandParserTests {
private Mockery mockery;
[SetUp]
public void BeforeTest() {
mockery = new Mockery();
}
[Test]
public void NotifiesListenerOfNewSaleEvent() {
var saleEventListener = mockery.NewMock<ISaleEventListener>();
var commandParser = new CommandParser();
var newSaleCommand = "Command:NewSale";
commandParser.Parse(newSaleCommand);
}
}
ここでは、ISaleEventListener というインターフェイスを使用して、SaleEventListener の役割を明示的に定義します。C# には、デリゲートを使ったイベント処理手法があらかじめ用意されています。しかし、そうした組み込みの機能を使用せずに、リスナ インターフェイスを使用するのはなぜでしょうか。
私があえてイベントやデリゲートを使わず、リスナ インターフェイスを使用したのには、さまざまな理由があります。まず、リスナ インターフェイスを使用すれば、コマンド パーサーがコラボレートする相手の役割を明示的に識別することができます。CommandParser を特定のクラスに関連付けるのではなく、リスナ インターフェイスを使用することによって、CommandParser と、そのコラボレータの役割との関係を明示的に表現しようとしているのです。不特定のコードを CommandParser にフックするだけならば、イベントやデリゲートで事足りるかもしれません。しかし、それでは、同じドメイン内に存在するオブジェクト間の潜在的な関係を表現することは不可能です。このような場合、リスナ インターフェイスを使用すれば、オブジェクト間のコミュニケーション パターンを使用して、ドメイン モデルを明確に表現することができます。
この例では、CommandParser がコマンドを解析し、さまざまな種類のイベントをアプリケーション ドメインの観点で送出します。これらのイベントは必ず、同時にフックされます。この場合、個々のデリゲートをそれぞれ対応するイベントにアタッチするよりは、一連のイベントを処理することのできるリスナ インターフェイスのインスタンスに参照を渡した方がはるかに合理的です。さらに、このインターフェイスのインスタンスを作成して、CommandParser が対話できるようにする必要があります。ここでは、NMock フレームワークを使用して、このインターフェイスのインスタンスを動的に作成することにします。
CommandParser オブジェクトが、入力デバイスからのコマンドの意味を解釈できたとしましょう。そのときに、ISaleEventListener の役割を果たすオブジェクトが、CommandParser オブジェクトに対して期待する情報は、どのようなものでしょうか。今度は、それを指定する必要があります。ここでは、その期待される情報を ISaleEventListener のモック実装に記述することによって指定します。
[Test]
public void NotifiesListenerOfNewSaleEvent() {
var saleEventListener = mockery.NewMock<ISaleEventListener>();
var commandParser = new CommandParser();
var newSaleCommand = "Command:NewSale";
Expect.Once.On(saleEventListener).Method("NewSaleInitiated");
commandParser.Parse(newSaleCommand);
mockery.VerifyAllExpectationsHaveBeenMet();
}
このように、"期待される事柄" を記述していくことにより、CommandParser がそのコラボレータに対して求めるインターフェイスの姿が明らかになっていきます。モック オブジェクトを使用すると、オブジェクトのコラボレータが一切実装されていなくても、そのオブジェクトのコラボレータにどのようなインターフェイスが必要かを見極め、設計することができます。その結果、コラボレータの実装に関してあれこれ悩むことなく、CommandParser に専念することができます。
このテストをコンパイルするためには、次のような CommandParser クラスおよび ISaleEventListener インターフェイスを作成する必要があります。
public class CommandParser {
public void Parse(string messageFromDevice) {
}
}
public interface ISaleEventListener {
void NewSaleInitiated();
}
このテストをコンパイルし、実行すると、次のようなエラーが発生します。
TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfNewSaleEvent'
failed: NMock2.Internal.ExpectationException : not all expected invocations were performed
Expected:
1 time: saleEventListener.NewSaleInitiated(any arguments) [called 0 times]
at NMock2.Mockery.FailUnmetExpectations()
at NMock2.Mockery.VerifyAllExpectationsHaveBeenMet()
NMock フレームワークからの報告から、次のことがわかります。ISaleEventListener のモック実装は、その NewSaleInitiated メソッドが 1 回呼び出されるものと期待していたが、一度も呼び出されなかった、ということです。このテストに合格するためには、saleEventListener のモック インスタンスを CommandParser オブジェクトに対し、依存情報として渡す必要があります。
[Test]
public void NotifiesListenerOfNewSaleEvent() {
var saleEventListener = mockery.NewMock<ISaleEventListener>();
var commandParser = new CommandParser(saleEventListener);
var newSaleCommand = "Command:NewSale";
Expect.Once.On(saleEventListener).Method("NewSaleInitiated");
ommandParser.Parse(newSaleCommand);
mockery.VerifyAllExpectationsHaveBeenMet();
}
今度は、CommandParser がその環境に対して依存する情報が明示的に指定され、saleEventListener へと送られるメッセージ (メソッド呼び出し) も明確化されました。
このテストを通過する最も単純な実装形態は、次のようになります。
public class CommandParser {
private readonly ISaleEventListener saleEventListener;
public CommandParser(ISaleEventListener saleEventListener) {
this.saleEventListener = saleEventListener;
}
public void Parse(string messageFromDevice) {
saleEventListener.NewSaleInitiated();
}
}
販売を完了する
先ほどのテストは無事、通過したので、次のテストに進むことにしましょう。次のテストは、CommandParser が販売完了コマンドをデコードし、システムに通知できれば成功、という単純なシナリオです。
[Test]
public void NotifiesListenerOfSaleCompletedEvent() {
var saleEventListener = mockery.NewMock<ISaleEventListener>();
var commandParser = new CommandParser(saleEventListener);
var endSaleCommand = "Command:EndSale";
Expect.Once.On(saleEventListener).Method("SaleCompleted");
commandParser.Parse(endSaleCommand);
mockery.VerifyAllExpectationsHaveBeenMet();
}
このテストにより、ISaleEventListener インターフェイスに実装すべき別のメソッドの存在が浮上してきました。
public interface ISaleEventListener {
void NewSaleInitiated();
void SaleCompleted();
}
当然、テストを通過することはできません。NMock からは、次のようなエラー メッセージが表示されます。
TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfSaleCompletedEvent'
failed: NMock2.Internal.ExpectationException : unexpected invocation of saleEventListener.NewSaleInitiated()
Expected:
1 time: saleEventListener.SaleCompleted(any arguments) [called 0 times]
生のコマンドを解釈し、saleEventListener オブジェクトのインスタンスの適切なメソッドを呼び出す必要があります。図 2 に単純な実装例を示しました。これならば、テストに合格するはずです。
図 2 saleEventListener の実装
public class CommandParser {
private const string END_SALE_COMMAND = "EndSale";
private readonly ISaleEventListener saleEventListener;
public CommandParser(ISaleEventListener saleEventListener) {
this.saleEventListener = saleEventListener;
}
public void Parse(string messageFromDevice) {
var commandName = messageFromDevice.Split(':')[1].Trim();
if (END_SALE_COMMAND.Equals(commandName))
saleEventListener.SaleCompleted();
else
saleEventListener.NewSaleInitiated();
}
}
次のテストに進む前に、一度テスト コードをまとめます (図 3 を参照)。
図 3 テストのアップデート
[TestFixture]
public class CommandParserTests {
private Mockery mockery;
private CommandParser commandParser;
private ISaleEventListener saleEventListener;
[SetUp]
public void BeforeTest() {
mockery = new Mockery();
saleEventListener = mockery.NewMock<ISaleEventListener>();
commandParser = new CommandParser(saleEventListener);
mockery = new Mockery();
}
[TearDown]
public void AfterTest() {
mockery.VerifyAllExpectationsHaveBeenMet();
}
[Test]
public void NotifiesListenerOfNewSaleEvent() {
var newSaleCommand = "Command:NewSale";
Expect.Once.On(saleEventListener).Method("NewSaleInitiated");
commandParser.Parse(newSaleCommand);
}
[Test]
public void NotifiesListenerOfSaleCompletedEvent() {
var endSaleCommand = "Command:EndSale";
Expect.Once.On(saleEventListener).Method("SaleCompleted");
commandParser.Parse(endSaleCommand);
}
}
今度は、CommandParser で入力コマンドとバーコード情報を処理できることを確認する必要があります。アプリケーションは、生のメッセージを次の形式で受け取ります。
Input:Barcode=100008888559, Quantity =1
SaleEventListener の役割を果たすオブジェクトに対し、何らかの品目がバーコードおよび数量と共に入力されたことを伝える必要があります。
[Test]
public void NotifiesListenerOfItemAndQuantityEntered() {
var message = "Input: Barcode=100008888559, Quantity =1";
Expect.Once.On(saleEventListener).Method("ItemEntered")
.With("100008888559", 1);
commandParser.Parse(message);
}
このテストにより、ISaleEventListener インターフェイスに追加すべきメソッドの存在が、また 1 つ浮上してきました。
public interface ISaleEventListener {
void NewSaleInitiated();
void SaleCompleted();
void ItemEntered(string barcode, int quantity);
}
テストを実行すると、次のエラーが生成されます。
TestCase 'Domain.Tests.CommandParserTests.NotifiesListenerOfItemAndQuantityEntered'
failed: NMock2.Internal.ExpectationException : unexpected invocation of saleEventListener.NewSaleInitiated()
Expected:
1 time: saleEventListener.ItemEntered(equal to "100008888559", equal to <1>) [called 0 times]
エラー メッセージを見ると、saleEventListener で間違ったメソッドが呼び出されているようです。これは当然です。バーコードと数量を含んだ入力メッセージを処理するためのロジックが、まだ CommandParser に実装されていないためです。図 4 は更新後の CommandParser を示しています。
図 4 入力メッセージの処理
public class CommandParser {
private const string END_SALE_COMMAND = "EndSale";
private readonly ISaleEventListener saleEventListener;
private const string INPUT = "Input";
private const string START_SALE_COMMAND = "NewSale";
public CommandParser(ISaleEventListener saleEventListener) {
this.saleEventListener = saleEventListener;
}
public void Parse(string messageFromDevice) {
var command = messageFromDevice.Split(':');
var commandType = command[0].Trim();
var commandBody = command[1].Trim();
if(INPUT.Equals(commandType)) {
ProcessInputCommand(commandBody);
}
else {
ProcessCommand(commandBody);
}
}
private void ProcessCommand(string commandBody) {
if (END_SALE_COMMAND.Equals(commandBody))
saleEventListener.SaleCompleted();
else if (START_SALE_COMMAND.Equals(commandBody))
saleEventListener.NewSaleInitiated();
}
private void ProcessInputCommand(string commandBody) {
var arguments = new Dictionary<string, string>();
var commandArgs = commandBody.Split(',');
foreach(var argument in commandArgs) {
var argNameValues = argument.Split('=');
arguments.Add(argNameValues[0].Trim(),
argNameValues[1].Trim());
}
saleEventListener.ItemEntered(arguments["Barcode"],
int.Parse(arguments["Quantity"]));
}
}
相互のやり取りを抽象化する
次のテストに進む前に、CommandParser と saleEventListener 間の対話をリファクタリングして、整理したいと思います。オブジェクトとそのコラボレータ間の対話をアプリケーション ドメインの観点で指定する必要があります。ItemEntered メッセージは、2 つの引数を受け取ります。バーコードを表す文字列と、数量を表す整数です。この 2 つの引数ですが、本当に、アプリケーションのドメインの観点で表現されていると言えるでしょうか。
経験上、オブジェクトのコラボレータ間で受け渡ししているデータがプリミティブ データ型の場合、適切な抽象化レベルでやり取りできていない可能性があります。ドメイン内で何か見落としている概念がないか確認しましょう。その答えがプリミティブ データ型に隠されている可能性があります。
この場合、バーコードはメーカー コードと品目コードに分解でき、品目コードは品目の ID を表します。コードには、この品目 ID の概念を反映させることが可能です。これはバーコードから構築できるイミュータブルな (不変の) 型とします。ItemIdentifier には、バーコードをメーカー コードと品目コードに分解するという "責任" を与えることができます。同様に、数量は測定値を表すため、値オブジェクトとします。たとえば、品目の数量は、重量で測定されることも考えられます。
今のところ、バーコードを分解することも、数量に対するさまざまな種類の測定値を処理する必要もありません。単に、これらの値オブジェクトを反映し、オブジェクト間の対話が確実にドメインの観点でなされるように配慮するだけです。コードをリファクタリングし、品目 ID と数量の概念をテストに反映させました。
[Test]
public void NotifiesListenerOfItemAndQuantityEntered() {
var message = "Input: Barcode=100008888559, Quantity=1";
var expectedItemid = new ItemId("100008888559");
var expectedQuantity = new Quantity(1);
Expect.Once.On(saleEventListener).Method("ItemEntered").With(
expectedItemid, expectedQuantity);
commandParser.Parse(message);
}
ItemId も Quantity もまだ存在しません。テストを通過するためには、これらの新しいクラスを作成し、その新しい概念を反映するように、コードを修正する必要があります。ここでは、これらの型を値オブジェクトとして実装します。これらのオブジェクトの ID は、それぞれが保持する値に基づきます (図 5 を参照)。
図 5 ItemID と Quantity
public interface ISaleEventListener {
void SaleCompleted();
void NewSaleInitiated();
void ItemEntered(ItemId itemId, Quantity quantity);
}
public class ItemId {
private readonly string barcode;
public ItemId(string barcode) {
this.barcode = barcode;
}
public override bool Equals(object obj) {
var other = obj as ItemId;
if(other == null) return false;
return this.barcode == other.barcode;
}
public override int GetHashCode() {
return barcode.GetHashCode();
}
public override string ToString() {
return barcode;
}
}
public class Quantity {
private readonly int value;
public Quantity(int qty) {
this.value = qty;
}
public override string ToString() {
return value.ToString();
}
public override bool Equals(object obj) {
var otherQty = obj as Quantity;
if(otherQty == null) return false;
return value == otherQty.value;
}
public override int GetHashCode() {
return value.GetHashCode();
}
}
モック オブジェクトを使用した対話ベースのテストでは、オブジェクトとそのコラボレータ間の対話を手軽に実現できます。テストを使って、オブジェクトどうしのやり取りに伴う決まり事をドメインの観点で十分に抽象化し、明示的に記述できるためです。モックを使用することになるので、コラボレータの実装が存在している必要もありません。オブジェクト間のコラボレーションがアプリケーション ドメインの観点で正式に設計されるまでは、代用のコラボレーション パターンを試すことができます。オブジェクトとそのコラボレータ間の対話を吟味し、念入りにたどることによって、自分が見過ごしていたドメインの概念を浮き彫りにすることが可能です。
領収金額を計算する
入力デバイスからのコマンドを POS イベントとして分解するオブジェクトが完成したので、今度は、これらのイベントを捕捉し、処理するオブジェクトが必要です。具体的な要件は領収書の印刷です。そこで、領収金額を計算するオブジェクトを作成する必要があります。これらの条件 (責任) を満たし、SaleEventListener の役割を果たすことのできるオブジェクトを探さなければなりません。まず思い浮かぶのは、レジ (Register) の概念です。これならば、SaleEventListener の役割にぴったりではないでしょうか。Register という新しいクラスを作成することにしましょう。このクラスは、販売イベントに反応するため、ISaleEventListener を実装します。
public class Register : ISaleEventListener {
public void SaleCompleted() { }
public void NewSaleInitiated() { }
public void ItemEntered(ItemId itemId, Quantity quantity) { }
}
Register に課せられている主要な責任の 1 つは、領収金額を計算してプリンタに送信することです。早速、このオブジェクトのメソッドを呼び出すためのイベントを作成することにします。最初は、単純なシナリオから始めましょう。品目ゼロで販売の領収金額を計算した場合、当然、合計は 0 でなければなりません。そこで、次のような疑問が浮かびます。レジが品目の合計金額を正しく計算できた場合、どこに知らせるかです。真っ先に思い浮かぶのは、レシート プリンタです。テストを作成して、これをコードの中で表現してみます。
[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
var receiptPrinter = mockery.NewMock<IReceiptPrinter>();
var register = new Register();
register.NewSaleInitiated();
Expect.Once.On(receiptPrinter).Method("PrintTotalDue").With(0.00);
register.SaleCompleted();
}
このテストを見ていると、なんとなく、Register オブジェクトがレシート プリンタに対し、受取総額 (Total Due) を印刷するように命令しているようです。
先に進む前に、少し Register オブジェクトとレシート プリンタ間のやり取りに伴う決まり事を吟味してみましょう。PrintTotalDue というメソッド名は、Register オブジェクトにとってふさわしいと言えるでしょうか。その対話に注目すると、領収書の印刷に関する事柄は、どう見ても Register の責任ではありません。Register オブジェクトの関心事は、領収金額を計算し、計算結果を他のオブジェクト (領収金額を受け取るオブジェクト) に送信することです。そこで、このメソッドには、その振る舞いにふさわしい名前を付けることにしました。ReceiveTotalDue です。Register オブジェクトにとっては、こちらの方がはるかに適切です。こうしている間に、Register のコラボレータの役割が見えてきました。つまり、Register が対話する相手は ReceiptPrinter ではなく ReceiptReceiver です。役割にふさわしい名前を探すことは、設計活動の重要な工程です。密に関連した責任を集約したオブジェクトを設計することにつながるためです。役割の新しい名前を反映するため、テストを書き換えることにしましょう。
[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
var register = new Register();
register.NewSaleInitiated();
Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue").With(0.00);
register.SaleCompleted();
}
これをコンパイルするため、ReceiptReceiver の役割を表す IReceiptReceiver インターフェイスを作成します。
public interface IReceiptReceiver {
void ReceiveTotalDue(decimal amount);
}
案の定、テストを実行すると、エラーが発生します。モック フレームワークから、ReceiveTotalDue メソッドが一度も呼び出されなかったという内容のメッセージが表示されます。このテストを通過するためには、IReceiptReceiver のモック実装を Register オブジェクトに渡す必要があります。テスト コードを書き換えて、この依存関係を反映することにします。
[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
var register = new Register(receiptReceiver);
register.NewSaleInitiated();
Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue").With(0.00m);
register.SaleCompleted();
}
ここに単純な実装例を示しました。これならば、テストに合格するはずです。
public class Register : ISaleEventListener {
private readonly IReceiptReceiver receiptReceiver;
public Register(IReceiptReceiver receiver) {
this.receiptReceiver = receiver;
}
public void SaleCompleted() {
receiptReceiver.ReceiveTotalDue(0.00m);
}
public void NewSaleInitiated() { }
public void ItemEntered(ItemId itemId, Quantity quantity) { }
}
合計支払金額を表すために使用されているプリミティブ型の decimal は単なるスカラ値なので、このドメインでは意味を持ちません。この値が本来表しているのは金銭的価値です。そこで、金額を表すイミュータブルな値オブジェクトを作成することにします。現時点では、複数通貨への対応や金額の丸め処理の必要性はありません。単に、decimal 値をラップする Money クラスを作成するだけです。必要に応じて、このクラスで通貨を追加したり、丸め処理の規則を追加することができます。当面は、現在の作業に専念し、この点をコードに反映させることにしましょう。実装したものを図 6 に示します。
図 6 Money の使用
public interface IReceiptReceiver {
void ReceiveTotalDue(Money amount);
}
public class Register : ISaleEventListener {
private readonly IReceiptReceiver receiptReceiver;
public Register(IReceiptReceiver receiver) {
this.receiptReceiver = receiver;
}
public void SaleCompleted() {
receiptReceiver.ReceiveTotalDue(new Money(0.00m));
}
public void NewSaleInitiated() { }
public void ItemEntered(ItemId itemId, Quantity quantity) { }
}
[Test]
public void ReceiptTotalForASaleWithNoItemsShouldBeZero() {
var receiptReceiver = mockery.NewMock<IReceiptReceiver>();
var register = new Register(receiptReceiver);
register.NewSaleInitiated();
var totalDue = new Money(0m);
Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue")
.With(totalDue);
register.SaleCompleted();
}
次のテストでは、Register オブジェクトに他の動作をいくつか追加しながら肉付けしていきます。Register が領収金額を計算するのは、あくまで新規販売が開始された場合だけです。この振る舞いを指定するテストを作成します。
[SetUp]
public void BeforeTest() {
mockery = new Mockery();
receiptReceiver = mockery.NewMock<IReceiptReceiver>();
register = new Register(this.receiptReceiver);
}
[Test]
public void ShouldNotCalculateRecieptWhenThereIsNoSale() {
Expect.Never.On(receiptReceiver);
register.SaleCompleted();
}
このテストでは、receiptReceiver のメソッドは一切呼び出されてはならない、という条件が明示的に指定されています。テストを実行すると、やはり、次のエラーが発生します。
TestCase 'Domain.Tests.RegisterTests. ShouldNotCalculateRecieptWhenThereIsNoSale'
failed: NMock2.Internal.ExpectationException : unexpected invocation of receiptReceiver.ReceiveTotalDue(<0.00>)
このテストを通過するためには、Register オブジェクトが、ある状態 (つまり、処理中の販売が存在するかどうか) を絶えず追跡する必要があります。このテストに合格するための実装例を図 7 に示しました。
図 7 Register で状態を追跡する
public class Register : ISaleEventListener {
private readonly IReceiptReceiver receiptReceiver;
private bool hasASaleInprogress;
public Register(IReceiptReceiver receiver) {
this.receiptReceiver = receiver;
}
public void SaleCompleted() {
if(hasASaleInprogress)
receiptReceiver.ReceiveTotalDue(new Money(0.00m));
}
public void NewSaleInitiated() {
hasASaleInprogress = true;
}
public void ItemEntered(ItemId itemId, Quantity quantity) { }
}
商品の説明を取得する
商品情報システムは本社に置かれ、RESTful なサービスとして公開されています。Register オブジェクトは、販売の領収金額を算出するために、このシステムから商品情報を取得する必要があります。この外部システムの実装の細部に制約されるのは避けたいところです。そこで、POS システムが必要とするサービスについて、ドメインの観点で独自のインターフェイスを定義することにします。
いくつかの販売品目に対して領収金額を計算するテストを作成します。販売の合計を算出するには、Register は他のオブジェクトと連携しなければなりません。特定の品目に関する商品説明を取得するために、ここでは商品カタログの役割を取り入れることにしました。これは、RESTful なサービス、データベース、または、何か他のシステムでもかまいません。実装の細部は大きな問題ではありません。Register オブジェクトにとっても同様です。大切なことは、Register オブジェクトにとって意味のあるインターフェイスを設計することです。このテストを図 8 に示します。
図 8 Register のテスト
[TestFixture]
public class RegisterTests {
private Mockery mockery;
private IReceiptReceiver receiptReceiver;
private Register register;
private readonly ItemId itemId_1 = new ItemId("000000001");
private readonly ItemId itemId_2 = new ItemId("000000002");
private readonly
ProductDescription descriptionForItemWithId1 =
new ProductDescription("description 1", new Money(3.00m));
private readonly
ProductDescription descriptionForItemWithId2 =
new ProductDescription("description 2", new Money(7.00m));
private readonly Quantity single_item = new Quantity(1);
private IProductCatalog productCatalog;
[SetUp]
public void BeforeTest() {
mockery = new Mockery();
receiptReceiver = mockery.NewMock<IReceiptReceiver>();
productCatalog = mockery.NewMock<IProductCatalog>();
register = new Register(receiptReceiver, productCatalog);
Stub.On(productCatalog).Method("ProductDescriptionFor")
.With(itemId_1)
.Will(Return.Value(descriptionForItemWithId1));
Stub.On(productCatalog).Method("ProductDescriptionFor")
.With(itemId_2)
.Will(Return.Value(descriptionForItemWithId2));
}
[TearDown]
public void AfterTest() {
mockery.VerifyAllExpectationsHaveBeenMet();
}
...
[Test]
public void
ShouldCalculateRecieptForSaleWithMultipleItemsOfSingleQuantity() {
register.NewSaleInitiated();
register.ItemEntered(itemId_1, single_item);
register.ItemEntered(itemId_2, single_item);
Expect.Once.On(receiptReceiver).Method("ReceiveTotalDue")
.With(new Money(10.00m));
register.SaleCompleted();
}
}
このテストからは、Register オブジェクトに必要な productCatalog のインターフェイスの姿が浮かび上がってきます。また、商品の説明を表す新しい型 (productDescription) が必要であることもわかります。ここでは、これを値オブジェクト (イミュータブルな型) としてモデル化することにします。productCatalog をスタブ化することによって、ItemIdentifier で照会された productDescription を渡します。productCatalog の ProductDescriptionFor メソッドの呼び出しをスタブ化しているのは、これが、productDescription を返すクエリ メソッドであるためです。Register は、このクエリから返される結果に基づいて動作します。ここで重要なことは、指定された ProductDescription が ProductCatalog から返されれば、Register が正しい二次的作用を生むことです。テストの残りでは、合計が正しく計算され、ReceiptReceiver に送信されることを検証します。
テストを実行すると、予想どおり、次のエラーが発生します。
unexpected invocation of receiptReceiver.ReceiveTotalDue(<0.00>)
Expected:
Stub: productCatalog.ProductDescriptionFor(equal to <000000001>), will
return <Domain.Tests.ProductDescription> [called 0 times]
Stub: productCatalog.ProductDescriptionFor(equal to <000000002>), will
return <Domain.Tests.ProductDescription> [called 0 times]
1 time: receiptReceiver.ReceiveTotalDue(equal to <10.00>) [called 0 times]
モック フレームワークによれば、receiptReceiver に合計として 10 が渡されるべきところに 0 が渡された、とあります。これは当然の結果です。合計を計算するための機能を何も実装していないのですから。テストを通過するには、これをどのように実装すればよいでしょうか。まず、試したのが図 9 です。
図 9 合計の計算
public class Register : ISaleEventListener {
private readonly IReceiptReceiver receiptReceiver;
private readonly IProductCatalog productCatalog;
private bool hasASaleInprogress;
private List<ProductDescription> purchasedProducts =
new List<ProductDescription>();
public Register(IReceiptReceiver receiver,
IProductCatalog productCatalog) {
this.receiptReceiver = receiver;
this.productCatalog = productCatalog;
}
public void SaleCompleted() {
if(hasASaleInprogress) {
Money total = new Money(0m);
purchasedProducts.ForEach(item => total += item.UnitPrice);
receiptReceiver.ReceiveTotalDue(total);
}
}
public void NewSaleInitiated() {
hasASaleInprogress = true;
}
public void ItemEntered(ItemId itemId, Quantity quantity) {
var productDescription = productCatalog.ProductDescriptionFor(itemId);
purchasedProducts.Add(productDescription);
}
}
このコードはコンパイル エラーになります。Money クラスに、金額の加算処理が定義されていないためです。これで、Money オブジェクトに加算処理のためのロジックが必要であることがわかりました。これが今、私たちの目の前にある要件です。そこで、Money クラスがこの要件を処理できるかどうかを確認するための簡単なテストを記述することにします。
[TestFixture]
public class MoneyTest {
[Test]
public void ShouldBeAbleToCreateTheSumOfTwoAmounts() {
var twoDollars = new Money(2.00m);
var threeDollars = new Money(3m);
var fiveDollars = new Money(5m);
Assert.That(twoDollars + threeDollars, Is.EqualTo(fiveDollars));
}
}
テストに合格できるように実装したものを図 10 に示します。
図 10 金額の加算に対応
public class Money {
private readonly decimal amount;
public Money(decimal value) {
this.amount = value;
}
public static Money operator +(Money money1, Money money2) {
return new Money(money1.amount + money2.amount);
}
public override string ToString() {
return amount.ToString();
}
public override bool Equals(object obj) {
var otherAmount = obj as Money;
if(otherAmount == null) return false;
return amount == otherAmount.amount;
}
public override int GetHashCode() {
return amount.GetHashCode();
}
}
リファクタリング
ここから先に進むには、あらかじめコードの意図を明確にしておく必要があります。これまで紹介してきたテストでは、Register オブジェクトの内部的な詳細が一切追跡されません。こうした情報はすべて Register オブジェクトの内部に隠れているので、リファクタリングすれば、コードの意図を明確にすることができます。
Register オブジェクトは、現在の販売に関連した状態を管理しています。しかし、販売の概念を表すオブジェクトが存在しません。そこで、販売に関連したすべての状態と振る舞いを管理する Sale クラスを抽出してみたいと思います。リファクタリングしたコードを図 11 に示します。
図 11 Sale クラスの追加
public class Register : ISaleEventListener {
private readonly IReceiptReceiver receiptReceiver;
private readonly IProductCatalog productCatalog;
private Sale sale;
public Register(IReceiptReceiver receiver,
IProductCatalog productCatalog) {
this.receiptReceiver = receiver;
this.productCatalog = productCatalog;
}
public void SaleCompleted() {
if(sale != null) {
sale.SendReceiptTo(receiptReceiver);
}
}
public void NewSaleInitiated() {
sale = new Sale();
}
public void ItemEntered(ItemId itemId, Quantity quantity) {
var productDescription =
productCatalog.ProductDescriptionFor(itemId);
sale.PurchaseItemWith(productDescription);
}
}
public class Sale {
private readonly List<ProductDescription> itemPurchased =
new List<ProductDescription>();
public void SendReceiptTo(IReceiptReceiver receiptReceiver) {
var total = new Money(0m);
itemPurchased.ForEach(item => total += item.UnitPrice);
receiptReceiver.ReceiveTotalDue(total);
}
public void PurchaseItemWith(ProductDescription description) {
itemPurchased.Add(description);
}
}
値オブジェクト
.NET の世界には、"値型" という言葉があります。これは、CLR がサポートしているプリミティブ型 (int、bool、structs、enum など) を指します。一方、値オブジェクトは、物事を表すオブジェクトであり、値型とは区別されます。重要なことは、値オブジェクトはクラス (参照型) で実装できることです。これらのオブジェクトはイミュータブルであり、概念的なアイデンティティは持ちません。値オブジェクトは個別のアイデンティティを持たないため、2 つの値オブジェクトは両者がまったく同じ状態を持つ場合に等価と見なされます。
まとめ
この例では、モック オブジェクトとテスト駆動開発を軸にして、いかにオブジェクト指向プログラムの設計を進めていくかを紹介してきました。反復的に要件を見極めていくそのプロセスには、副次的にもたらされる重要な利点が数多くあります。モック オブジェクトの使用は、必要なコラボレータやオブジェクトの要件を見極めるためだけでなく、オブジェクトの相互の役割やコミュニケーション パターンをテストの中で定義し、明確化するうえでも大きな効果があります。つまり、オブジェクトの周囲にどのような役割を持ったオブジェクトが必要か、テスト下にあるオブジェクトと、そのコラボレータ間のコミュニケーション パターンはどうするかなどです。
ドメインの概念と照らしてオブジェクトが何をしているのか。テストからわかるのは、それだけです。Register と Sale 間の対話方法がテストによって指定されるわけではありません。これらは、Register オブジェクトがその仕事を果たすために、どのような構造になっていればよいか、という内部的な詳細にかかわることであり、こうした実装の細部は隠されたままです。モックを使用することにしたのは、システム内のオブジェクトとそのコラボレータ間でやり取りされる対話のうち、外部から見えるべき対話を明示的に指定するためです。
モック オブジェクトを使用したテストにより、オブジェクトからどのようなメッセージを送信できるか、また、そうしたメッセージがいつ送信されるべきかなど、一連の制約が明らかになってきます。オブジェクト間の対話は、ドメインの概念と照らして明確に表現されます。ドメインは、それぞれが限られた役割を持つインターフェイスが集まって構成されています。1 つのインターフェイスに与える役割を限定することによって、プラガブルなシステムが実現し、システムの振る舞いも、オブジェクトの組み合わせを少し変えるだけで、容易に変更することができます。
オブジェクトの内部的な状態や構造が外部にさらされることはなく、受け渡しされるのも、イミュータブルな状態 (Money、itemId などの値オブジェクト) だけです。このことがプログラムの保守性を高め、変更も容易になります。1 つ大切なことを挙げるならば、開発は "あえて不合格となるようなテストを書くことから始まる" という点です。ご紹介した例でも、それが CommandParser の要件やニーズを洗い出すきっかけになっていたことがおわかりいただけたと思います。
Isaiah Perumalla は、ThoughtWorks の上級開発者です。同社で、オブジェクト指向設計とテスト駆動開発を駆使しながら、エンタープライズ規模のソフトウェア開発に携わっています。