junitで例外のテストをもっと簡単にしたい

最近のJunitでは例外のチェックが前よりは簡単になったけど、JDaveの匿名クラスを使った記述方法と比べていまいちだなと感じていた。

例外のチェック方法の比較

JUnit4での例その1:@Testのexceptionを使う場合。

JUnit Cookbook」より

@Test(expected= IndexOutOfBoundsException.class)
public void empty() { 
    new ArrayList<Object>().get(0); 
}
JUnit4での例その2:@Ruleを使う場合。

http://kentbeck.github.com/junit/javadoc/latest/org/junit/rules/ExpectedException.html」より。

// These tests all pass.
 public static class HasExpectedException {
        @Rule
        public ExpectedException thrown= new ExpectedException();
 
        @Test
        public void throwsNothing() {
    // no exception expected, none thrown: passes.
        }
 
        @Test
        public void throwsNullPointerException() {
                thrown.expect(NullPointerException.class);
                throw new NullPointerException();
        }
 
        @Test
        public void throwsNullPointerExceptionWithMessage() {
                thrown.expect(NullPointerException.class);
                thrown.expectMessage("happened?");
                thrown.expectMessage(startsWith("What"));
                throw new NullPointerException("What happened?");
        }
 }

あとは「JUnit4.7 の新機能 Rules とは〜その2 - A Memorandum」が参考になる。

JDaveでの例

JDave-examples」から必要な部分を抜粋。

import jdave.Block;
import jdave.Specification;
import jdave.junit4.JDaveRunner;

@RunWith(JDaveRunner.class)
public class StackSpec extends Specification<Stack<?>> {
    public class FullStack {
        private Stack<Integer> stack;

        public Stack<Integer> create() {
            stack = new Stack<Integer>(10);
            for (int i = 0; i < 10; i++) {
                stack.push(i);
            }
            return stack;
        }

        public void complainsOnPush() {
            specify(new Block() {
                public void run() throws Throwable {
                    stack.push(100);
                }
            }, should.raise(StackOverflowException.class));
        }       
    }
}

好みの問題かもしれないけど、

  • Blockを実装した匿名クラスを使う事で例外が起きるであろう箇所を指定できる。
  • そのすぐ近くに投げられるであろう例外と例外メッセージを記述できる。

という部分がとても気に入っている。

ただ、以下のような違いもあって、Junitから乗り換えるのが難しいと感じる部分もある。

  • JDaveRunnerでうごかさないといけない
  • Specificationクラス内にテストのサブクラスを記述しないといけい
  • createメソッドが必要

だったらJUnitのassertThatで同じような感じで記述できればいいかなと思って、以下のような感じで記述するためのカスタムMatcherを作ってみた。

assertThat( new Block(){
  public void run() throws Throwable {
                    stack.push(100);
                }
}, should.raise(StackOverflowException.class));

設計と実装

hamcrest の Matcher を独自拡張 - java.util.Date 版 closeTo - 倖せの迷う森」の独自のDate用Matcherを作成した例を参考にして、実装してみる。

TypeSafeMatcherについて

この例ではorg.hamcrest.TypeSafeMatcherというクラスを使っていて、これはhamcrest1.2以降で導入されている。
で、hamcrest-core1.2を使おうとしたら、mavenのセントラルレポジトリにないではないか。「Google Code Archive - Long-term storage for Google Code Project Hosting.」を見てみると、mavenレポジトリのアップロードに苦労しているっぽい。仕方がないので、ローカルにインストールして使う。

jdaveの例外記述バリエーション
  • 例外メッセージチェックの有無。
  • 例外クラスを厳密にチェックするか派生クラスを許可するか。

の組み合わせで、以下の4つが存在する。

  1. should.raise(Class expected)
  2. should.raise(Class expectedType, String expectedMessage)
  3. should.raiseExactly(Class expected)
  4. should.raiseExactly(Class expected,String expectedMessage)
assertThatメソッドのactualとBlockの相性

ひとまずJDaveのBlockインターフェースをそのまま使ってみたのだが、assertThatメソッドと第一引数として渡しているBlockの相性が悪い。

assertThatの第一引数actualに渡したオブジェクトは、toString()メソッドで実際の値をかえさなければいけないが、インターフェースを実装した匿名クラスだと、toStringで匿名クラス名しか表示できない。
かといって、toString()メソッドをそれぞれ実装するのは記述量がふえてしまって良くない。
ということでBlockを抽象クラスに変更して、Matcherでの評価時に発生した例外を保存出来るようにし、toString()メソッドでその例外の情報を返すようにした。

実際のコード

ひとまずbitbucketに置いてみた。
http://bitbucket.org/cnaos/hamcrest-exception-matcher/

主要なクラスの解説

Blockクラス

jdaveのBlockインターフェースとほぼ一緒。前述の理由によりassertThat()に適合するように修正している。

public abstract class Block {
    /** ブロックの実行時に発生した例外 */
    private String actualException="No Exception was thrown";

    /**
     * Evaluate this block.
     *
     * @throws Throwable if an exception is thrown during block evaluation.
     */
    public abstract void run() throws Throwable;

    /**
     * 発生した例外を設定します。
     * @param actualException
     */
    public void setActualException(String actualException) {
        this.actualException = actualException;
    }

    @Override
    public String toString() {
        return this.actualException;
    }
}
RaiseMatcherクラス

Block用のカスタムMatcher。
要となる部分は、matchesSafelyメソッドで、この部分で渡されたBlockを実行して、投げられた例外をチェックしている。

public class RaiseMatcher extends TypeSafeMatcher<Block> {

    /** 例外の期待値 */
    private final ExpectedException<? extends Throwable> expectation;

    /**
     * 
     * @param aExpectation 例外の期待値
     */
    public RaiseMatcher(ExpectedException aExpectation) {
        this.expectation = aExpectation;
    }

    @Factory
    public static <E extends Throwable> Matcher<Block> raise(final Class<E> expected) {
        return new RaiseMatcher(new ExpectedException<E>(expected));
    }

    @Factory
    public static <E extends Throwable> Matcher<Block> raise(final Class<E> expected, final String expectedMessage) {
        return new RaiseMatcher(new ExpectedExceptionWithMessage<E>(expected, expectedMessage));
    }

    @Factory
    public static <E extends Throwable> Matcher<Block> raiseExactly(final Class<E> expected) {
        return new RaiseMatcher(new ExactExpectedException<E>(expected));
    }

    @Factory
    public static <E extends Throwable> Matcher<Block> raiseExactly(final Class<E> expected, final String expectedMessage) {
        return new RaiseMatcher(new ExactExpectedExceptionWithMessage<E>(expected, expectedMessage));
    }

    /**
     *
     * @see jdave.Specification#specify(Block,ExpectedException)
     * @param block テストで実行するブロック
     * @return 指定したテスト条件を満たした場合はtrue
     */
    @Override
    protected boolean matchesSafely(final Block block) {
        try {
            block.run();
        } catch (final Throwable t) {
            block.setActualException(expectation.createDescription(t));
            if (!expectation.matches(t)) {
                return false;
            }
            return true;
        }
        return false;
    }

    /**
     * 投げられる事を期待している例外クラス名や例外メッセージを返す
     * @param description 期待する条件の説明を返す
     */
    @Override
    public void describeTo(Description description) {
        description.appendText("throw ");
        description.appendValue(expectation);
    }
}
ExpectedExceptionクラス

Blockから投げられる例外に関する期待条件クラス。
jdaveのExpectedExceptionをhamcrestMatcherに合うように改変した。

public class ExpectedException<T> {
    protected final Class<? extends T> expected;

    public ExpectedException(Class<? extends T> expected) {
        if(expected == null){
            throw new NullPointerException();
        }
        this.expected = expected;
    }

    public boolean matches(Throwable t) {
        return matchesType(t.getClass());
    }

    public String createDescription(Throwable t){
        return t.getClass().getName();
    }

    protected boolean matchesType(Class<? extends Throwable> actual) {
        return expected.isAssignableFrom(actual);
    }

    @Override
    public String toString() {
        return expected.getName();
    }
}