BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Spring Frameworkによるソフトウェアテスト

Spring Frameworkによるソフトウェアテスト

ブックマーク

はじめに

エンタープライズソフトウェア開発においてテストの実施は不可欠な要素である。最重要とは言わないまでも、ソフトウェア開発ライフサイクル(SDLC; Software Development Lifecycle)のさまざまな段階と同等に重要な要素である。しかし、エンタープライズアプリケーションのテストは口で言うほど簡単ではない。いくつかの制約によって、プロジェクトにおけるテスト労力が大きな課題となる。これらの制約は通常2つのカテゴリに分類される。フレームワーク関連の制約とプロジェクト方法論関連の制約である。

フレームワーク関連の制約の一例に、J2EEアーキテクチャモデルがソフトウェア開発の一環として単体テストの側面を考慮に入れないことが挙げられる。コンテナ(アプリケーションサーバー)はJ2EEランタイムアーキテクチャのコアコンポーネントであるため、コンテナ外で、J2EE APIをベースに構築されたアプリケーションをテストするのは困難である。単体テスト(コンテナ外)は、高いテストカバレッジを実現するために不可欠である。また、アプリケーションサーバーの設定やコード配備のオーバーヘッドなしに、多くのエラーシナリオを容易に再現できる。テストを迅速に実行できることがプロジェクトの開発または生産支援段階において重要であり、不可欠である。単体テストを使用してコードを検証することにより、コードを変更するたびにアプリケーションを配備する非生産的な待ち時間が削減される。

従来のJ2EEアプリケーションのコードはアプリケーションサーバーに大きく依存しているため、アプリケーションがJ2EEコンテナ内に配備されている場合にのみ、その機能を完全にテストできる。しかし、コンテナ内テストは、特にプロジェクトが比較的大きくコードアーティファクト(javaソースファイル)の数が多い場合に、時間がかかりすぎて開発者の生産性に与える影響が大きすぎる。

ソフトウェアテストを開発プロセスに統合する強力なサポートを提供するよう、徹底的に構築されたJ2EEフレームワークがいくつかある。Spring(サイト・英語)は、そのようなjavaエンタープライズアプリケーション開発フレームワークの1つである。

先日、私は、妻が地元のTier-One (一次下請け)の自動車部品会社のために取り組んでいるエンタープライズjavaアプリケーションについて専門的な助言をした。そのプロジェクトとは、顧客の会社のプロファイルを追跡するための「顧客プロファイル管理システム」を作成することであった。このアプリケーションのアーキテクチャには、Hibernate 3.0、Spring 2.0、およびJBoss 4.0テクノロジーが組み込まれた。プロジェクトチームは、1週間のイテレーションで要件を実現するアジャイルソフトウェア開発手法に従った。データアクセス層とサービス層のアプリケーションコードのテストには、Springフレームワークで提供される統合テスト機能を使用した。我々はこのフレームワークで提供されるテストサポートが非常に気に入った。Springフレームワークは、テストを大幅に簡素化し、1週間という集中的な開発イテレーションを可能かつ管理しやすいものにする。

この記事では、単体/統合テストの分野でSpringフレームワークが提供するサポートの概要について説明する。一般的なJava EEアプリケーションでのアジャイルテスティングフレームワークの実装と、Springテストクラスを使用したアプリケーション機能のテスト方法について、読者の方の参考となるように、ローン処理のWebアプリケーションの例を取りあげる。

アジャイルソフトウェアテスト

ソフトウェア開発プロジェクトには、優れたテストプラクティスだけでなく優れた設計とアーキテクチャプラクティスを組み込む必要がある。アプリケーションは非常に優れたアーキテクチャ、設計、およびコードを採用していたとしても、十分にテストされていない場合、成功した製品としてみなすことはできない。製品の品質は企業(ソフトウェアサービスベンダー)の生命を左右するため、こうした企業が成功するにはテストの実施が非常に重要となる。

アジャイルソフトウェア開発には、ソフトウェア開発において俊敏性と品質を達成するための包括的なテスト戦略が必要である。アジャイルテストには、単体テストと統合テストが含まれる。つまり、我々ができるだけ迅速にテストを実行できなければならないことを意味している(俊敏性を達成する方法の1つは、アプリケーションサーバー外でテストを実行することである)。テスト駆動開発(TDD; Test Driven Development)(source)は、アジャイル開発プロセスの主要な要素の1つである。Springおよびその他PicoContainer(サイト・英語)やHiveMind(サイト・英語などの軽量コンテナは、テスト駆動ソフトウェア開発を強力にサポートする。

次に、一般的なJava EE開発プロジェクトにおける単体/統合テストの重要性と各テスト手法の目的および制約に少し目を向けてみよう。

単体テスト

単体テストは、アプリケーション内の特定の単位(クラス)を対象としたテストである。単体テストは、メソッドのすべての例外パスを含め、クラス内のすべてのメソッドをテストするように記述する必要がある。単体テストの目的は、サーバー設定、サービス設定、およびアプリケーション配備に伴うオーバーヘッドや追加時間なしで新しいコードまたは既存のコードの変更を迅速にテストすることである。開発者の単体テストは、ソフトウェア開発ライフサイクルの後半ではなく初期の段階(コーディング段階および単体テスト段階)でより安く容易にバグを発見して修正できるため、非常に重要である。

JUnit(サイト・英語)は、単体テストの記述に使われる有名なテスティングフレームワークである。JUnitテストでは、コンテナのJNDIリソースやリソースプーリングなどのJ2EEサービス、JDBC接続プール、およびJMSキューを気にする必要なく、単純にnew演算子を使ってオブジェクトのインスタンスを作成する。また、モックオブジェクト(source)のようなテスト技術を使用して、コードを独立した形でテストできる。単体テストでは、アプリケーションサーバーあるいはデータベースサーバーのインフラストラクチャを設定する必要がない。

単体テストにはいくつかの制限がある。単体テストは、アプリケーションの機能的要件のテストには対処せず、アプリケーション内の各モジュールのテストのみを対象とする。また、アプリケーションサーバーの内部に設定されたJMSメッセージキューを必要とする非同期サービスのようなシナリオもテストできない。しかしそれでも、できるだけ多くのアプリケーション機能を単体テストし、コンテナ外でテストできない機能にのみコンテナ内テストを使用できる必要がある。

統合テスト

単体テストは、モジュールまたはクラスを独立した形でテストするのに非常に役立つ。しかし、各種モジュールが統合環境でアセンブルされるとどのように連携して動作するかを確認するために、アプリケーションの統合テストを実施することも重要となる。機能がモジュールレベルでは良好に動作しても、アプリケーション内で他のモジュールと統合されると正常に動作しない場合がある。このシナリオは、複数の開発者がアプリケーションの異なる部分の開発を同時に進め、定期的に(一部の開発チームでは毎日)コードの変更をマージする必要があるアジャイル開発環境において、非常に現実味がある。統合テストには、アプリケーションのクライアント層とサービス層の間の往復呼び出しのテストが含まれる。統合テストの多くは通常、コンテナ内で動作する。しかし、真の意味で俊敏(アジャイル)になるには、少なくともいくつかの統合テストを、コードをコンテナに配備せずに実行する必要がある。

統合テストは、DAOインターフェースの実装を効果的に単体テストできないDAO層で役立つ。この他の統合テストの目的は、リモートサービス、ステート(セッション)管理、Webフローおよびトランザクション管理などの状態をテストすることである。統合テストにもいくつかの制約がある。これらのテストの実行には長い時間がかかる。アプリケーションをJava EEコンテナ内に配備する必要があるため、サーバーのセットアップと構成のオーバーヘッドがテストの実行に伴う。

統合テストは単体テストの代用ではなく補足的なテストであるということに注意しなければならない。開発者はまず、高いコードカバレッジを達成するためにアプリケーション内の各javaクラスに対して十分な単体テストを記述する必要がある。同時に、単体テストではテストできないアプリケーションのさまざまなユースケースシナリオを網羅する、十分な統合テストを記述する必要がある。

単体/統合テストのほか、数種類のテストがある。次の表に、さまざまなテスト戦略とその目的を示す。

表1. Java EEのテスト戦略

テスト戦略 目的
単体テスト クラスレベルでアプリケーションをテストして、各クラス内のすべてのメソッドをテストする。
モックオブジェクト モックオブジェクトはアプリケーションのクライアント層とサービス層で使われ、実際にバックエンドデータベースやその他ネットワークリソースに接続する必要なくクラスメソッドをテストする。
ORMテスト ORM層で定義されたデータベーステーブルのマッピングの完全性を検証する。これらのテストクラスはデータベースメタデータ情報を使用してORMマッピングをチェックする。
DBテスト データアクセスクラス(DAO)を独立した形でテストする。これらのテストはテストの実行ごとにデータベーステーブルを既知のステートにする。
XMLテスト Test XML文書とその有効性をテストし、2つの異なる文書を比較してそれらが同一かそうでないかを断定する。
統合テスト Webサイトナビゲーション、Webフロー、およびステート(セッション)管理およびトランザクション管理をテストする。
回帰テスト 実稼働環境に配備されたときにエンドユーザーがアプリケーションを使用するような形でアプリケーション機能をテストする。これらのテストは通常、専門のQAチームによってMercury QuickTest Professional(QTP)(サイト・英語)などの自動機能テストツールを使用して実行される。
負荷テスト アプリケーションの拡張性をテストする。これらの性能テストは通常、専門のテストチームによってMercury LoadRunner(サイト・英語)、WAPT(サイト・英語)、およびJMeter(サイト・英語)などのツールを使用して実行される。
プロファイリング アプリケーション実行時のメモリリーク、メモリ使用量、ガ-ベジコレクションなどをテストする。開発者はJProfiler(サイト・英語)、Quest JProbe(サイト・英語)、Eclipse TestおよびPerformance Tools Platform(TPTP)(サイト・英語)などのjavaプロファイラを使ってアプリケーションを実行する。

上記に一覧した各種テスト戦略を実行するための、さまざまなオープンソースのテスティングフレームワークがある。そのテスティングフレームワークの一部を次に示す。

  • JUnit
  • JMock
  • ORMUnit
  • DBUnit
  • XMLUnit
  • JUnitEE
  • MockEJB
  • Cactus

テストはプロジェクトの成功と失敗を決定付ける重大な側面であるため、ソフトウェア開発に使用するJava EEフレームワークは、設計および開発段階へのテストのシームレスな統合をサポートするものでなくてはならない。次に、単体/統合テストの観点から、理想的なJava EEフレームワークが備えるべきいくつかの特性について注目しよう。

アジャイル開発:
フレームワークはアプリケーションの反復型およびアジャイル開発を支援する必要がある。アジャイル手法は、多くの開発チームによって採用されており、俊敏なテストと早期のフィードバックが反復型開発の主体となっている。

テスト駆動開発:
テストの懸念はアプリケーション開発ライフサイクルの初期段階から対処する必要があることは十分に証明された事実である。プロセスの初期にバグを発見して修正することは非常に効果的で安上がりである。バグを発見する最良の方法は、プロジェクトの各イテレーションの設計および開発段階で「早めに何度か」テストすることである。

インターフェースベースの設計:
我々オブジェクト指向プログラマが目指しているベストプラクティスの1つは、concreteクラスではなくjavaクラスをインターフェースに記述することである。インターフェースに記述することで、サービスコンポーネントの実装を変更するたびにクライアントコードを修正する必要なく、単体/統合テストを実行できる優れた柔軟性が得られる。

関心事の分離:
(ドメイン、ビジネスロジック、データアクセス、基盤ロジックなど)対処しようとしている特定のアプリケーションの関心事に基づいて意識的に個別のモジュールでコードを設計および記述する場合、「関心事の分離」(SOC; Separation of Concerns)を達成する。これにより、ロギング、例外処理、アプリケーションセキュリティなどのさまざまな関心事を、他のモジュールに依存せずに独立した形式でテストできる。

階層化アーキテクチャ:
一般的なJavaエンタープライズアプリケーションは、クライアント層、サービス層、ドメイン層および永続層が存在するような方法で設計されている。各層の要素はどれも、同じ層内の他の要素またはすぐ「下」の層の要素にのみ依存すべきである(アーキテクチャ層においてプレゼンテーション層が最上層、永続層が最下層と仮定する)。つまり、クライアント層はサービス層にのみ依存でき、サービス層はドメイン層にのみ依存でき、ドメイン層は永続層にのみ依存できる。Java EEフレームワークはこれらすべての層において他の層に依存しない単体/統合テストをサポートすべきである。

非侵襲:
EJBやStrutsのようなフレームワークでは、開発者はアプリケーション内のフレームワーク固有のクラス(EJBObject, ActionForm, Actionなど)を拡張することが要求される。これは、特定のフレームワークへのアプリケーション依存が生じ、他の(より良い)フレームワークに切り替える必要がある場合に余計な作業が発生する。これらのフレームワークは本質的に侵襲的であり、将来的な拡張性の要件を考慮に入れて慎重に選択する必要がある。Java EE 5の一部であるEJB仕様の最新版(バージョン3.0)は、Entity(旧名Entity Bean)およびSession beanがSpring beanと同様にコンテナ外でテストできるプレーンなjavaクラスであるため、侵襲性は低い。

制御の反転(IoC; Inversion Of Control):
フレームワークは、アプリケーションで作成されたオブジェクトにおいて制御の反転(Inversion of Control)をサポートする必要がある。制御の反転(Inversion of Control)すなわちIoC(依存性の注入(Dependency Injection; DI)とも呼ばれる)の設計パターンは、統合テストにメリットをもたらす。主なメリットは、IoCパターンを基に設計されたアプリケーションは従来のJ2EEアプリケーションアーキテクチャを使用して作成されたアプリケーションよりもコンテナに対する依存性が少ないことである。

アスペクト指向プログラミング(AOP; Aspect Oriented Programming): 
AOP(source)は、さまざまなクラスに散らばってしまう処理を単一のモジュールにまとめることができる。これは単体/統合テストに非常に有用であり、トランザクション管理や役割ベースのセキュリティといったJava EEサービスを、アスペクトを使用して宣言的にテストできる。

サービス指向アーキテクチャ(SOA; Service Oriented Architecture):
サービスはエンタープライズのさまざまなモジュールおよびアプリケーションで使用されるため、SOA基盤においてテストは非常に重要なコンポーネントである。サービスコンポーネントの特定のユースケースが十分にテストされない場合、実稼働環境でコード変更が行われると生産上の問題と品質の問題が発生する可能性がある。

データアクセス抽象化:
データアクセスに対する一貫したアーキテクチャアプローチもまた、アプリケーションのデータアクセス機能をテストする上で非常に重要である。データアクセス抽象化は永続実装フレームワーク(HibernateJPAJDOiBATISOJB、およびSpring JDBCなど)に不可知論的でなければならない。また、永続層でスローされたデータアクセス例外をうまく処理する必要がある。

トランザクション管理:
フレームワークはトランザクション管理をテストするための抽象インターフェースを提供する必要がある。JDBCJTAトランザクション(コンテナ管理トランザクションとBean管理トランザクションの両方)およびその他トランザクションオブジェクト(Hibernateトランザクションなど)を統合する必要がある。

Springのテストサポート

Springフレームワークは、開発者が適切な設計と効果的な単体テストのベストプラクティスに従うことができるようにアジャイルテスト戦略に基づいて設計されている。また、アプリケーションサーバー外での統合テストの実行を強力にサポートする。Springは、Springの使用時にフレームワークへのアプリケーションコードの依存性が最小限であるという意味では非侵襲的なフレームワークである。Spring固有のクラスを拡張する必要なく、アプリケーションオブジェクトをプレーンなjavaクラス(POJO)として設定できる(注: JDBCTemplateJNDITemplateHibernateDaoSupportなどのSpringテンプレートヘルパーを使用する場合)。Springができる前に記述されたレガシークラスでも設定可能である。

一般のSpringフレームワーク、および特にSpringテストモジュールは、次の側面(アスペクト)をサポートする。

隔離性:
SpringはJ2EE開発者に、モック実装の導入によりjavaクラスを独立した形でテストする柔軟性を提供する。たとえば、対応するRepositoryクラスのモック実装を使用してサービスクラスをテストできる。このようにして、データベース接続の永続性の詳細を気にせずにサービスクラスでビジネスロジックをテストできる。

制御の反転(IoC; Inversion of Control):
フレームワークはPOJOの高度な設定管理を提供する。Spring IoCコンテナは細かなまたは大まかなjavaクラスを管理できる。beanファクトリを使用してアプリケーションオブジェクトのインスタンスを作成し、それらをコンストラクタインジェクションまたはセッターインジェクションを使用して結びつける。

データアクセス:
データアクセス用の優れた永続アーキテクチャと、データアクセス例外の適正な階層を提供する。また、主要な永続フレームワークを使用するためのhelperクラス(JdbcTemplateHibernateTemplateTopLinkTemplateJpaTemplateなど)を提供する。

トランザクション管理:
Springは、トランザクション(ローカルおよびグローバルの両方)を管理するための優れた抽象化フレームワークを提供する。この抽象化は、幅広い開発環境で一貫したプログラミングモデルを提供し、Springの宣言的およびプログラム的トランザクション管理の基盤である。

Springを使用した統合テスト

Springの設定、依存性の注入(DI)、データアクセス(CRUD)、およびトランザクション管理は、Springテスティングフレームワークを使用してサーバー環境外でテストできる関心事の一部である。データアクセステストは実際のデータベースに実行されるため、このテストでモックオブジェクトを使用する必要は一切ない。

Springコンテキストのロード時間は小中規模のアプリケーションでは問題にならないかもしれない。しかし大規模のエンタープライズアプリケーションの場合、アプリケーションのクラスのインスタンスを作成するのに大幅な時間がかかることがある。また、1つ1つのテストフィクスチャの1つ1つのテストケースを実行するオーバーヘッドにより、テスト全体の実行時間が遅くなり、開発者の生産性に悪影響を及ぼす。このような懸念を念頭に置いて、Spring開発者はコンテナ外での統合テストの実行に使用できるいくつかのテストクラスを記述した。このテストクラスはJUnit APIの拡張であるため、Springテストクラスの使用時に細かい設定なしにすべてのJUnitの利点を得られる。これらのテストクラスは、各テストメソッドにトランザクションを設定し、自動的にクリーンアップ(各メソッドの完了時にトランザクションをロールバック)して、データベースの設定および分解タスクの必要性をなくす。

Springアプリケーションで統合テストを実行する場合に検証できる項目の一覧を次に示す。

  • Springコンテキストのロードおよび各テストケースの実行間にロードされたコンテキストをキャッシュすることによるコンテキストの管理。また、Spring IoCコンテナによるアプリケーションコンテキストの正確な記述を検証する。
  • テストフィクスチャの依存性の注入およびSpring設定の詳細(特定のデータアクセス(Repository)クラスの設定が正しくロードされたかどうかを検証する)
  • データアクセスおよびCRUD操作のコンビニエンス変数(データベース選択と更新をテストするためのデータアクセスクラスのロジック)。
  • トランザクション管理。
  • ORMマッピングファイルの設定(永続オブジェクトに関連するすべてが正しくマッピングされており、正確な遅延ローディングの意味論があるかどうかを検証する)。

統合テストはJUnitテストと同じように実行できる。クラスレベルではなく統合レベルでコードをテストするため、統合テストは単体テストと比べて実行に時間がかかる。しかし統合テストは、テスト実行前のコンテナへのアプリケーション配備に依存するJUnitEE(英語・サイト)やCactus(英語・サイト)などのコンテナ内テスティングフレームワークで作成されるテストよりも高速に実行される。

Springの統合テストクラスは、さまざまなテスト懸念に対処するように設計されており、org.springframework.test パッケージにさまざまなテストクラスが含まれている。次の表に、統合テストにおいてSpringフレームワークで提供されるテストクラスと、それらをどのようなシナリオで使用できるかについて示す。

表2. Springのテストクラス

テストクラス名 説明
AbstractDependencyInjection SpringContextTests このテストクラスはテスト依存性を注入するため、Springアプリケーションコンテキストのルックアップを特別に実行する必要がない。また、対応するオブジェクトgetConfigLocations()メソッドで指定されている設定ファイルのセットに自動的に配置する。
AbstractTransactionalDataSource SpringContextTests このテストクラスは、トランザクションの内部で実行するコードのテストに使用される。テストケースごとにトランザクションを作成およびロールバックする。我々は、トランザクションが存在していることを仮定してコードを記述する。このテストクラスは、テスト操作後のデータベース状態の検証、またはアプリケーションコードで実行されたクエリー結果の検証に使用できるJdbcTemplateなどのフィールドを提供する。ApplicationContextも継承され、必要に応じて明示的なルックアップに使用できる。
AbstractJpaTests このテストクラスはJPA機能のテストに使用される。JPAメソッドの呼び出しに使用できるEntityManagerインスタンスを提供する。
AbstractAspectjJpaTests このクラスはAbstractJpaTestsから拡張し、load-time weaving(LTW)の目的でAspectJを使用するために使われる。我々は、getActualAopXmlLocation()メソッドをオーバーライドしてAspectJのxml設定ファイルの場所を指定する。
AbstractModelAndViewTests これは、アプリケーションのプレゼンテーション層とコントローラ層(Spring MVCを使用)をテストするための便利なベースクラスである。

下の図1は、JUnit TestCaseクラスから拡張されるSpringフレームワークのテストクラスの、クラス階層図を示している。注意: これらのテストクラスは、spring-mock.jarファイル(Springフレームワークのインストールディレクトリ下のdistフォルダ内にある)の一部である。


図1. Springテストクラス階層(スクリーンショットをクリックすると拡大画像が開く。)

次に、どのテストクラスから拡張するかを決定する場合に考慮すべき要因の一覧を示す。

  • 初期化および管理のためのコードを記述せずにSpringアプリケーションコンテキストを使用する
  • データアクセスをテストする(データソースを使用)
  • トランザクションの内部のメソッドをテストする(transactionmanagerを使用)
  • JDKのバージョン: JDK 1.4を使用する場合、JDK 1.5で導入されたアノテーションを利用できない。

次のセクションで、これらのテストクラスについて詳細に説明する。

AbstractSpringContextTests:
これは、すべてのSpringテストクラスのベースクラスである。このクラスは、Springアプリケーションコンテキストをロードするための便利なメソッドを提供する。我々は、依存性の注入を明示的に管理する必要なくコンテキストのロードをテストする必要がある場合に、このクラスを拡張する。このクラスは、キーに基づいてコンテキストの静的キャッシュを維持する。これは、アプリケーションにいくつものロードするSpring bean(特に、HibernateのようなORMツールで作業するためのLocalSessionFactoryBeanなどのbean)が含まれる場合に非常に大きなパフォーマンスのメリットをもたらす。そのため、ほとんどの場合に、アプリケーションコンテキストを一度初期化してその後のルックアップのためにキャッシュすることが理にかなう。

AbstractSingleSpringContextTests:
これは、単一のApplicationContextをエクスポーズする抽象テストクラスである。コンテキストキーに基づいてアプリケーションコンテキストをキャッシュする。コンテキストキーは通常、Springリソース記述子を表すconfig locations(String[])である。このクラスは、Springコンテキストのロードと管理に関連するすべての関数をカプセル化する。 

AbstractDependencyInjectionSpringContextTests:
これは、Springアプリケーションコンテキストに依存するテストに便利なスーパークラスである。このクラスには、依存性の注入によって設定されたプロパティのテストでautowireモードを設定するためのsetAutowireMode()メソッドが含まれている。デフォルトはAUTOWIRE_BY_TYPEだが、AUTOWIRE_BY_NAMEまたはAUTOWIRE_NOに設定することもできる。

AbstractTransactionalSpringContextTests:
このクラスには、統合テストの実行時にトランザクション管理タスクを容易にするいくつもの便利なメソッドが含まれる。テストメソッドでトランザクションを管理するための、transactionManagertransactionDefinition、およびtransactionStatusトランザクション変数を提供する。また、トランザクションのコミットまたはロールバックを強制実行するendTransaction()というメソッドも含まれている。startNewTransaction()メソッドは新しいトランザクションを開始するために使用される。これは、endTransaction()の呼び出し後に呼び出される。

AbstractTransactionalDataSourceSpringContextTests:
このクラスはほとんどの場合に使用されるSpringテストクラスの1つである。これは、データベースでCRUD操作を実行するために使用できるJdbcTemplateなどの、継承され保護された有用なフィールドを提供する。また、独自のトランザクションで各テストメソッドを実行する。これは、デフォルトで自動的にロールバックされる。つまり、テストがデータベースの状態を変更しても(挿入、更新、削除)、データベースの変更が自動的にロールバックされるため、分解またはクリーンアップスクリプトが不要である。このクラス内の他のヘルパーメソッドとして、新しいレコードが追加されたかどうか、または既存のレコードが削除されたかどうかを確認するためのcountRowsInTable()という便利なメソッド、すべての行を表から削除するdeleteFromTables、所定のSQLスクリプトを実行するexecuteSqlScriptがある(データベース変更は現在のトランザクションの状態を基にロールバックされる)。

AbstractAnnotationAwareTransactionalTests: 
このテストクラスはSimpleJdbcTemplate変数をエクスポーズする。このクラスを使用すると、@Transactionalアノテーションを使ってトランザクション動作を制御できる。また、@NotTransactionalを使用して、作成されるすべてのトランザクションを回避することもできる(この2つはSpring固有のアノテーションであり、Springフレームワークへの依存性を作成することに注意する)。この機能は、JDK 1.5を使用している場合にのみ使用できる。

AbstractJpaTests:
これは、JPA APIで記述されたDAOクラスをテストしたい場合に拡張するベーステストクラスである。このクラスは、永続メソッドにEntityManagerFactoryおよび共有EntityManagerをエクスポーズする。DataSourceおよびJpaTransactionManagerの注入が必要である。

どのテストクラスを拡張するか決定した後、Springテストクラスを各自の統合テストに統合するための必要な手順を次に示す。

  • Springテストクラスの1つを拡張する(JPA機能を具体的にテストしたい場合は通常、AbstractTransactionalDataSourceSpringContextTests または AbstractJpaTests)。JPAはJava SE 5.0専用であるため、アプリケーションでJDK1.5より前のバージョンを使用している場合はこのクラスを拡張できないことに注意する。
  • getConfigLocations() メソッドをオーバーライドして、データソース、トランザクションマネージャ、およびアプリケーションコードで使用されるその他リソースのすべての設定ファイルをロードする。@Override アノテーションを使用して、スーパークラス(AbstractSingleSpringContextTests)に定義されたこのメソッドをオーバーライドすることを指定する。 
  • テストクラスで使用されるすべてのDAOオブジェクトのセッターメソッドを記述する(これらのDAOオブジェクトは、指定されたauto-wireオプションを使用してSpring IoCコンテナによって注入される)。
  • これらのDAOオブジェクト(データソースを順に使用)、トランザクションマネージャ、およびORM永続ヘルパーメソッドを使用してテストメソッドを追加する。

Rod Johnson氏のSpringを使用したシステムテスト(source)に関するプレゼンテーションは、単体/統合テストでSpring Test APIが提供するサポートについての優れた情報源である。

サンプルアプリケーション

この記事で使用するサンプルアプリケーションは、住宅ローン処理システムである。ユースケースは、システムで住宅ローンに対する資金調達を処理することである。借入申請は、住宅ローン融資会社に提出される際にまず、融資会社が顧客の収入の詳細、信用履歴、その他いくつかの要因に基づいて借入要求を承認または否定できる審査プロセスを通過する。借入申請が承認されると、次に権原移転(closing)および資金調達プロセスを通過する。

ローン処理アプリケーションの資金調達モジュールは、資金支払いのプロセスを自動化する。資金調達プロセスは通常、融資会社がローンパッケージを権原保険会社に転送することから始まる。権原保険会社はそのローンパッケージを調査してローン権原移転の日時をスケジューリングする。借り手と売り手は権原保険会社のclosing代理人と会う。

権原移転時、買い手(借り手)と売り手は、最終のローン書類を読んで署名する。借り手は、頭金とローンの権原移転に関わる手数料を支払う。また、権原移転費用とエスクロー手数料が権原移転プロセス時に支払われる。権原移転後、権原保険会社は資金調達する融資会社に署名済みの契約書を送付する。融資会社は権原保険会社に資金を送金する。

アプリケーションアーキテクチャ:

私は、サンプルアプリケーションの設計において一般的な階層化アーキテクチャに従った。これらの階層は、プレゼンテーション層、アプリケーション(コントローラ)層、サービス層、ドメイン層、およびデータアクセス層である。『Domain Driven Design』(source)の方針で推奨されている命名規則に従って、データアクセスクラス(DAO)をRepositoryとして命名した。サービス層、ドメイン層、およびデータアクセス層をプレーンなjavaクラスとして記述した。この記事ではプレゼンテーション層とアプリケーション層のクラスを取り上げていない。

LoanApp Webアプリケーションのアーキテクチャ図を下の図2に示す。


図2. LoanAppアプリケーションのアーキテクチャ図(スクリーンショットをクリックすると拡大画像が開く。)

データモデル:
HSQL(サイト・英語) データベースを使用してloanappアプリケーションのローンアプリケーションデータベース(LoanDB)を作成した。デモ目的のため、LOANDETAILSFUNDINGDETAILS、およびBORROWERDETAILSという3つのテーブルを含む単純なデータモデルにした。

ドメインモデル:
モデル内には、ローン処理システムの資金モジュールで必要なビジネスエンティティを取得するための、LoanDetailsBorrowerDetails、およびFundingDetailsという3つのドメインオブジェクトがある。

注意: サンプルアプリケーションで使用されるモデルは、単にデモンストレーションを目的としたものである。実際のアプリケーションドメインモデルは、ここで示すものよりも複雑である。

私は各ドメインクラスのデータアクセスクラスを記述した。DAOクラスは、LoanDetailsRepositoryJpaImplBorrowerDetailsRepositoryJpaImpl、およびFundingDetailsRepositoryJpaImplである。そして、資金リクエストの処理を行うロジックをカプセル化するFundingServiceImplというサービスクラスがある。これは、指定されたローンを承認、否定、または無効にするために、DAOを呼び出す。

永続: 
Webアプリケーションのオブジェクト関係マッピング(ORM)要件の永続モデルとしてJava Persistence API(サイト・英語)(JPA)を使用した。Springは、HibernateJDOiBATISTopLinkJPAなどの主要な永続フレームワークすべてをサポートする。Spring 2.0リリースの一部であるSpring JPAは、org.springframework.orm.jpaパッケージ内にJPAヘルパークラスを含む。私は、永続性の懸念のためにEntityManagerオプションを(JPA Templateの代わりに)使用した。この手法はSpringへの依存がなく、Springアプリケーションコンテキストを使用して管理できる。また、アノテーションを利用して、@PersistenceContextおよび@PersistenceUnitタグでEntityManagerを注入できる。

次の表に、サンプルアプリケーションで使用されるフレームワークとテクノロジーを示す。

表3. サンプルアプリケーションで使用されるテクノロジー

テクノロジー バージョン
コントローラ Spring MVC 2.0
サービス Spring 2.0
ドメイン Plain Java Classes  
永続 JPA  
データベース HSQLDB server 1.8.0
Java Java SE 6.0
ビルド Ant 1.7
IDE Eclipse 3.3

The other tools I used in the sample application are Metrics and FindBugs for static code analyses, EclEmma for code coverage.

サンプルアプリケーションで使用したその他のツールは、静的コード分析用のMetrics(source)およびFindBugs(source)と、コードカバレッジ用のEclEmma(source)である。

テスト

以下で、テストの記述および実行における目的について簡単に繰り返す。

  • IDE(Eclipse)を離れずにテストをコード化および実行したい
  • コードの特別な配備が必要ない
  • バグをすぐに発見してその問題を修正できるように、IDE内から正しくMetricsやFindBugsなどの他のコード分析ツールを利用できる必要がある。

アプリケーション内の各Repositoryクラス(すなわちLoanDetailsRepositoryJpaImplBorrowerDetailsRepositoryJpaImpl、およびFundingDetailsRepositoryJpaImpl)に対する従来の単体テスト(JUnitを使用)に加え、FundingServiceImplクラスメソッドを検証するために統合テストも記述した。

次の表に、メインクラスおよび対応するテストクラスの一覧を示す。

表4: ローンアプリケーションにおけるテストケースの一覧

アプリケーション層 メインクラス テストクラス
データアクセス LoanDetailsRepositoryImpl LoanDetailsRepositoryJpaImplTest,
LoanDetailsRepositoryJpaImplSpringDITest
データアクセス BorrowerDetailsRepository BorrowerDetailsRepositoryJpaImplTest
データアクセス FundingDetailsRepositoryImpl FundingDetailsRepositoryJpaImplTest
サービス FundingServiceImpl FundingServiceImplIntegrationTest,
FundingServiceImplSpringDITest,
FundingServiceImplSpringJPATest

Springの統合テストサポートを比較するために、まずSpringテストクラス(FundingServiceImplTest)を使用せずに資金調達サービス統合テストを記述した。次に、FundingServiceImplクラスでロジックをテストするために他の2つのクラス(FundingServiceImplSpringDITestおよびFundingServiceImplSpringJpaTest)を記述した。ただしこの時はSpringテストを使用した。そして、データベースクエリーの実行を支援する複数のヘルパー変数とメソッドを使用した。jdbcTemplatesimpleJdbcTemplatesharedEntityManager変数とcountRowsInTable()deleteFromTables()endTransaction()メソッドである。

こうした単体/統合テストクラスのコード例に注目し、開発者が実際のビジネスロジックの確立に集中できるようにいくつのボイラープレートテストのタスクがSpring Testing APIによって自動化されるかを確認しよう。

まずは、SpringのXML設定ファイルに注目してみよう。このファイルには、サンプルアプリケーションで使用されるレポジトリ(DAO)クラスのSpring bean定義が含まれている。リスト1は、loanApplicationContext-jpa.xml設定ファイルのコードを示している。

リスト1. LoanApp Spring設定の詳細

 <?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.0.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx-2.0.xsd">
<!--
! Load JDBC Properties
!-->
<bean id="propertyConfigurer" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="location" value="classpath:jdbc.properties"/>
</bean>

<!--
! Service classes
!-->
<bean id="fundingService" class="com.ideature.agiletestingspring.loanapp.service.FundingServiceImpl" >
<property name="loanDetailsRepository" ref="loanDetailsRepository"/>
<property name="borrowerDetailsRepository" ref="borrowerDetailsRepository"/>
<property name="fundingDetailsRepository" ref="fundingDetailsRepository"/>
</bean>

<!--
! Repository classes
!-->
<bean id="loanDetailsRepository" class="com.ideature.agiletestingspring.loanapp.repository.LoanDetailsRepositoryJpaImpl" />
<bean id="borrowerDetailsRepository" class="com.ideature.agiletestingspring.loanapp.repository.BorrowerDetailsRepositoryJpaImpl" />
<bean id="fundingDetailsRepository" class="com.ideature.agiletestingspring.loanapp.repository.FundingDetailsRepositoryJpaImpl" />

<!--
! Configure the JDBC datasource. Use the in-container datasource
! (retrieved via JNDI) in the production environment.
!-->
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="${jdbc.driverClassName}"/>
<property name="url" value="${jdbc.url}"/>
<property name="username" value="${jdbc.username}"/>
<property name="password" value="${jdbc.password}"/>
</bean>

<!--
! Configure the entity manager.
!-->
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">

<property name="persistenceUnitName" value="LoanDBSpring"/>

<property name="dataSource" ref="dataSource"/>
<property name="loadTimeWeaver">
<!-- InstrumentationLoadTimeWeaver expects you to start the appserver with
-javaagent:/Java/workspace2/spring/dist/weavers/spring-agent.jar
-->
<bean class="org.springframework.instrument.classloading.InstrumentationLoadTimeWeaver"/>
</property>

<!--
! JPA Adapter
!-->
<property name="jpaVendorAdapter">
<bean class="org.springframework.orm.jpa.vendor.TopLinkJpaVendorAdapter">
<property name="databasePlatform"
value="oracle.toplink.essentials.platform.database.HSQLPlatform"/>
<property name="generateDdl" value="false"/>
<property name="showSql" value="true" />
</bean>
<!--
<bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter">
<property name="databasePlatform" value="org.hibernate.dialect.HSQLDialect" />
<property name="generateDdl" value="true" />
<property name="showSql" value="true" />
</bean>
-->

</property>
</bean>
<!--
! Transaction manager for EntityManagerFactory.
!-->
<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
<property name="entityManagerFactory" ref="entityManagerFactory"/>
<property name="dataSource" ref="dataSource"/> </bean>

<!--
! Use Spring's declarative @Transaction management !-->
<tx:annotation-driven/>

<!--
! Configure to make Spring perform persistence injection using
! @PersistenceContext/@PersitenceUnit annotations
!-->
<bean class="org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor"/>

</beans>

サンプルアプリケーション内のすべてのテストクラスの拡張元となる2つのベースクラスを記述した。 BaseDataSourceSpringContextIntegrationTest および BaseJpaIntegrationTest である。

BaseDataSourceSpringContextIntegrationTest:
これは、データアクセスとSpringコンテキストロード機能をテストするためのベーステストクラスである。このテストクラスは、SpringのAbstractTransactionalDataSourceSpringContextTestsクラスを拡張する。そして、getConfigLocations()メソッドを呼び出すことでアプリケーションコンテキストをロードする。抽象テストクラスのソースコードをリスト2に示す。

リスト2. BaseDataSourceSpringContextIntegrationTestベーステストクラス

package com.ideature.agiletestingspring.loanapp;

import org.springframework.test.AbstractTransactionalDataSourceSpringContextTests;
public abstract class BaseDataSourceSpringContextIntegrationTest extends AbstractTransactionalDataSourceSpringContextTests {
private static final String[] configFiles = new String[]{"loanapp-applicationContext-jpa.xml"};

@Override
protected String[] getConfigLocations() {
return configFiles;
}
}

BaseJpaIntegrationTest:
これは、JPAを使用してORM機能をテストするために作成されたすべての統合テストのベースクラスである。このテストクラスは、Springの AbstractJpaTests クラスを拡張する。次のリスト3は、BaseJpaIntegrationTestクラスのコードを示している。.

リスト3. BaseJpaIntegrationTestテストクラス

package com.ideature.agiletestingspring.loanapp;

import org.springframework.test.jpa.AbstractJpaTests;

public class BaseJpaIntegrationTest extends AbstractJpaTests {
private static final String[] configFiles = new String[]{"loanapp-applicationContext-jpa.xml"};

@Override
protected String[] getConfigLocations() {
return configFiles;
}
}

LoanAppアプリケーションの他のテストクラスの詳細は次のとおりである。

LoanDetailsRepositoryJpaImplTest: 
これは、LoanDetailsRepositoryJpaImplクラスでCRUDロジックをテストするためのごく普通のレポジトリ単体テストクラスである。このテストクラスはSpringアプリケーションコンテキストを明示的に初期化し、コンテキストからloanDetailsRepositoryを取り出し、その後レポジトリクラス内のCRUDメソッドを呼び出す。また、deleteメソッドを呼び出して、LOANDETAILSテーブルに追加された新しいレコードを削除する。このテストクラスには、テストメソッドで使用されるリソースを初期化およびクリーンアップするsetUp()tearDown()メソッドも含まれる。

LoanDetailsRepositoryJpaImplSpringDITest:
このテストクラスはLoanDetailsRepositoryJpaImplTestと似ているが、これはSpringテストクラスを使用してLoanDetailsRepositoryクラスのデータアクセスメソッドのテストを非常に簡単にする。このテストクラスはBaseDataSourceSpringContextIntegrationTestを拡張する。setLoanDetailsRepository()のセッターメソッドが含まれているため、SpringのIoCコンテナはランタイム時にレポジトリインターフェースの適切な実装を注入する。アプリケーションコンテキスト、setUp()およびtearDown()メソッドの初期化などのボイラープレートコードはない。また、各テストメソッドの終了時にデータベースの変更すべてが自動的にロールバックされるため、deleteメソッドを呼び出す必要がない。AUTOWIRE_BY_TYPE(デフォルトオプション)を使用して、setLoanDetailsRepository()メソッドでLoanDetailsRepositoryをauto-wire(自動的に結合)する。

FundingServiceImplIntegrationTest: 
これは、FundingServiceImplクラスのテストクラスである。Springテストクラスを利用しなかった場合にいくつのコードを記述する必要があるかを示す。リスト4は、この統合テストクラスのコードを示している。

リスト4. FundingServiceImplIntegrationTestのサンプルコード

package com.ideature.agiletestingspring.loanapp.service;

import static org.junit.Assert.assertEquals;

import java.util.Collection;
import java.util.Date;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import com.ideature.agiletestingspring.loanapp.LoanAppConstants;
import com.ideature.agiletestingspring.loanapp.LoanAppException;
import com.ideature.agiletestingspring.loanapp.domain.BorrowerDetails;
import com.ideature.agiletestingspring.loanapp.domain.FundingDetails;
import com.ideature.agiletestingspring.loanapp.domain.LoanDetails;
import com.ideature.agiletestingspring.loanapp.dto.FundingDTO;
import com.ideature.agiletestingspring.loanapp.repository.BorrowerDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.FundingDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.LoanDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.RepositoryException;

public class FundingServiceImplIntegrationTest {
private static final Log log = LogFactory.getLog(FundingServiceImplIntegrationTest.class);

private static final String[] configFiles = new String[] {
"loanapp-applicationContext-jpa.xml"};

private ApplicationContext ctx = null;

private LoanDetailsRepository loanDetailsRepository = null;
private BorrowerDetailsRepository borrowerDetailsRepository = null;
private FundingDetailsRepository fundingDetailsRepository = null;
private FundingService fundingService;

@Before
public void setUp() {
ctx = new ClassPathXmlApplicationContext(configFiles);
log.debug("ctx: "+ctx);
loanDetailsRepository = (LoanDetailsRepository)ctx.getBean("loanDetailsRepository");
borrowerDetailsRepository = (BorrowerDetailsRepository)ctx.getBean("borrowerDetailsRepository");
fundingDetailsRepository = (FundingDetailsRepository)ctx.getBean("fundingDetailsRepository");
log.debug("loanDetailsRepository: "+loanDetailsRepository);

fundingService = (FundingService)ctx.getBean("fundingService");
log.debug("fundingService: " + fundingService);
}

@After
public void tearDown() {
fundingService = null;
loanDetailsRepository = null;
borrowerDetailsRepository = null;
fundingDetailsRepository = null;
ctx = null;
log.debug("ctx set null.");
}

@Test
public void testLoanFunding() {

// -------------------------------------------
// Set LOAN details
// -------------------------------------------
long loanId = 100;
LoanDetails loanDetails = new LoanDetails();
loanDetails.setLoanId(loanId);
loanDetails.setLoanAmount(450000);
loanDetails.setLoanStatus("REQUESTED");
loanDetails.setProductGroup("FIXED");
loanDetails.setProductId(1234);
loanDetails.setPurchasePrice(500000);

// -------------------------------------------
// Set BORROWER details
// -------------------------------------------
BorrowerDetails borrowerDetails = new BorrowerDetails();
long borrowerId = 131;
borrowerDetails.setBorrowerId(borrowerId);
borrowerDetails.setFirstName("BOB");
borrowerDetails.setLastName("SMITH");
borrowerDetails.setPhoneNumber("123-456-7890");
borrowerDetails.setEmailAddress("test.borr@abc.com");
borrowerDetails.setLoanId(loanId);

// -------------------------------------------
// Set FUNDING details
// -------------------------------------------
long fundingTxnId = 300;
FundingDetails fundingDetails = new FundingDetails();
fundingDetails.setFundingTxnId(fundingTxnId);
fundingDetails.setLoanId(loanId);
fundingDetails.setFirstPaymentDate(new Date());

fundingDetails.setFundType(LoanAppConstants.FUND_TYPE_WIRE);
fundingDetails.setLoanAmount(450000);
fundingDetails.setMonthlyPayment(2500);
fundingDetails.setTermInMonths(360);

// Populate the DTO object
FundingDTO fundingDTO = new FundingDTO();
fundingDTO.setLoanDetails(loanDetails);
fundingDTO.setBorrowerDetails(borrowerDetails);
fundingDTO.setFundingDetails(fundingDetails);

try {
Collection loans = loanDetailsRepository.getLoans();
log.debug("loans: " + loans.size());
// At this time, there shouldn't be any loan records
assertEquals(0, loans.size());

Collection borrowers = borrowerDetailsRepository.getBorrowers();
log.debug("borrowers: " + borrowers.size());
// There shouldn't be any borrower records either
assertEquals(0, borrowers.size());

Collection fundingDetailsList = fundingDetailsRepository.getFundingDetails();
log.debug("FundingDetails: " + fundingDetailsList.size());
// There shouldn't be any fundingDetails records
assertEquals(0, fundingDetailsList.size());

// Call service method now
fundingService.processLoanFunding(fundingDTO);

// Assert that the new record has been saved to the DB.
loans = loanDetailsRepository.getLoans();
log.debug("After adding a new record - loans 2: " + loans.size());
// Now, there should be one loan record
assertEquals(1, loans.size());

borrowers = borrowerDetailsRepository.getBorrowers();
log.debug("After adding a new record - borrowers2: " + borrowers.size());
// Same with borrower record
assertEquals(1, borrowers.size());

fundingDetailsList = fundingDetailsRepository.getFundingDetails();
log.debug("After adding a new record - # of records: " + fundingDetailsList.size());
// Same with funding details record
assertEquals(1, fundingDetailsList.size());

// Now, delete the newly added records

// Delete the funding details record
fundingDetailsRepository.deleteFundingDetails(fundingTxnId);

// Delete the borrower details record
borrowerDetailsRepository.deleteBorrower(borrowerId);

// Delete loan details record last
loanDetailsRepository.deleteLoanDetails(loanId);

} catch (RepositoryException re) {
log.error("RepositoryException in testLoanFunding() method.", re);
} catch (LoanAppException lae) {
log.error("LoanAppException in testLoanFunding() method.", lae);
}
}
}

testLoanFunding() メソッドで分かるように、データベース状態をこのテストの実行前と同じに保つために、FundingDetailsRepository クラスのdeleteメソッドを明示的に呼び出す必要がある。

FundingServiceImplSpringDITest:
このクラスは、BaseDataSourceSpringContextIntegrationTestベースクラスを拡張する。このテストクラスにはRepositoryオブジェクトのセッターメソッドが含まれているため、これらはアプリケーションコンテキストのロード時にSpring DIコンテナによって注入される。この統合テストクラスのソースコードをリスト5に示す。

リスト5. FundingServiceImplSpringDITestテストクラス

package com.ideature.agiletestingspring.loanapp.service;

import java.util.Collection;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.internal.runners.TestClassRunner;
import org.junit.runner.RunWith;

import com.ideature.agiletestingspring.loanapp.BaseDataSourceSpringContextIntegrationTest;
import com.ideature.agiletestingspring.loanapp.LoanAppConstants;
import com.ideature.agiletestingspring.loanapp.domain.LoanDetails;
import com.ideature.agiletestingspring.loanapp.repository.LoanDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.RepositoryException;

@RunWith(TestClassRunner.class)
public class FundingServiceImplSpringDITest extends BaseDataSourceSpringContextIntegrationTest {

private static final Log log = LogFactory.getLog(FundingServiceImplSpringDITest.class);

private LoanDetailsRepository loanDetailsRepository = null;

public void setLoanDetailsRepository(LoanDetailsRepository loanDetailsRepository) {
this.loanDetailsRepository = loanDetailsRepository;
}

@Before
public void initialize() throws Exception {
super.setUp();
}

@After
public void cleanup() throws Exception {
super.tearDown();
}

@Test
public void testFindLoans() throws RepositoryException {
// First delete all the records from LoanDetails table
// by calling deleteFromTables() helper method.
deleteFromTables(new String[]{"LoanDetails"});
Collection loans = loanDetailsRepository.getLoans();
assertEquals(0, loans.size());
}

@Test
public void testJdbcQueryUsingJdbcTemplate() {
// Use jdbcTemplate to get the loan count
int rowCount = jdbcTemplate.queryForInt("SELECT COUNT(0) from LoanDetails");
assertEquals(rowCount,0);
}

@Test
public void testLoadLoanDetails() throws RepositoryException {
int rowCount = countRowsInTable("LOANDETAILS");
log.info("rowCount: " + rowCount);

long loanId = 100;
double loanAmount = 450000.0;
String loanStatus = LoanAppConstants.STATUS_REQUESTED;
String productGroup = "FIXED";
long productId = 1234;
double purchasePrice = 500000.0;

// Add a new record
LoanDetails newLoan = new LoanDetails();
newLoan.setLoanId(loanId);
newLoan.setLoanAmount(loanAmount);
newLoan.setLoanStatus(loanStatus);
newLoan.setProductGroup(productGroup);
newLoan.setProductId(productId);
newLoan.setPurchasePrice(purchasePrice);

// Insert a new record using jdbcTemplate helper attribute
jdbcTemplate.update("insert into LoanDetails (LoanId,ProductGroup,ProductId,LoanAmount,PurchasePrice," +
"PropertyAddress,LoanStatus) values (?,?,?,?,?,?,?)",
new Object[] { new Long(newLoan.getLoanId()),newLoan.getProductGroup(),new Long(newLoan.getProductId()),
new Double(newLoan.getLoanAmount()), new Double(newLoan.getPurchasePrice()),"123 MAIN STREET","IN REVIEW" });

// Explicitly end the transaction so the new record will be
// saved in the database table.
endTransaction();

// Start a new transaction to get a different unit of work (UOW)
startNewTransaction();

rowCount = countRowsInTable("LOANDETAILS");
log.info("rowCount: " + rowCount);

LoanDetails loanDetails1 = loanDetailsRepository.loadLoanDetails(loanId);
// We should get a null as the return value.
assertNull(loanDetails1);
}

@Test
public void testInsertLoanDetails() throws RepositoryException {
int loanCount = 0;
Collection loans = loanDetailsRepository.getLoans();

loanCount = loans.size();
assertTrue(loanCount==0);

long loanId = 200;

LoanDetails loanDetails = loanDetailsRepository.loadLoanDetails(loanId);
assertNull(loanDetails);

double loanAmount = 600000.0;
String loanStatus = LoanAppConstants.STATUS_IN_REVIEW;
String productGroup = "ARM";
long productId = 2345;
double purchasePrice = 700000.0;

// Add a new record
LoanDetails newLoan = new LoanDetails();
newLoan.setLoanId(loanId);
newLoan.setLoanAmount(loanAmount);
newLoan.setLoanStatus(loanStatus);
newLoan.setProductGroup(productGroup);
newLoan.setProductId(productId);
newLoan.setPurchasePrice(purchasePrice);

loanDetailsRepository.insertLoanDetails(newLoan);

loans = loanDetailsRepository.getLoans();
log.info("loans.size(): " + loans.size());
System.out.println("loans.size(): " + loans.size());
assertEquals(loanCount + 1, loans.size());
}

@Test
public void testUpdateLoanDetails() throws Exception {
// First, insert a new record
long loanId = 100;
double loanAmount = 450000.0;
String oldStatus = LoanAppConstants.STATUS_FUNDING_COMPLETE;
String productGroup = "FIXED";
long productId = 1234;
double purchasePrice = 500000.0;
String propertyAddress = "123 MAIN STREET";

// Add a new record
LoanDetails newLoan = new LoanDetails();
newLoan.setLoanId(loanId);
newLoan.setLoanAmount(loanAmount);
newLoan.setLoanStatus(oldStatus);
newLoan.setProductGroup(productGroup); newLoan.setProductId(productId);
newLoan.setPurchasePrice(purchasePrice);
newLoan.setPropertyAddress(propertyAddress);

// Insert a new record using jdbcTemplate helper attribute
jdbcTemplate.update("insert into LoanDetails (LoanId,ProductGroup,ProductId,LoanAmount,PurchasePrice," +
"PropertyAddress,LoanStatus) values (?,?,?,?,?,?,?)",
new Object[] { new Long(newLoan.getLoanId()),newLoan.getProductGroup(),new Long(newLoan.getProductId()),
new Double(newLoan.getLoanAmount()), new Double(newLoan.getPurchasePrice()),newLoan.getPropertyAddress(),
newLoan.getLoanStatus() });

LoanDetails loanDetails1 = loanDetailsRepository.loadLoanDetails(loanId);
String status = loanDetails1.getLoanStatus();
assertEquals(status, oldStatus);

String newStatus = LoanAppConstants.STATUS_FUNDING_DENIED;

// Update status field
loanDetails1.setLoanStatus(newStatus);
loanDetailsRepository.updateLoanDetails(loanDetails1);
status = loanDetails1.getLoanStatus();
assertEquals(status, newStatus);
}
}

ヘルパーメソッドのdeleteFromTables()は、このクラスでFUNDINGDETAILSテーブルからデータを削除するために使用される。このメソッドはSpringテストスーパークラスから利用可能である。また、指定のテーブルから行数を得るために、あるケースではjdbcTemplate変数を、別のインスタンスではcountRowsInTable()を使用した。

FundingServiceImplSpringJpaTest:
このクラスはBaseJpaIntegrationTestベースクラスを拡張して、スーパークラスが提供する便利なメソッドを利用する。FUNDINGDETAILSテーブルから行数を得るために、simpleJdbcTemplateヘルパー変数を使用する。また、 sharedEntityManager属性のcreateQuery()メソッドを使用して無効なクエリーに対してテストを実行する。リスト6は、FundingServiceImplSpringJpaTestクラスのソースを示している。

リスト6. FundingServiceImplSpringJpaTestクラス

package com.ideature.agiletestingspring.loanapp.service;

import java.util.Collection;
import java.util.Date;

import javax.persistence.EntityManager;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.internal.runners.TestClassRunner;
import org.junit.runner.RunWith;
import org.springframework.test.annotation.ExpectedException;

import com.ideature.agiletestingspring.loanapp.BaseJpaIntegrationTest;
import com.ideature.agiletestingspring.loanapp.LoanAppConstants;
import com.ideature.agiletestingspring.loanapp.domain.BorrowerDetails;
import com.ideature.agiletestingspring.loanapp.domain.FundingDetails;
import com.ideature.agiletestingspring.loanapp.domain.LoanDetails;
import com.ideature.agiletestingspring.loanapp.repository.BorrowerDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.FundingDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.LoanDetailsRepository;
import com.ideature.agiletestingspring.loanapp.repository.RepositoryException;

@RunWith(TestClassRunner.class)
public class FundingServiceImplSpringJpaTest extends BaseJpaIntegrationTest {

private static final Log log = LogFactory.getLog(FundingServiceImplSpringDITest.class);

private LoanDetailsRepository loanDetailsRepository = null;
private BorrowerDetailsRepository borrowerDetailsRepository = null;
private FundingDetailsRepository fundingDetailsRepository = null;

public void setLoanDetailsRepository(LoanDetailsRepository loanDetailsRepository) {
this.loanDetailsRepository = loanDetailsRepository;
}

public void setBorrowerDetailsRepository(BorrowerDetailsRepository borrowerDetailsRepository) {
this.borrowerDetailsRepository = borrowerDetailsRepository;
}

public void setFundingDetailsRepository(FundingDetailsRepository fundingDetailsRepository) {
this.fundingDetailsRepository = fundingDetailsRepository;
}

@Before
public void initialize() throws Exception {
super.setUp();
}

@After
public void cleanup() throws Exception {
super.tearDown();
}

@Test
@ExpectedException(IllegalArgumentException.class)
public void testInvalidQuery() {
sharedEntityManager.createQuery("select test FROM TestTable test").executeUpdate();
}

@Test
public void testApplicationManaged() {
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.joinTransaction();
}

@Test
public void testJdbcQueryUsingSimpleJdbcTemplate() {
// Use simpleJdbcTemplate to get the loan count
int rowCount = simpleJdbcTemplate.queryForInt("SELECT COUNT(*) from LoanDetails");
assertEquals(rowCount,0);
}

@Test
public void testInsertLoanDetails() throws RepositoryException {
int loanCount = 0;
Collection loans = loanDetailsRepository.getLoans();
loanCount = loans.size();
assertTrue(loanCount==0);

long loanId = 200;
LoanDetails loanDetails = loanDetailsRepository.loadLoanDetails(loanId);
assertNull(loanDetails);

double loanAmount = 600000.0;
String loanStatus = LoanAppConstants.STATUS_IN_REVIEW;
String productGroup = "ARM";
long productId = 2345;
double purchasePrice = 700000.0;

// Add a new record
LoanDetails newLoan = new LoanDetails();
newLoan.setLoanId(loanId);
newLoan.setLoanAmount(loanAmount);
newLoan.setLoanStatus(loanStatus);
newLoan.setProductGroup(productGroup);
newLoan.setProductId(productId);
newLoan.setPurchasePrice(purchasePrice);

loanDetailsRepository.insertLoanDetails(newLoan);

loans = loanDetailsRepository.getLoans();
assertEquals(loanCount + 1, loans.size());
}

@Test
public void testLoanFunding() throws RepositoryException {

long loanId = 100;
// -------------------------------------------
// Insert LOAN details
// -------------------------------------------
Collection loans = loanDetailsRepository.getLoans();
log.debug("loans: " + loans.size());

// Add a new record
LoanDetails newLoan = new LoanDetails();
newLoan.setLoanId(loanId);
newLoan.setLoanAmount(450000);
newLoan.setLoanStatus("REQUESTED");
newLoan.setProductGroup("FIXED");
newLoan.setProductId(1234);
newLoan.setPurchasePrice(500000);

loanDetailsRepository.insertLoanDetails(newLoan);

loans = loanDetailsRepository.getLoans();
log.debug("After adding a new record - loans 2: " + loans.size());

// -------------------------------------------
// Insert BORROWER details
// -------------------------------------------
long borrowerId = 131;
Collection borrowers = borrowerDetailsRepository.getBorrowers();
log.debug("borrowers: " + borrowers.size());

// Add a new Borrower
BorrowerDetails newBorr = new BorrowerDetails();
newBorr.setBorrowerId(borrowerId);
newBorr.setFirstName("BOB");
newBorr.setLastName("SMITH");
newBorr.setPhoneNumber("123-456-7890");
newBorr.setEmailAddress("test.borr@abc.com");
newBorr.setLoanId(loanId);

borrowerDetailsRepository.insertBorrower(newBorr);

borrowers = borrowerDetailsRepository.getBorrowers();
log.debug("After adding a new record - borrowers2: " + borrowers.size());

// -------------------------------------------
// Insert FUNDING details
// -------------------------------------------
long fundingTxnId = 300;

Collection fundingDetailsList = fundingDetailsRepository.getFundingDetails();
log.debug("FundingDetails: " + fundingDetailsList.size());

// Add a new record
FundingDetails newFundingDetails = new FundingDetails();
newFundingDetails.setFundingTxnId(fundingTxnId);
newFundingDetails.setLoanId(loanId);
newFundingDetails.setFirstPaymentDate(new Date());
newFundingDetails.setFundType(LoanAppConstants.FUND_TYPE_WIRE);
newFundingDetails.setLoanAmount(450000);
newFundingDetails.setMonthlyPayment(2500);
newFundingDetails.setTermInMonths(360);

fundingDetailsRepository.insertFundingDetails(newFundingDetails);

fundingDetailsList = fundingDetailsRepository.getFundingDetails();
log.debug("After adding a new record - # of records: " + fundingDetailsList.size());

// Delete the borrower details record
borrowerDetailsRepository.deleteBorrower(borrowerId);

// Delete the funding details record
fundingDetailsRepository.deleteFundingDetails(fundingTxnId);

// Delete loan details record last
loanDetailsRepository.deleteLoanDetails(loanId); }
}

トランザクションはtestInsertLoanDetails()メソッドの終了時にロールバックされる。これが、insertLoanDetailsメソッドを呼び出してローンレコードを挿入しても、トランザクションのロールバック時にデータベース挿入が行われない理由である。したがって、統合テストの実行後またはテスト時に作成されたテストデータを削除する特別なデータベースクリーンアップスクリプトを実行した後、テーブル内にテストデータが残る心配がない。

トランザクション状態をテストするため、testLoadLoanDetails()メソッドはトランザクションマネージャヘルパーメソッドのendTransaction()およびstartNewTransaction()を呼び出し、それぞれ現在のトランザクションをコミットして新しいトランザクションを取得する。新しいトランザクションは、LoanDetailsドメインオブジェクトがシステムに存在しない作業単位(UOW; Unit Of Work)を開始する。これらのヘルパーメソッドは、HibernateTopLinkOpenJPAなどのORMツールの遅延ローディング機能をテストする場合に役立つ。注意: これらのヘルパーメソッドは、Java 5より前のアプリケーションでも機能する。

AllIntegationTests: 
最後に、1度にすべてのFunding Service関連のテストを実行するAllIntegationTestsテストスイートがある。リスト7は、このテストスイートクラスのソースを示している。

リスト7. FundingServiceImplSpringJpaTestクラス

package com.ideature.agiletestingspring.loanapp;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

import com.ideature.agiletestingspring.loanapp.service.FundingServiceImplIntegrationTest;
import com.ideature.agiletestingspring.loanapp.service.FundingServiceImplSpringDITest;
import com.ideature.agiletestingspring.loanapp.service.FundingServiceImplSpringJpaTest;
import com.ideature.agiletestingspring.loanapp.service.FundingServiceImplTest;

@RunWith(Suite.class)
@SuiteClasses(value = {
FundingServiceImplTest.class,
FundingServiceImplIntegrationTest.class,
FundingServiceImplSpringDITest.class,
FundingServiceImplSpringJpaTest.class
})
public class AllIntegrationTests {
}

これらのタスクを実行するには、設定ファイル(loanapp-applicationContext-jpa.xml)の場所がtest classpathに指定されていることを確かめる。Log4Jロギングを有効にすると、Spring beanがアプリケーションコンテキストによってロードされることを確認できる。エンティティマネージャ、データソース、トランザクションマネージャ、および統合テストの実行に必要なその他オブジェクトのロードを示すCachedIntrospectionResults DEBUGメッセージを探す。

結論

Springは、Java EE開発者に、コンテナ外で単体/統合テストの両方を記述および実行するための容易かつ強力なフレームワークを提供することで、テスト駆動のJ2EEアプリケーション開発を現実のものにする。その非侵襲的な設定管理、モックオブジェクトを使用するための依存性の注入、およびスタブしにくいAPIの一貫した抽象化は、コンテナ外での単体テストを容易にする。そのテストモジュールは、依存性の注入(DI; Dependency Injection)とアスペクト指向プログラミング(AOP; Aspect Oriented Programming)手法を使用して、単体/統合テストを構築できる基盤を作成する。

Springテストクラスを使用してテストを記述するベストプラクティスのいくつかを次に示す。

  • 実稼働環境へのアプリケーションの配備時に問題を発生させる恐れのある相違について心配する必要がないように、配備環境と同じSpring設定ファイルを統合テストで使用する。
  • Springフレームワークを使用する場合、データベース接続ポーリングとトランザクション基盤に関連したいくつかの相違を覚えておく必要がある。万全のアプリケーションサーバーに配備する場合は、おそらくその接続プール(JNDIを通じて利用可能)とJTA実装を使用するだろう。そのため実稼働環境では、DataSource用のJndiObjectFactoryBean、およびJtaTransactionManagerを使用することになる。JNDIおよびJTAはコンテナ外統合テストでは利用できないため、こうしたテストにはCommons DBCPのBasicDataSourceおよびDataSourceTransactionManagerまたはHibernateTransactionManagerを使用する。
  • Springの統合テストサポートは実際の回帰テストの代わりではない。回帰テストは、実稼働環境に配備されたときにエンドユーザーがアプリケーションをどのように使用するかわかるような形でアプリケーション機能をテストする。

プロジェクトの開始直後からテストについて考えて計画し、QAチームを関与させることが必要である。また、できるだけ多くのシナリオとメインクラスのパス(例外パスを含む)を網羅するように単体テストを記述する必要がある。テスト駆動開発(TDD; Test Driven Development)は、プロジェクトで記述するコードの必要なテストカバレッジとプロダクション品質を達成する素晴らしい方法である。このTDDをプロジェクトで開始できない場合は、少なくとも他のTDD(Test During Development)を試して、統合環境(通常はTest環境)に配備される前にコードの単体テストが実施されるようにすることが必要である。

リソース

  • Springテストのドキュメンテーション(source)
  • 『Spring in Action』第2版(source)、Craig WallsおよびRyan Breidenbach著、Manning Publications出版
  • Springを使用したシステム統合テスト(source)、Rod Johnson、Spring Experience 2006
  • Java Persistence API (サイト・英語)
  • Spring JPAのドキュメンテーション(source)

原文はこちらです:http://www.infoq.com/articles/testing-in-spring
このArticleは2007年11月12日に原文が掲載されました)

この記事に星をつける

おすすめ度
スタイル

BT