疑似オブジェクトによる単体テスト
コラボレーターを疑似オブジェクトで置き換えることによって単体テストを改善する
単体テストは、ソフトウェア開発の「ベスト・プラクティス」として広く受け入れられるようになりました。オブジェクトを作成する場合は、そのオブジェクトの動作を試すメソッドを含み、さまざまなパラメーターと共にさまざまなパブリック・メソッドを呼び出し、返された値が適切であるかを確認する、自動化されたテスト・クラスも提供する必要があります。
シンプルなデータやサービス・オブジェクトを処理する場合には、単体テストの作成は簡単です。しかし多くのオブジェクトは、他の複数のオブジェクトやインフラストラクチャーのレイヤーに依存しています。このようなオブジェクトのテストは、しばしば高価であったり、非現実的であったり、これらのコラボレーターをインスタンス化するには非効率であったりします。
たとえば、データベースを使用するオブジェクトの単体テストを実行する場合、データベースのローカル・コピーをインストールして構成し、導入し、テストを実行し、再びそのローカル・データベースを削除するのは手間のかかる作業になる可能性があります。このジレンマを解決するには、疑似オブジェクトが役立ちます。疑似オブジェクトは、本物の実オブジェクトのインターフェースと同様に振る舞いますが、いかにも本物のオブジェクトが機能したかのような振る舞いをテスト対象オブジェクトに見せて、テスト対象オブジェクトの振る舞いを追跡するための最低限のコードのみを持ちます。たとえば、特定の単体テストにおけるデータベース接続では、常に同じ固定の結果を返す一方で、クエリーを記録することもあります。テストするクラスが予想どおりに振る舞う限り、テスト対象オブジェクトはその違いに気付かず、したがってこの単体テストでは、適切なクエリーが出力されたかどうかを確認できます。
中間に存在する疑似オブジェクト
疑似オブジェクトを使ったテストの一般的なコーディング・スタイルは次のとおりです。
- 疑似オブジェクトのインスタンスの作成
- 疑似オブジェクトの状態と期待値の設定
- 疑似オブジェクトをパラメーターとしてのドメイン・コードの呼び出し
- 疑似オブジェクト間の整合性の検証
このパターンは多くの場合非常に効果的ですが、疑似オブジェクトをテスト中のオブジェクトに渡すことができない場合があります。そのようなオブジェクトは、代わりに、コラボレーターを作成、検索、あるいは取得するよう設計されています。
たとえば、テスト対象オブジェクトが、Enterprise JavaBean (EJB) コンポーネントまたはリモート・オブジェクトへの参照の取得を必要とする場合があります。あるいは、ファイルを削除してしまうFile
オブジェクトのような、単体テストでは望ましくないと思われる副作用のあるオブジェクトを使用することもあります。
一般的に、こうした状況は、テストをより容易にするために、オブジェクトのリファクタリングを行うよい機会であると考えられています。たとえば、コラボレーター・オブジェクトが渡されるよう、メソッド・シグニチャーを変更することができます。
Nicholas Lesiecki氏の記事「AspectJおよび疑似オブジェクトによる柔軟なテスト」で、氏は、リファクタリングは必ずしも望ましいものではなく、また、それによって必ずしもコードが、読みやすく、理解しやすいものになるとは限らないことを指摘しています。多くの場合、コラボレーターをパラメーターとするようメソッド・シグニチャーを変更すると、メソッドの呼び出し元のコードは、テストされない、混乱を招く、分かりにくいものとなるでしょう。
問題の核心は、テスト対象オブジェクトがこれらのオブジェクトを「内部で」取得していることです。何らかの解決策をこの生成コードのすべてのオカレンスに適用しなければなりません。この問題を解決するために、Lesiecki氏は、検索アスペクトまたは生成アスペクトを使用しています。この解決策では、検索を実行するコードが、代わりに疑似オブジェクトを返すコードと自動的に置き換えられます。
AspectJは選択できない場合があるので、この記事では代わりのアプローチを紹介します。このアプローチは基本的にリファクタリングであるため、Martin Fowler氏の独創性に富んだ著書である「Refactoring: Improving the Design of Existing Code」 (参考文献を参照) で氏によって確立された表示規則に従います (ここで紹介するコードは、Javaプログラミング用の最もよく知られた単体テスト・フレームワークであるJUnitに基づいていますが、JUnit専用というわけではありません)。
リファクタリング: ファクトリー・メソッドの抽出およびオーバーライド
リファクタリングとは、プログラムの機能を変えずにソース・コードを変える手順のことで、より理解しやすく、より効率的に、そしてより簡単にテストできるようにコードの設計を変更します。このセクションでは、ファクトリー・メソッドの抽出およびオーバーライドのリファクタリングについて段階的に説明します。
問題:テスト対象オブジェクトでコラボレーター・オブジェクトが生成されます。このコラボレーターは、疑似オブジェクトと置き換える必要があります。
リファクタリング前のコード
class Application { ... public void run() { View v = new View(); v.display(); ...
解決策:生成コードをファクトリー・メソッドに抽出し、テスト・サブクラス内でこのファクトリー・メソッドをオーバーライドし、オーバーライドされたメソッドが代わりに疑似オブジェクトを返すようにします。最後に、実用的であれば、元のオブジェクトのファクトリー・メソッドに対し、正しい型のオブジェクトを返すよう要求する単体テストを追加します。
リファクタリング後のコード
class Application { ... public void run() { View v = createView(); v.display(); ... protected View createView() { return new View(); } ... }
このリファクタリングによって、リスト1に示す単体テスト・コードが有効になります。
リスト1. 単体テスト・コード
class ApplicationTest extends TestCase { MockView mockView = new MockView(); public void testApplication { Application a = new Application() { protected View createView() { return mockView; } }; a.run(); mockView.validate(); } private class MockView extends View { boolean isDisplayed = false; public void display() { isDisplayed = true; } public void validate() { assertTrue(isDisplayed); } } }
役割
この設計では、システムのオブジェクトによって実行される次の役割について紹介しています。
- ターゲット・オブジェクト: テスト対象オブジェクト
- コラボレーター・オブジェクト: ターゲットによって生成または取得されるオブジェクト
- 疑似オブジェクト: 疑似オブジェクトのパターンに従うコラボレーターのサブクラス (または実装)
- 特化オブジェクト: コラボレーターの代わりに疑似を返すよう生成メソッドをオーバーライドする、ターゲットのサブクラス
リファクタリング技巧
リファクタリングは、複数の小さな技術的ステップで構成されます。これらをまとめてリファクタリング技巧と呼びます。料理本のレシピに従うのと同様に、この技巧に従えば、大きなトラブルもなくリファクタリングを学ぶことができます。
- コラボレーターを生成または取得するコードのすべての出現を特定します。
- この生成コードに抽出メソッド・リファクタリングを適用し、ファクトリー・メソッドを作成します (Fowler氏の著書の110ページに記述。詳細については、参考文献のセクションを参照)。
- ファクトリー・メソッドがターゲット・オブジェクトとそのサブクラスからアクセスできるようにします (Java言語では、
protected
キーワードを使用します)。 - テスト・コードで、コラボレーターと同じインターフェースを実装する疑似オブジェクトを作成します。
- テスト・コードで、ターゲットを拡張する (特化する) 特化オブジェクトを作成します。
- 特化オブジェクトで、テストに対応した疑似オブジェクトを戻すために、作成メソッドをオーバーライドします。
- オプション: 元のターゲット・オブジェクトのファクトリー・メソッドが、今までどおり、疑似ではない正しいオブジェクトを戻すように、単体テストを作成します。
例: ATM
銀行の自動預金支払機 (ATM) 用のテストを作成するとしましょう。その1つの例をリスト2に示します。
リスト2. 疑似オブジェクト導入前の初期の単体テスト
public void testCheckingWithdrawal() { float startingBalance = balanceForTestCheckingAccount(); AtmGui atm = new AtmGui(); insertCardAndInputPin(atm); atm.pressButton("Withdraw"); atm.pressButton("Checking"); atm.pressButtons("1", "0", "0", "0", "0"); assertContains("$100.00", atm.getDisplayContents()); atm.pressButton("Continue"); assertEquals(startingBalance - 100, balanceForTestCheckingAccount()); }
さらに、AtmGui
クラス内部のマッチ・コードは、リスト3のようになります。
リスト3. リファクタリング前の生成コード
private Status doWithdrawal(Account account, float amount) { Transaction transaction = new Transaction(); transaction.setSourceAccount(account); transaction.setDestAccount(myCashAccount()); transaction.setAmount(amount); transaction.process(); if (transaction.successful()) { dispense(amount); } return transaction.getStatus(); }
このアプローチはうまくいきますが、残念なことに副作用があります。それは、テスト開始時より当座預金残高が少なくなり、他のテストがより難しくなるというものです。これを解決する方法がいくつかありますが、それらはすべてテストをより複雑なものにします。さらに悪いことに、このアプローチでは、預金管理システムまで3回往復する必要があります。
この問題を解決する最初のステップとして、リスト4のようにAtmGui
のリファクタリングを行い、本物のトランザクションの代わりに疑似トランザクションを使用します (何を変更しているかは太字のソース・コードを比較してください)。
リスト4. AtmGuiのリファクタリング
private Status doWithdrawal(Account account, float amount) { Transaction transaction = createTransaction(); transaction.setSourceAccount(account); transaction.setDestAccount(myCashAccount()); transaction.setAmount(amount); transaction.process(); if (transaction.successful()) { dispense(amount); } return transaction.getStatus(); } protected Transaction createTransaction() { return new Transaction(); }
テスト・クラス内部に戻り、リスト5に示すように、MockTransaction
クラスをメンバー・クラスとして定義します。
リスト5. MockTransactionクラスをメンバー・クラスとして定義
private MockTransaction extends Transaction { private boolean processCalled = false; // override process method so that no real work is done public void process() { processCalled = true; setStatus(Status.SUCCESS); } public void validate() { assertTrue(processCalled); } }
最後に、リスト6に示すように、テスト対象オブジェクトが、本物のクラスではなくMockTransaction
クラスを使用するようテストを作り直すことができます。
リスト6. MockTransactionkクラスの使用
MockTransaction mockTransaction; public void testCheckingWithdrawal() { mockTransaction = new MockTransaction(); AtmGui atm = new AtmGui() { protected Transaction createTransaction() { return mockTransaction; } }; insertCardAndInputPin(atm); atm.pressButton("Withdraw"); atm.pressButton("Checking"); atm.pressButtons("1", "0", "0", "0", "0"); assertContains("$100.00", atm.getDisplayContents()); atm.pressButton("Continue"); assertEquals(100.00, mockTransaction.getAmount()); assertEquals(TEST_CHECKING_ACCOUNT, mockTransaction.getSourceAccount()); assertEquals(TEST_CASH_ACCOUNT, mockTransaction.getDestAccount()); mockTransaction.validate(); }
この解決策では、テストが多少長くなりますが、ATMのインターフェースを超えたシステム全体の振る舞いではなく、テスト対象クラスの直接の振る舞いにのみ関与しています。つまり、テスト口座の最終残高が正確であることはもはや確認しません。その機能は、AtmGui
オブジェクトではなく、Transaction
オブジェクト用の単体テストで確認します。
注:発明者によると、疑似オブジェクトは、そのvalidate()
メソッド内部で自身の検証のすべてを実行することになっています。この例では、わかりやすくするため、その検証の一部をテスト・メソッド内部に残しました。疑似オブジェクトの使用に慣れるに従って、疑似にどの程度の検証を分担させるかという感覚が養われるようになるでしょう。
内部クラスの魔術
リスト6では、AtmGui
の匿名内部サブクラス (anonymous inner subclass) を使用して、createTransaction
メソッドをオーバーライドしました。1つのシンプルなメソッドをオーバーライドするだけでよかったので、私たちの目的を達成するにはこれが簡潔な方法でした。複数のメソッドをオーバーライドするか、AtmGui
サブクラスを多くのテスト間で共有するのであれば、完全な (匿名でない) メンバー・クラスを生成する価値があるでしょう。
また、インスタンス変数を使用して、疑似オブジェクトへの参照を保存しました。これは、テスト・メソッドと特殊クラス間でデータを共有するための最も簡単な方法です。私たちのテスト・フレームワークは、マルチスレッド化されておらず、再入可能でもないので、この方法が有効です(マルチスレッド化され再入可能な場合は、synchronized
ブロックによる保護が必要となります)。
最後に、疑似オブジェクト自体を、テスト・クラスのprivateな内部クラスとして定義しました。疑似オブジェクトを使用するテスト・コードの直後にそれを置くことでより明確になり、また、内部クラスは、その周囲のクラスのインスタンス変数にアクセスできるので、このアプローチは多くの場合便利です。
念には念を
このテストを作成するのに、ファクトリー・メソッドをオーバーライドしたため、元の生成コード (現在は、ベース・クラスのファクトリー・メソッド内部にある) のすべてのテスト対象が失われています。このコードを明示的に対象とするテストを追加することが有益かもしれません。このテストは、ベース・クラスのファクトリー・メソッドを呼び出し、返されるオブジェクトが正しい型であると明示するだけの簡単なものです。例を以下に示します。
AtmGui atm = new AtmGui(); Transaction t = atm.createTransaction(); assertTrue(!(t instanceof MockTransaction));
MockTransaction
もTransaction
であるため、この逆のassertTrue(t instanceof Transaction)
では十分でないことに注意してください。
ファクトリー・メソッドから抽象ファクトリーへ
この時点でさらに一歩進み、Erich Gamma氏などの 「Design Patterns」 (参考文献を参照) に詳しく説明されているように本格的な抽象ファクトリー・オブジェクトと置き換えたいと思うかもしれません。実際、多くのみなさんは、ファクトリー・メソッドではなくファクトリー・オブジェクトを使って、このアプローチを始めたことでしょう。私たちもそうしましたが、すぐに撤退しました。
3つめのオブジェクト・タイプ (ロール) をシステムに導入すると、いくつかの欠点が発生する可能性があります。
- 複雑さが増すが、それに見合うだけの機能性の向上がない。
- ターゲット・オブジェクトへのパブリック・インターフェースを変更しなければならない場合がある。抽象ファクトリー・オブジェクトを渡さなければならない場合、新しいパブリック・コンストラクターまたはミューテーターを追加しなければなりません。
- 多くの言語に、「ファクトリー」の概念に関係した紛らわしい規則がある。たとえば、Java言語では、ファクトリーは多くの場合、静的メソッドとして実装されますが、この状況ではそれは適切ではありません。
ここで思い出してください。今回の記事の全体のポイントは、オブジェクトをより簡単にテストできるようにすることです。多くの場合、テストしやすいように設計することで、オブジェクトのAPIがよりわかりやすく、よりモジュール化された状態になることもあります。ただし、これは大変な作業になるかもしれません。テスト主導の設計変更が、元のオブジェクトのパブリック・インターフェースに悪影響を与えてはなりません。
ATMの例では、生成コードに関する限り、AtmGui
オブジェクトは、常にTransaction
オブジェクトの1つの (実際の) 型だけを生成します。テスト・コードでは、それが異なる型 (疑似) を生成することが望まれます。しかし、テスト・コードが望むからといって、パブリックAPIにファクトリー・オブジェクトや抽象ファクトリーを処理させるのは誤った設計です。生成コードでそのコラボレーターの多くの型をインスタンス化する必要がない場合、そうした機能を追加すると、結果としてその設計は、不必要に理解が困難なものになるでしょう。
ダウンロード可能なリソース
関連トピック
- Tim Mackinnon氏、Steve Freeman氏、およびPhilip Craig氏による「Endo-Testing: Unit Testing with Mock Objects」は、疑似オブジェクトという用語について紹介した論文です。
- Mock Objectsプロジェクトは、疑似オブジェクトの実装をサポートするフレームワークです。
- ファクトリー・メソッドと抽象ファクトリーの設計パターンのソースは、四人組としても知られている、Erich Gamma氏、Richard Helm氏、Ralph Johnson氏、およびJohn Vlissides氏による「Design Patterns: Elements of Reusable Object-Oriented Software」 (Addison-Wesley、1997) にあります。
- Martin Fowler氏が管理するRefactoring Home Page は、優れたプログラマーのための優れた参考文献です。
- また、Martin Fowler氏による「Refactoring: Improving the Design of Existing Code」 (Addison-Wesley、1999) も役立ちます。
- JUnit は、Java言語用の最もよく知られた単体テスト・フレームワークです。
- Purple TechnologyでXPとリファクタリングの参考文献のリストをご確認ください。
- XPの指導者であり、また、Java開発者であるRoy Miller氏の 『エクストリーム・プログラミングの神秘を解く』 のコラムは、この方法論に関する洞察を示しています。
- 「XPの真髄」に立ち戻る 第1回: XPの誇大宣伝を詳しく検討する
- 「XPの真髄」に立ち戻る 第2回: プログラマーのプラクティスを全体像に当てはめる
- 「XPの真髄」に立ち戻る 第3回: 顧客役と管理役のプラクティス
- 考え方を変える: XPを使用するために必要な考え方について
- Nicholas Lesiecki氏の「AspectJおよび疑似オブジェクトによる柔軟なテスト」 (developerWorks、2002年5月) は、単体テストでのAspectJと疑似オブジェクトの使用について詳しく説明しています。
- Eric Allen氏の「Diagnosing Java code: Unit tests and automated code analysis working together」 (developerWorks、2002年10月) は、単体テストと静的分析の関係について説明しています。
- WebSphere は、JUnitによる単体テストについて説明しています。
- WebSphere は、対話式デバッグや反復的な単体テストを含むサーバー・サイドのWeb開発のシナリオについて説明しています。
- developerWorks Java technologyゾーンで他のJava関連記事やチュートリアルをご覧ください。