モックオブジェクトのアンチパターン

オリジナルのポストに付けられたコメントも読む価値がある。

しまった! モックオブジェクトのテストをしてるじゃないか!

ここのところ、間違ったモックオブジェクトの使い方をしているチームで働いていたんだ。(これこそが本当のアンチパターンの例さ。)これの詳しい話をする前に、モックオブジェクトの基本的なポイントについて幾つか確認しておこう。

モックとは「本物」のオブジェクトの代わりに振舞う「偽者」のオブジェクトのことだ。モックオブジェクトはそのクラスのインタフェースを実装したものだったり、いくつかのクラスのサブクラスだたりする。そして、それらは自動的に生成されることもあるし、手作業でコーディングされることもある。

モックとは何か、何の目的に作られるのか、といことを詳しく説明するために、MockObject と JMock について紹介しよう。

先に述べたチームが開発したソースコードに関する問題点は、間違ったクラスをモックオブジェクトで置き換えてしまったことだ。この結果、分かりにくいテストが生み出され、テストしている内容に誤解を生じさせてしまった。このソースコードの多くのモックは、"Mock" という名前が付けられているだけのモックでした。

このような間違った「モック」はどこでどのように利用されたのだろう? それらのモックはテストするべきクラスの代用品として使われていたんだ(!) つまり、ユニットテストでモックオブジェクトのテストをしていたんだ。。。

このシステムは DAO (Data Access Object) を介して Hibernate とやり取りする WebWork Action で構成されている。これはとても一般的なパターンだ。

ほとんどのアクションは次のような感じになっている。

public class CheeseAction extends ActionSupport {
     private String cheese;
     public void setCheese(String cheese) { this.cheese = cheese; }
     public String getCheese() { return cheese; }
 
     public String execute() {
          try {
               // CheeseDao is a concrete class.
               saveCheese(new CheeseDao());
               return SUCCESS;
           } catch (Exception e) {
               return ERROR;
           }
      }
 
     protected void saveCheese(CheeseDao dao) {
          dao.saveCheese(cheese);
      }
}

CheeseDao クラスは Hibernate と通信するクラスだ。CheeseDao.saveCheese メソッドを呼び出すとデータベースにチーズが記録される。

アクションをテストするときにデータベースにアクセスしていると、とても時間がかかって仕方がない。そこで、僕のチームはテストのときは Hibernate DAO をモックに置き換えることにしたんだ。

アクションをテストするテストは次のようになっている。

public class CheeseActionTest extends TestCase {
 
     public void testAddCheese() {
          // うわっ、モックオブジェクトのテストをしてるぞ!
          MockCheeseAction action = new MockCheeseAction();
          action.setCheese("stilton");
          assertEquals(ActionSupport.SUCCESS, action.execute());
          assertEquals("stilton", action.getSavedCheese());
      }
 
     public class MockCheeseAction extends CheeseAction {
          private String savedCheese;
  
          protected void saveCheese(CheeseDao dao) {
               CheeseDao mockDao = new CheeseDao() {
                    public void saveCheese(String cheese) {
                         savedCheese = cheese;
                     }
                };
               super.saveCheese(mockDao);
           }
  
          public String getSavedCheese() {
               return savedCheese;
           }
      }
}

(モックを使うことの開発チームの一番の目的はテストの事項時間を減らすことのようだ。これは立派な目的ではあるのだけど、モックを利用することの理由としてはよくない。より良いデザインを浮かび上がらせるために利用するのが、良いモックの使い方だ。TDD とモックを使って開発されたコードは他の方法よりも依存関係の少ないものにできる。

この「モックオブジェクトのアンチパターン」が発明されたとき、これを考えていた人はこのモックDAOをどうやって使ってアクションさせるかについて考えていたに違いないと思う。

(もし、このモックオブジェクトの使われ方についての推測が正しければ、アクション自身のあとに書かれたテストについても同じ推測が成り立つのではないかと思う。すくなくともこのアンチパターンが生み出された後のテストについては間違いないだろう。アクションを作成する前にテストが書かれていれば、Hibernate DAO とこれほどまでに密結合されたものになる可能性は少なかっただろう。)

ここで紹介したテストで用いられた「モックオブジェクト」を利用したパターンには幾つかの基本的な誤りがある。

1) テストが「本物」のアクションをテストしておらず、テストコードにのみ存在するサブクラスをテストしている。このチームが生み出したモックオブジェクトパターンは、偽者に置き換えたオブジェクトそのものにまで適用されてしまっている。そして、DAO のためにモックオブジェクトが作られたが (これが一番まともなモックだ)、そのオブジェクトの代わりにスーパークラスのメソッドを呼んでしまった。

このちょっとした例はそれほど悪いものには見えないかもしれない。結局、モックオブジェクトがオーバーライドした saveCheese メソッドがスーパークラスのメソッドを呼び出すのだから。

しかし、実際のテストコードやオーバーライドされたアクションは(たくさんのオーバライドがされて)非常に複雑なものになりがちです。そして、そのようなテストコードは制御が非常に困難です。特にスーパークラスリファクタリングされると、テストが有効であるかどうかもはや保障できなくなる。テストの中で実装された「モック」サブクラスはスーパークラスをオーバーライドしているものではなくなっているだろう。そして、 テストは元々意図されたメソッドの実行に失敗するかもしれません。

2) アクションクラスの公開されたインタフェースを使ってテストされていない。アプリケーションに組み込まれたときは公開されたインタフェースを使ってアクセスされるのに!

3) テスト対象のクラスとそのクラスが依存しているクラスのモックによって、テストはものすごく複雑なものになる。

4) DAO のメソッドを呼び出すアクションをチェックする期待値の設定が不恰好になる。モックサブクラスに期待値を設定するためには追加のフィールドや getter を用意しなければならなくなる。

モックは本物のオブジェクトの代用品だ。どのクラスをモックで置き換えればいいのかが間違えられやすい。テスト対象のクラスが依存しているオブジェクトを置き換えるのがモックだ。テスト対象のクラスをモックで置き換えてはならない。そんなことをしたら、本物のクラスをテストできなくなってしまうだろう。

以下に良いコード例を載せてこう。(テストメソッドの新しい名前にも注意してほしい。「ユニットテストの目的」についてはまた別のエントリーで紹介しよう)

public class CheeseAction extends ActionSupport {
     private final CheeseDao dao;
     private String cheese;
     public void setCheese(String cheese) { this.cheese = cheese; }
     public String getCheese() { return cheese; }
     
     // Yes, you can do this. See http://wiki.opensymphony.com/space/PicoContainer+Integration
     public CheeseAction(CheeseDao dao) {
          this.dao = dao;
      }
 
     public String execute() {
          try {
               dao.saveCheese(cheese);
               return SUCCESS;
           } catch(Exception e) {
               return ERROR;
           }
      }
}

テストコードはこのようになる。(出来ればアクションのコードを書く前に作って欲しい)

public class CheeseActionTest extends TestCase {
 
     public void testExecuteCallsSaveCheeseOnDaoAndSucceedsWhenNoDaoException() {
          Mock mockDao = new Mock(CheeseDao.class);
          mockDao.expect("saveCheese", C.eq("stilton"));
  
          CheeseAction action = new CheeseAction((CheeseDao)mockDao.proxy());
          action.setCheese("stilton");
  
          assertEquals(ActionSupport.SUCCESS, action.execute());
          mockDao.verify();
      }
}

このように適切な方法でモックを利用すると、幾つかの利点が得られる。

1) テストが短くなる。

2) 読みやすくなる。

3) テストすべきものをテストしている。

4) TDD は無料で依存性の低いよい設計をもたらす。このアクションは有名なコンスタラクタベースの DI である PicoComponent になった。

しかしながら、このように適切にすすめるためには、依存関係(この例では CheeseDao)を外部に出してインターフェース化する必要がある。これはソースコードにいくらかのオーバヘッドを生み出してしまう。つまり、開発者は CheeseDao と HibernateCheeseDaao の両方をメンテナンスしなければならなくなる。でも、CheeseAction と MockCheeseAction のメンテナンスをするよりかはマシである。

最近の MockObject の追加機能で、CGLib を利用して concrete クラスの自動的なモック化ができるようになった。

それから、モックを使ってテストを最初に書けば、よい設計で依存性の低い独立したコードを作り上げることができるだろう。後付けのテストと後付けのモックは、本当はよくないものなのだ。それらはクラスを前よりも酷いものにしてしまうことすらある。