テストしにくいクラス

http://d.hatena.ne.jp/ms2310/20070407#p1
ではとりあえずxUnitの紹介をしてみました。単純なクラスはこれでテストをやってゆけると思います。では

  • クラスの中で特殊なライブラリを呼び出していて、それが実行環境*1に依存している
  • 通信して戻ってきた結果を利用して処理を行う
  • システムの日付等に結果が影響されてしまい、一定の結果が返ってこない

このようなテストのしにくいクラスをどうにかテストできる形にしようというのが今回の目的です。

サンプルとして以下のような大雑把な仕様のクラスをテストすることを考えます。

クラス名
HtmlTableCreator
メソッド
String create()
仕様
createメソッドで実行環境からCSVファイルのパスを取得し、そのファイルの内容HTMLのテーブルタグの形式で出力する。

実行のイメージは以下のような形でしょう。

    HtmlTableCreator creator = new HtmlTableCreator();
    String table = creator.create();

環境に依存しない部分だけ別のクラスとして切り出す方法もありますが、今回はその手段を取らずにテストする方法を考えます。

package ms2310.xunit.section1.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;

import ms2310.oot.section5.sample0.SplitHandler;
import ms2310.oot.section5.sample0.StringSplitterByString;

/**
 * テストがしにくい実装の入ったクラス
 */
public class HtmlTableCreator {

  private class CSV2TableHandler implements SplitHandler{
    private StringBuffer buffer;

    CSV2TableHandler(StringBuffer buffer){
      this.buffer = buffer;
    }
    
    public void handleSplit(String str) {
      buffer.append("<td>").append(str).append("</td>");
    }
    
  }
  
  public String create() throws IOException {
    BufferedReader reader = new BufferedReader(createCSV());
    String delim = ",";
    StringBuffer buffer = new StringBuffer();
    buffer.append("<table>\n");
    CSV2TableHandler handler = new CSV2TableHandler(buffer);

    String line;
    while((line = reader.readLine()) != null){
      buffer.append("<tr>");
      StringSplitterByString ss = new StringSplitterByString(handler, line, delim);
      ss.split();
      buffer.append("</tr>\n");
    }
    buffer.append("</table>");
    
    return buffer.toString();
  }

  Reader createCSV(){
    //ここはシステムや環境に依存していて
    //切り離すのが難しい処理が入ります。
    //例えばサーブレットAPIを利用してファイル内容を取得する等

    //この戻り値はテストには無関係
    return null;
  }
}
package ms2310.xunit.section1.sample0;

import java.io.BufferedReader;
import java.io.Reader;
import java.io.StringReader;

import junit.framework.TestCase;

public class HtmlTableCreatorTest extends TestCase {

  private static class TestHtmlTableCreator extends HtmlTableCreator{
    Reader createCSV() {
      //実装クラスの関数をオーバーライドして、常にテスト用の戻り値が取得できるようにする。
      String ret = "1,3,5\n" +
            "2,,6\n" + 
            "3,3,11";
      return new StringReader(ret);
    }
  }
  
  
  public void testSenario() throws Exception{
    
    //本来HtmlTableCreatorはテストが難しいクラスだったが、
    //原因となる部分をオーバーライドしたTestHtmlTableCreatorはテストがしやすいクラス。
    HtmlTableCreator creator = new TestHtmlTableCreator();
    String table = creator.create();
    
    //テーブルタグが出力される
    BufferedReader reader = new BufferedReader(new StringReader(table));
    assertEquals("<table>", reader.readLine());
    assertEquals("<tr><td>1</td><td>3</td><td>5</td></tr>", (Object)reader.readLine());
    assertEquals("<tr><td>2</td><td></td><td>6</td></tr>", (Object)reader.readLine());
    assertEquals("<tr><td>3</td><td>3</td><td>11</td></tr>", (Object)reader.readLine());
    assertEquals("</table>", reader.readLine());
  }
  
}

テストに邪魔な関数をオーバーライドして、テスト用の実装で上書きしてしまうのです。これでテストしにくい状況を作り出している部分を消してしまいます。
本来はこのような「実装の上書き」の意味でのオーバーライドは嫌われるものだと思いますが、テストする際には重要なテクニックでしょう。また、「Readerを生成する」という概念をクラス内の生成関数として抜き出す形は、FactoryMethodというデザインパターンで知られる形式*2です。

Javaの場合、テストの為だけにオーバーライドさせる関数はパッケージプライベート(デフォルト)のアクセス権限を持たせることでテストケースからの上書きを可能にすると良いでしょう。テストケースはテスト対象クラスと同じパッケージに置き、別のディレクトリをルートにすることで管理上も分けるのが扱いやすいと思います。

また、今回のようなCSVはファイルを読み込む実装になることが多いので、ファイルから読み込ませるケースを書くことも可能だとは思いますが、テストケースとテストデータが離れてしまうとかえってわかりにくいものになってしまいます。そのため自分はなるべくテストケース内に書いてしまうようにしています。*3

さらに余談となりますが

 assertEquals("<tr><td>3</td><td>3</td><td>11</td></tr>", (Object)reader.readLine());

のように右辺をあえてObjectにキャストしているのはStringの比較の場合、長すぎる文字列が省略されてしまうことを嫌っています。このキャストを入れることで全ての文字列が出力されます。文字列比較の時に役に立つ方法かもしれません。

*1:例えばサーブレット

*2:デザインパターンも時間があれば少し触れたいところです。

*3:PerlPHPのようにヒアドキュメントがあると便利だと思ったりします。