DevelopingJdbcApplicationsTestFirst
注: 2008年12月:この文章はレガシー化しています(リンクもほぼ死んでいることに注意)。
本ページは、http://mockobjects.com/wiki/DevelopingJdbcApplicationsTestFirstを勝手に翻訳したものです(2004/2/20にフリーマンに了解を得たとは言え向こうは日本語は読めないので翻訳の正当性が保証されるわけではない)。公開物に対するリンクと勝手に翻訳は一見似ていますが、そこには大きな差があります。それは翻訳が正しいかどうかの保証がないっていうことです。したがって、この翻訳を鵜呑みにせずに、原文も参照されることを勧めます。 なお、ここでは強い意志をもってできるだけ意訳するようにしています。すなわち、僕にとっては日本語の文章記述練習を兼ねています。より透過的にフリーマンの記述を読みたければ、原文は読みやすいので、そちらをあたってください。
注
[TDD] About MockConnectionのように、最新のMockObjectsではここに記述されたメソッドは使えなくなっているようです。実際に利用可能なメソッドはJavadocを生成して確認してください。また、JDBCのように利用するオブジェクトがすべてinterfaceで定義されているものは、EasyMockなどの利用も検討してみてください。
JDBCアプリケーションをテストファーストで開発する
はじめに
ほとんどの開発者がサードパーティ製のコンポーネント(たとえばデータベースとか)を利用するソフトウェアのユニットテストは大変だと感じている。原因はたいていの場合、ユニットテストから依存性を排除するのがとっても面倒だからか、さもなければ、動作が遅くなるからだ。だが、それは間違いだ。僕はこの文書で現実のデータベースを一切使わずに、JavaのデータベースインターフェイスのJDBCが使えるってことをお見せしよう。それだけじゃない。モックオブジェクトに基づいたアプローチがいかにより正確なユニットテストをもたらすか、そして僕はそうあって欲しいと思っているんだが、現実のデータベースを使うよりももっと整合性が取れたコードをかけるかを見せるつもりだ。
テストファーストなプログラムでモックオブジェクトを書くには2つの重要なスキルがある。ひとつは、どんなテストが必要かを見極めて、オブジェクトのモック実装にそれを反映させること。次が、モックオブジェクトに本物の動作を再実装しないことだ。もしモックが複雑になってしまったら、モックの実装とテスト対象のコードの両方を見直したほうが良い。それはオブジェクト間の結合があまりに密になっているってことだからだ。
ちなみに、これからお見せするコードとモックオブジェクトライブラリはhttp://www.mockobjects.comからダウンロード可能だ。もしモックオブジェクト初心者だったらサイトのドキュメントセクションに行って文書を読んでくれ。
最初に例のストーリーを説明しよう。
僕らのカスタマーが僕らにメーリングリストシステムを発注したと考えてくれ。誰でもリストに参加するのも退会するのも自由だけど、オウナーだけがメンバーを見れるしメッセージを送ることもできる、そんなシステムだ。もし僕らが1つのリストだけしかサポートしないって決めたら、やるべきことはこんな感じだ、
- 誰でも名前とメールアドレスを登録できる
- もし会員ならメールアドレスを使って退会できる
- オウナーは会員リストを参照できる
- オウナーは全員にメッセージを送れる
会員登録
とりあえず、アクセス制御は周りのシステムが固めてくれているっていう前提で、リストを管理するMailingListクラスを開発しよう。 インサートをうまくやるには、次の条件が必要だ。
- 適切なインサート文からステートメントオブジェクトを生成
- 会員のメールアドレスと名前を元にしてインサートのパラメータを設定
- インサートのために1回ステートメントを実行
- ちょうど1回ステートメントをクローズする。
それじゃやるかぁ。
public void testAddNewMember() { connection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); statement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); statement.setExpectedExecuteCalls(1); statement.setExpectedCloseCalls(1); [...] }
どうやら、コネクションとステートメントのアサートが必要らしい。それから会員を登録するにはメーリングリストオブジェクトにコネクションオブジェクトを渡す必要もありそうだ。ここで使っているデータベースオブジェクトの名前を変えて明示的にモックだってことをわかるようにしたほうが良いかも。つまりテストはこんな感じになる。
public class TestMaililingList extends TestCaseMo { public void testAddNewMember() throws SQLException { mockConnection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); mailingList.addMember(mockConnection, EMAIL, NAME); mockStatement.verify(); mockConnection.verify(); } [...] }
さてと、testAddNewMember()はSQLExceptionのスロー宣言をしてるわけだ。これはもちろん、もしSQLExceptionがスローされたらJUnitフレームワークがキャッチしてエラーをレポートするってことになる(僕らの環境をぶち壊してね)。厳密には、僕らがそいつをキャッチして(コーディングミスがあるわけだから)失敗としてやる必要があるわけだが、JUnitに任せてしまうのは手軽だしたいていのプロジェクトには十分な処理だよね。
で、次のメソッドに取り掛かろう。
public void setUp() { private MailingList list = new MailingList(); private MockConnection mockConnection = new MockConnection(); private MockPreparedStatement mockStatement = new MockPreparedStatement(); mockConnection.setupPreparedStatement(mockStatement); }
ここまでできたらMailingListの実装だ。とりあえず最初のテストをパスさせるために、テスト側が設定した期待値を設定しよう。
訳注:この文章はTDDではないのでRedが先に来るわけではない
public class MailingList { public void addMember(Connection connection, String emailAddress, String name) throws SQLException { PreparedStatement statement = connection.prepareStatement(INSERT_SQL); statement.setString(1, emailAddress); statement.setString(2, name); statement.execute(); statement.close(); } }
もう気付いているだろうけど、この実装はステートメントを確実にクローズしているわけじゃないね。まあ、それは後回しだ。
さてここまでで何がわかったかな? この最初のテストが自分の殻に閉じこもっているってことだ。たった1つのテスト――つまりリストに会員を登録することだけ――しかしない。しかも、外部依存性がゼロ。つまり正しくデータベースをインストールして、テーブルを用意して……なんてまったく必要なし。ま、こんな状況はあるよね。プロジェクトはとっくに始まっているっていうのにデータベースの選定が遅れてる、そんな場合でも大丈夫ってわけだ。
データベースプログラムのテストを作るには2つの視点から考えなきゃならない。僕らがデータベースに「何を与える」かと、データベースが「何を欲しがってるか」だ。このアプローチはこれを区別するんだな。だからシステムがでかくなってもエラーをピンポイントで発見できるわけだ。もちろん、全部ひっくるめた統合テストは必要だよ。でもそれは大きなモジュールでの受け入れテストみたいにするべきだろうな。
このテストスタイルをうまくやるには、オブジェクトのやりとりが必要だ。今の例だと、僕らはコネクションオブジェクトを検証する必要があったから、それをメーリングリストオブジェクトへ引数として与えてたよね。現実問題として、ほとんどのシステムはパフォーマンスをよくするためにコネクションをプーリングしているわけだから、何かやらせるたびにコネクションを渡してやるって言うのは正しいアプローチなんだ。つまり、僕らはモックを使ったテストファーストをやることで、正しいデザインをしてしまうことになるんだよ。
既に登録済み会員の追加
もし誰かが同じメールアドレスを2回登録しようとしたらどうなるんだろう? しばらく話し合った結果、僕らのカスタマーはそんなことは認める必要はないから、エラーメッセージを表示しろって決断してくれた。それじゃ僕らもMailingListExceptionをスローすることに決めよう。重複を検出したら例外をスローしたかを検出するテストが書けるじゃないか。
public void testAddExistingMember() throws SQLException { mockStatement.setupThrowExceptionOnExecute( new SQLException("MockStatment", "Duplicate", DatabaseConstants.UNIQUE_CONSTRAINT_VIOLATED)); mockConnection.setExpectedPrepareStatementString(MailingList.INSERTION_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL, NAME}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); try { mailingList.addMember(mockConnection, EMAIL, NAME); fail("should have thrown an exception"); } catch (MailingListException expected) { } mockStatement.verify(); mockConnection.verify(); }
このsetupThrowExceptionOnExecuteっていうメソッドは、モックSQLステートメントがexecute()を呼び出されたら例外をスローするように作ってある。僕らはあらかじめ仕込みを入れたモック実装のステートメントをオブジェクトに与えることができるようなコーディングスタイルを取っているからこういった芸当ができるわけだ。相変わらずコネクションとステートメントを使って正しいパラメータが使われているかとか、executeとcloseが1回しか呼ばれないこともチェックできるし。しかもこのテストはちゃんと例外をキャッチしてMailingListExceptionに変えてスローするかも確認できるんだ。もしそうしなかったらfailを呼び出してアサーションエラーがスローされるわけだし。まあちょっぴり複雑になったけど、相変わらずテストは明確だし自己説明的だよね。
テストがパスするように実装も修正しなくちゃ。
public class MailingList { public void addMember(Connection connection, String emailAddress, String name) throws MailingListException, SQLException { PreparedStatement statement = connection.prepareStatement(INSERT_SQL); try { statement.setString(1, emailAddress); statement.setString(2, name); statement.execute(); } catch (SQLException ex) { if (ex.getErrorCode() == DatabaseConstants.UNIQUE_CONSTRAINT_VIOLATED) { throw new MailingListException("Email address exists"); } else { throw ex; } } finally { statement.close(); } } }
僕らはステートメントの利用中に失敗しても良いようにfinallyブロックを追加してちゃんとクローズするようにしたぜ。ここがおもしろいとこなんだけど、このコードは今度はもう1つのテストもパスするようになったんだ。
public void testPrepareStatementFailsForAdd() throws MailingListException, SQLException { mockConnection.setupThrowExceptionOnPrepare(new SQLException("MockConnection")); mockConnection.setExpectedPrepareStatementString(MailingList.INSERT_SQL); mockStatement.setExpectedExecuteCalls(0); mockStatement.setExpectedCloseCalls(0); try { list.addMember(mockConnection, EMAIL, NAME); fail("Should have thrown exception"); } catch (SQLException expected) { } mockConnection.verify(); mockStatement.verify(); }
これはコネクションオブジェクトの呼び出しが失敗したら、ステートメントに対してexecuteやcloseを呼び出さないことをチェックしているんだ。でも相変わらず正しいprepareStatementの呼び出しは要求してるけど。
さてここまでで何がわかったかな? まあ、確かに例外のスローと管理ってのはテストの中でも飛びっきりに厄介なことだとは思うよ。実際に失敗したときにちゃんとリソースのクリーンアップをしてないコードとか、してるのは良いけれど妙にごちゃごちゃやっているコードとかを見かけるけどさ、本物のライブラリを使ってたらすごく面倒だったり、そもそも無理だったりする例外の生成をシミュレートする方法を使って、少しずつテストファーストでプログラムするってことは、デベロッパーが正しくエラーをハンドリングするのにとっても効果的だってことなんだ。もちろん、どこまでちゃんと処理するかってことはアプリケーション次第なのは事実だけどね。一発勝負のユーティリティーだったらクリーンアップなんてどうでも良いかも知れないけど、ずーっと動き回ってるサーバーサイドアプリケーションだったらちゃんとやらなきゃだめだよ。
さてと、もうひとつ、デザイン上のポイントがこの例にはあるんだよね。メーリングリスト例外(システムは正しく動いているけど、正しくなく使われた場合)とSQL例外(こっちはシステムのエラーだ)を僕がちゃんと分けてるってことだ。アプリケーションがでっかくなると、例外ハンドリングをうまくやるために、このテのSQL例外をアプリケーションの特有例外に割り当てたくなってくるってことだ。ま、こいつはどうでも良いことかも知れないけど。
リストから会員を削除
次にすることはメールアドレスを元にして、誰かをリストから削除する作業だ。削除の成功を判断するテストはこんな感じ。
public void testRemoveMember() throws MailingListException, SQLException { mockStatement.setupUpdateCount(1); mockConnection.setExpectedPrepareStatementString(MailingList.DELETE_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); list.removeMember(mockConnection, EMAIL); mockConnection.verify(); mockStatement.verify(); }
ここでも、僕らは正しいパラメータを使って正しいメソッドを呼び出しているかをチェックしている。削除に成功すれば0より大きな更新件数が返ってくるはずだから、あらかじめステートメントオブジェクトに戻り値を設定しておくことにする。
僕らは更新件数を0にすることで、リストに存在しない会員の削除のテストもできる。この場合は、デリート要求に指定されたメールアドレスに一致する行がなかったってことだ。
public void testRemoveMissingMember() throws SQLException { mockStatement.setupUpdateCount(0); mockConnection.setExpectedPrepareStatementString(MailingList.DELETE_SQL); mockStatement.addExpectedSetParameters(new Object[] {EMAIL}); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); try { list.removeMember(mockConnection, EMAIL); fail("Should have thrown exception"); } catch (MailingListException expected) { } mockConnection.verify(); mockStatement.verify(); }
removeMemberメソッドの実装は以上2つのテストを通るようにする必要がある。
public void removeMember(Connection connection, String emailAddress) throws MailingListException, SQLException { PreparedStatement statement = connection.prepareStatement(DELETE_SQL); try { statement.setString(1, emailAddress); if (statement.executeUpdate() == 0) { throw new MailingListException("Could not find email address: " + emailAddress); } } finally { statement.close(); } }
そろそろリファクタリングをすべきだろう。だってユニットテストに繰り返しが見えてきたからね。特に、会員リストに何かしようとしたらモックSQLオブジェクトに特定のセットアップが必要になる点と、いつも2つのオブジェクトの検証をしてる点だな。それじゃ、リファクタリングしてヘルパメソッドに追いやることにするとしようか。
private void setExpectationsForAddMember() { setExpectationsForPreparedStatement(MailingList.INSERT_SQL, new Object[] {EMAIL, NAME}); } private void setExpectationsForRemoveMember() { setExpectationsForPreparedStatement(MailingList.DELETE_SQL, new Object[] {EMAIL}); } private void setExpectationsForPreparedStatement(String sqlStatement, Object[] parameters) { mockConnection.setExpectedPrepareStatementString(sqlStatement); mockStatement.addExpectedSetParameters(parameters); mockStatement.setExpectedExecuteCalls(1); mockStatement.setExpectedCloseCalls(1); } private void verifyJDBC() { mockConnection.verify(); mockStatement.verify(); }
これで既存のテストをシンプルにできるし、可読性も上がったはずだ。たとえばこんな感じだ。
public void testRemoveMember() throws MailingListException, SQLException { mockStatement.setupUpdateCount(1); setExpecationsForRemoveMember(); list.removeMember(mockConnection, EMAIL); verifyJDBC(); } public void testRemoveMissingMember() throws SQLException { mockStatement.setupUpdateCount(0); setExpectationsForRemoveMember(); try { list.removeMember(mockConnection, EMAIL); fail("Should have thrown exception"); } catch (MailingListException expected) { } verifyJDBC(); }
さてここまでで何がわかったかな? アプリケーションが大きくなってきたら、新しいテストの追加コストが下がるってことだ。だって既に作ったテストから利益を回収できるからね。最初の時点ではその利益は知的作業ってやつ――コードの切った貼った直したってやつだ。で、次の段階ではテストをリファクタリングして重複を削除。テストをメンテしたりリファクタリングしたりすることは、作ったコードをメンテすることと同じくらい重要だってことだ。テストはコードの成長や変化に追随できるようにアジャイルじゃなきゃならないんだ。
つまるところ、僕らはバランス感覚ってやつを磨かなきゃならない。テストが読みにくくなるまではリファクタリングしてはだめだよ。特に検証処理について言えることだけどね。今の例で、わかりやすい名前のメソッドへ移動した2つの検証オブジェクトはそのままでも十分にわかりやすかっただろ。っていうことは、本当はテストケースの中に残しておくべきだったかも知れないな。だってそのほうがテストを読んだ時に何を検証してるかはっきりするからね。
会員リストの表示
さて次にやるべきことは、登録されている全会員の表示だ。ここで最初に問題となるのはどうやってちゃんと全員を参照したかを調べる方法だ。そこで全会員のリストを処理するオブジェクトを作ることにした。これをListActionととりあえず呼ぶことにしよう。次にモックデータベースをセットアップしてListActionオブジェクトにMailingListオブジェクトが正しく処理しているか検証させることにしよう。
1行単位の抽出 最初の単純なテストは1人しか登録されていないリストを使ったテストだ。僕らが確認しなきゃならないのは、ListActionオブジェクトが正しい会員情報を受け取ったかという点だ。つまり、ResultSetが正しいカラム名で呼び出され、next()が2回呼ばれるということ。それからいつもどおり、ステートメントの作成とクローズ、それからクェリーの実行が期待通りかという点だ。というわけで最初のテストの実装はこうだ。
public void testListOneMember() throws SQLException { MockSingleRowResultSet mockResultSet = new MockSingleRowResultSet(); mockStatement.setupResultSet(mockResultSet); mockResultSet.addExpectedNamedValues( COLUMN_NAMES, new Object[] {EMAIL, NAME}); mockResultSet.setExpectedNextCalls(2); mockListAction.addExpectedMember(EMAIL, NAME); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }
ここでのMockSingleRowResultSetの期待値には2つの目的がある。まずクライアントの呼び出しが位置またはカラム名のどちらにしろ正しいカラムに対して行われているかのチェックだ。そして戻り値の格納だ。この2つの機能は分割すべきじゃないかっていう疑問を持つかも知れないが、ExpectaionMap(訳注:呼び出しパラメータに対して返送する値を格納するマップ)を実装しなきゃならないくらいパターン化してるんだからしょうがない。一方のListActionクラスの役割は会員の名前とメールアドレスのリストの期待値を持つことだ。ここでこのテストに対応するコードを示せば、ListActionのこのメソッドの大体の構造もわかるだろう。
public void applyToAllMembers(Connection connection, ListAction listAction) throws SQLException { Statement statement = connection.createStatement(); ResultSet results = statement.executeQuery(LIST_SQL); while (results.next()) { listAction.applyTo(results.getString("email_address"), results.getString("name")); } statement.close(); }
1人以上の会員の抽出 最初のテストはResultSetの正当な行から正しい値を抽出しているかを証明するものだ。このテストが完成したら、僕らは他の観点からのテストの記述に集中しよう。たとえば正当な行がもっとある場合とか。こういったテストでまた抽出が正しいかを検証するのは無駄だよね。たとえば、会員数が変わった場合のテストだったらlistActionオブジェクトを必要な回数呼び出すだけで済むはずだ。値の正当性の確認はもういらない。こんな感じでやっていけば、それぞれのテストは厳密だし、しかも互いに直交しているから、失敗した場合に原因を探すのも簡単になるわけだ。しかも、テストケースもモックオブジェクトも作るのが簡単になってくる。だって余分なことをしないわけだからね。
たとえば、2人の会員をリストするテストは、名前とメールアドレスを期待値に設定しないで済ませている。このテストではカラム名とダミーの値を設定するけど、これはResultSetから値を取り出した場合にキャストエラーになるのを防ぐためにやっているだけだ。このダミー値で返送する行数も決まるわけだ。僕らがまじめに設定しなければならない期待値は、各メソッドが正しい回数だけ呼び出されるかだ。
public void testListTwoMembers() throws SQLException { MockMultiRowResultSet mockResultSet = new MockMultiRowResultSet(); mockStatement.setupResultSet(mockResultSet); mockResultSet.setupColumnNames(COLUMN_NAMES); mockResultSet.setupRows(TWO_ROWS); mockResultSet.setExpectedNextCalls(3); mockListAction.setExpectedMemberCount(2); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }
ResultSetが1行も返さない場合のテストは単純だ。行のセットは一切無しにして、モックListActionに対しては呼び出しが行われないことを期待値として設定すれば良い。この点から、モックResultSetをヘルパメソッドに渡してセットアップを任せることにしよう。
訳注:このリストはmockStatement.setupResultSet(mockResultSet); をしていない(MockObjectsのexamplesのソース上も)。多分ミスだと思う。
public void testListNoMembers() throws SQLException { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setExpectedNextCalls(1); mockListAction.setExpectNoMembers(); setExpectationsForListMembers(); list.applyToAllMembers(mockConnection, mockListAction); verifyJDBC(); mockResultSet.verify(); mockListAction.verify(); }
エラー処理 僕らのapplyToAllMembersの単純な実装ではこれらのすべてのテストをサポートできる。とは言え例外管理についても見る必要があるのは明らかだ。というわけでResultSetが失敗した場合のテストを追加してうまくできるか見てみる必要があるだろう。そのためには既存会員の追加のテストの時と同じようにやるのが良さそうだ。ListActionオブジェクトの呼び出しをtryブロックに入れて、期待値がスローされなかった場合には失敗するようにしてみよう。同じくResultSetから値を取得しようとしたら例外をスローするように設定しておこう。
public void testListResultSetFailure() { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setupRows(TWO_ROWS); mockResultSet.setupThrowExceptionOnGet(new SQLException("Mock Exception")); mockResultSet.setExpectedNextCalls(1); mockListAction.setExpectNoMembers(); setExpectationsForListMembers(); try { list.applyToAllMembers(mockConnection, mockListMembers); fail("Should have thrown exception"); } catch (SQLException expected) { } mockResultSet.verify(); mockListMembers.verify(); }
ここで重要な点は、applyToAllMembersが失敗した場合の振る舞いがきれいに定義されているってことだ。たとえばレコードの読み出しに失敗しているのに処理を続行したりしてはダメだ。テストファーストで開発することとコードの利用者にテストプログラムを公開することは、これまで作られてきた中の最高のドキュメンテーションよりも、そのオブジェクトをどう使うかについての明確な説明になるんだ。
ここまでの実装ではテストをパスできないってのは、finally節が抜けているからだ。ここで実装しておこう。
public void applyToAllMembers(Connection connection, ListAction listAction) throws SQLException { Statement statement = connection.createStatement(); try { ResultSet results = statement.executeQuery(LIST_SQL); while (results.next()) { listAction.applyTo(results.getString("email_address"), results.getString("name")); } } finally { statement.close(); } }
コネクションがステートメントを作れなかったり、ステートメントがクェリーを実行できなかった場合のエラー処理が正しいかをチェックするテストも追加しておくべきだろう。サーバーサイドの場合、こういったエラー処理のバグはリソースリークの元になるし、このてのバグは実運用を開始してしまうと調査することも厄介になるからだ。
結果の表示 さてこれでMailingListオブジェクトは与えられたListAction型のオブジェクトを使ってすべての会員を処理できるようになった。次に各会員の詳細をXML文書(これを処理するのはListDocumentというアクション開始時に与えられるレンダリング用オブジェクトだ)に追加していくConvertToMemberNodeクラスを実装して結果を表示すべきだろう。
class ConvertToMemberNode implements ListAction { ListDocument listDocument; public ConvertToMemberNode(ListDocument aListDocument) { listDocument = aListDocument; } public void applyTo(String email, String name) { listDocument.startMember(); listDocument.addMemberEmail(email); listDocument.addMemberName(name); listDocumnet.endMember(); } }
もちろん、この新しいクラスは別の専用のユニットテストでテストされるわけだ。
さてここまでで何がわかったかな? オブジェクトの関連をテストする場合の共通の難関はテスト環境の構築コストだ。各テストについて何を検証するのか、必要となる最小のモックオブジェクトのセットはどれか、といったことをきちんと考えればこのコストを抑制できるってことを、今見せたようなテクニックは示しているんだ。各テストが直交するように、テスト対象毎に異なる角度からテストするってことは、なかなかの優れものだ。テストはより精密になる。だからエラーの発見は容易になる。小さなテストは実装しやすいし、読むのも簡単だ。そして、モック実装はそのテストにより特化できるし、実装はよりシンプルになるわけだ。
別のアプローチとしてスタブJDBC実装を使うって方法もあるし、実際のデータベースをとりあえずコピーして使うって方法もある。ユニットテスト初心者や既存のコードベースの改造時のテストには良い方法だよね。でも、モックオブジェクトを使えばさらに上をいけるんだ。
- セットアップは安上がり。テストのメンテは楽だし実行も素早い
- 異常検出時にはすぐ失敗してくれる。他の方法だと全部走りきらなきゃわからない
- 振る舞いの検証が可能だ。たとえば何回closeを呼び出したかとか
- SQL例外のような異常な状態を生み出せる
メッセージを全会員に送信
最後の仕事は、登録された全員にメッセージを送信する要件を片付けることだ。こいつをやっつけるのにはListActionインターフェイスの別の実装を使うことにしよう。ここではAddMemberToMessageクラスだ。ここでやらなきゃならないデザイン上の選択は、メッセージング例外をどう処理するかってことになる。すぐに中断するか、送信に失敗したメッセージを集めておいて最後にリトライするかとかだ。とりあえず、すぐに中断としておこう。ここではAddMemberToMessageクラスの呼び出し側がMessageオブジェクトのセットアップと、最後の送信を受け持つように作ってみた。
class AddMembersToMessage implements ListAction { Message message; public AddMemberToMessage(Message aMessage) { message = aMessage; } public void applyTo(String email, String name) throws MailingListException { try { message.addRecipient(RecipientType.TO, new InternetAddress(email, name)); } catch (MessagingException ex) { throw new MailingListException("Adding member to message", email, name, ex); } } }
MessagingException例外はチェック例外だ。だからここでキャッチしてから伝播させなきゃならない。ListActionインターフェイスを汎用的なままにしておきたいから、MessageExceptionをMailingListExceptionに変換して代わりにスローし直すってわけだ。これで新しい失敗状態を作ってしまったぞ。だからMailingListException発生時の処理を検証するユニットテストを書くわけだ。
public void testListmembersFailure() throws SQLException { MockMultiRowResultSet mockResultSet = makeMultiRowResultSet(); mockResultSet.setupRows(TWO_ROWS); mockListAction.setupThrowExceptionOnMember(new MailingListException()); mockResultSet.setExpectedNextCalls(1); setExpectationsForListMembers(); try { list.applyToAllMembers(mockConnection, mockListAction); fail("Should have thrown exception"); } catch (MailingListException expected) { } mockResultSet.verify(); mockListAction.verify(); }
呼び出せ。問い合わせるな テストファースト開発に対するこのアプローチは不必要に複雑に見えるかも。そりゃ単純にMailingListから会員のコレクションを取ってくるほうがシンプルに感じるよね。まあ、そいつはわからんでもない。でも良く聞いてくれ。こういったコードの記述方法は柔軟なんだよ。たとえば、後からメッセージエラーを無視して構わず送信を続けるってやり方に変更しなきゃならなくなったとしようじゃないか、AddMemberToMessageクラスが送信エラーになった会員を集めて送信完了後に処理するようにできるよね。同じようにメーリングリストが肥大化した場合を想像してみよう。その肥大化したリストを格納したコレクションを作る代わりに送信対象を幾つかの小さなリストに分割して処理することもできるよね。ところが、もし会員リストを直接もらうように作ったとすれば、その時点で個々の会員を追加するアプローチのListActionの実装を作らなきゃならなくなっちゃうじゃないか。
まとめ
この例では、個々のユニットテストの分離とモックオブジェクトを使うことによる外部リソースへの依存の回避が、どれだけ可能かを示したつもりだ。でももっと重要なことは、ユニットテストに対するこのアプローチは、柔軟な上に、システム例外のテストを含めてテストしやすいスタイルでコードを書きたくなるってことなんだ。アプリケーションが大きくなれば、通常のテスト用のインフラはモック実装を使わざるを得なくなるし、新しいテストの追加コストは実質的には下がっていくものだ。僕らの経験から言わせてもらうけど、複雑な新しいライブラリ(たとえばJDBCとか)を使うと最初のうちはテストをなかなか書けないものなんだけど、モック実装はすぐにそれを解消するんだ。特に実行時プロクシやスタブメソッドを生成するIDEを使った場合にはね。
完了
お気づきの点はどんどん修正してください。
追記(arton)
モックオブジェクトはまだ進化中です。MockObjectsはますますJDKのモック実装を充実化していますが、この記事の著者のスティーブフリーマン(Blog)達(実際にはナットプライスとはフリーマンの弁)のプロジェクトjMockでは、自分のオブジェクトのモック化に対する別のアプローチを開始しています。
また、IoC(またはデペンデンシインジェクション(角谷さんの訳))といった、オブジェクトの依存関係を静的に決定せずにコンテナに任せるアプローチも、モックと関連してきます。
Keyword(s):[MockObjects]
References: