単体テストの自動化 xUnit

http://d.hatena.ne.jp/ms2310/20070121#p1
までは結果を確かめる為にmain関数を動かした時の出力をコメントで書いていました。
プログラムを書いている時の作業は以下のようになります。

  1. コードを書く
  2. main関数を起動させる。
  3. 望み通りの出力になっているか確認する。

これの繰り返しです。

昔のコードに手を加えた場合など、上記の手順で確認していると知らぬ間に望み通りの結果になっていない場合があります。デグレードと呼ばれるものです。これに気付かずに納品してしまうと「今まで動いていた製品がちゃんと動かないです」とサポートに電話がかかるようなことになります。

これを防ぐには以前動いていたものが手を加えた後も望み通り動いているか(目で見るというようなミス発生の可能性の高い方法を取らずに)正確に確認する手段が必要です。そのような手段の一つにxUnitと呼ばれるものがあります。

xUnitとは総称で、各言語ごとにJUnitCppUnitPHPUnit等の実装が存在します。今回はJavaの実装であるJUnitを利用します。尚、ここではJUnitの具体的な利用方法については触れません。


StringSplitStringOutputterのテストクラス(テストケースと呼ばれます)は例えば以下のようなものです。

package ms2310.oot.section3.sample0;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

public class StringSplitStringOutputterTest extends TestCase {

  private static class TestHandler implements SplitHandler{

    private List result = new ArrayList();
    
    public void handleSplit(String str) {
      result.add(str);
    }
    
    public List getResult(){
      return result;
    }
  }
  
  public void testSenario() throws Exception{
    
    TestHandler handler = new TestHandler();
    
    String input = "こんな::文字列を::::分解します";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(
        handler, input, delim);
    
    //文字列分解を行う
    outputter.output();
    
    //handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }
  
}

TestHandlerというテスト用のSplitHandlerを作成しました。このようなことができるのも、ポリモーフィズムのお陰です。また、CharSplitStringOutputterTestも同様のコードになるでしょう。

さて、テストにはそのクラスが何の目的で作成されていて、どんな動作をするのかということを示す必要があります。testSenarioは俗に言う正常系のテストであり、サンプルでもあります。異常系として考えられることは何でしょう。例えば以下のようなものでしょうか。

  1. inputがnullだった場合
  2. delimがnullだった場合
  3. inputが空文字だった場合
  4. delimが空文字だった場合
  5. handlerがnullだった場合

1、2、5についてはコンストラクタでIllegalArgumentExceptionを投げることにしましょう。3、4は一回もSplitHandler.handleSplitが呼ばれずに処理が終わるものとします。

ではまず1から始めます。上記テストクラスに以下のようなメソッドを追加しました。

  public void testInputIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = null;
    String delim = "::";

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("inputがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

これで一度テストを実行します。テストは失敗しますね。"inputがnullなら例外"というメッセージが出力されるはずです。StringSplitStringOutputterではまだコンストラクタでの引数チェックを行っていないからです。引数チェックを実装してテストを成功させましょう。同様に他の仕様に関しても実装します。

テストファーストという言葉があります。「まずテストケースを作成し、現在の実装クラスにおいてテストが失敗してから修正し、テストを通すことで完成させる。」というものです。これによりテストが完全に通っていることが保証されるという概念です。より確実なテストを目指すならばこれを意識すると良いでしょう。ただし、設計がコロコロ変わるような段階で行うと設計を変更する度にテストも修正しなければならないので、ある程度固まってから作成するのが良さそうだと筆者は考えています。

また、テストケースの中ではあまり処理を細かい関数に分けない方が良いようです。多少冗長な表現であった方が見る人にわかりやすくなることが多いです。

結果、以下のようなコードになりました。全てを載せるのは大変なので中心になったStringSplitStringOutputterとそのテストケースのみ掲載します。

/**
 * 
 */
package ms2310.oot.section3.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;

class StringSplitStringOutputter implements SplitStringOutputter {
  private String delim;

  private String input;

  private SplitHandler handler;

  StringSplitStringOutputter(SplitHandler handler, String input, String delim) {
    if(handler == null) throw new IllegalArgumentException("handler == null");
    if(input == null) throw new IllegalArgumentException("input == null");
    if(delim == null) throw new IllegalArgumentException("delim == null");
    
    this.delim = delim;
    this.input = input;
    this.handler = handler;
  }

  public void output() throws IOException {
    if(input.length() == 0) return;
    if(delim.length() == 0) return;
    
    BufferedReader reader = new BufferedReader(new StringReader(input));
    int c;
    StringBuffer buf = new StringBuffer();

    char[] delimBuf = new char[delim.length()];
    for (;;) {
      reader.mark(delim.length());
      c = reader.read(delimBuf);

      if (c != delim.length()) {
        handler
            .handleSplit(buf.append(new String(delimBuf, 0, c)).toString());
        break;
      } else if (delim.equals(new String(delimBuf))) {
        handler.handleSplit(buf.toString());
        buf = new StringBuffer();
      } else {
        reader.reset();
        c = reader.read();
        buf.append((char) c);
      }
    }
  }
}
package ms2310.oot.section3.sample0;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

public class StringSplitStringOutputterTest extends TestCase {

  private static class TestHandler implements SplitHandler {

    private List result = new ArrayList();

    public void handleSplit(String str) {
      result.add(str);
    }

    public List getResult() {
      return result;
    }
  }

  public void testSenario() throws Exception {

    TestHandler handler = new TestHandler();

    String input = "こんな::文字列を::::分解します";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
        input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();

    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }

  public void testInputIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = null;
    String delim = "::";

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("inputがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testDelimIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("delimがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testHandlerIsNull() throws Exception {

    TestHandler handler = null;
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("handlerがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testInputIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
          input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

  public void testDelimIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = "";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
          input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

}

テストケースの利点

今回は単なる実装よりもコードを書く量が増えてしまいました。これが面倒で嫌なものだと感じる人もいるかと思います。しかし、この面倒さを補うほどの効果があります。

  1. デグレードチェックが一発でできる
  2. 他人にクラスの仕様を理解してもらう手段になる
  3. どの程度実装してあるかの証明になる

1は冒頭でも書いたデグレードに関することです。デグレードは精神的にも辛いものであるので、これを自分の作業の中で食い止めることが出来るのは大きな恩恵でしょう。
2は正常系のテストケースはサンプルコードであり、異常系の動作も示しているのでそれ自体がクラスの仕様を示すものであるという考えです。口頭で人に説明するよりも見ればわかるものになっているのは大きいでしょう。こういった仕様はjavadoc等に書かれる事もありますが、実際にjavadoc通りの実装であるのかを示すことが出来ます。
3は組織でアプリケーションを作成する場合に自分の実装レベルを明確にすることができるということです。他人にクラスを提供する際に「この程度はテストしているよ」と証拠を示すことが出来ます。人によっては自分を守る為の手段にもなるでしょう。