JUnitのテストランナーをいじる

テストに何らかの拡張を作りこみたいとき、@Ruleアノテーションを使えばだいたいOKですが、
俺俺アノテーションを作ってそれをテストとして認識させたいんだ!!っていう稀有なニーズ(自己満足)に応えることもできます。


今回は、独自のアノテーションで定義されたメソッドをテストメソッドとして認識させ、
例外時に例外メッセージを正規表現でひっかけて期待通りの例外が起きることを確認するテストランナーを作っちゃいたいと思います。

1. アノテーションを書く

とりあえず、例外クラスと、例外メッセージのパターンとオプション(flags)を受け取れるようにしておきます。

package my.test.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest
{
	public static final class NONE extends Throwable
	{
		private static final long serialVersionUID = 1L;
	}

	Class<? extends Throwable> exceptionClass() default NONE.class;
	String exceptionPattern() default "";

	int exceptionPatternFlags() default 0x00;
}

2. Statementを作る

JUnitではテストを実行する前に@Beforeとか、テスト実行後に@Afterを行ってくれます。
これはStatementのおかげで、内部ではこんな挙動になってます。

どんどん下に潜っていって、テストを実行し終えたら、上に戻っていく形になります。
下に潜る段階で、@Beforeの処理を呼び出したり、Timeoutの設定をしたりして、
上に戻る段階で、テストが投げた例外を調べたり、Timeoutしてないかチェックしたり、@Afterの処理を呼び出したりするわけです。(多分)
一番上の@Ruleアノテーションをつけたメソッドの処理を呼び出すStatementがあるので、
一番最初と一番最後に好きなようにStatement作りこむことができます。


ただ、テストがどれなのか、という判定を@Testアノテーションで行っているため、

@Test
@ExceptionTest(exceptionPattern = "xxx")
public void hoge(){}

と書かねばならず、冗長になります。
それよりも、

@ExceptionTest(exceptionPattern = "xxx")
public void hoge(){}

というように、@Testアノテーションをつけなくてもよいようにしたくなりませんか。
その場合は(おそらく)テストランナーをいじる必要があります。
(アノテーションにも継承みたいな仕組みがあれば・・・)


今回、自分で追加する俺俺アノテーションは当然この流れには組み込まれていないので、
新しくStatementを作って、読み取ってもらうようにします。
(例外が起きると期待してるのに正常に処理が終わった時のAssertionErrorのメッセージが言葉足らず過ぎる感。)

package my.test.statement;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import my.test.annotation.ExceptionTest;

import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.Statement;

public class ExceptionCheckStatement extends Statement
{
	@SuppressWarnings("unused")
	private FrameworkMethod method;
	private ExceptionTest annotation;
	private Statement next;

	public ExceptionCheckStatement(FrameworkMethod method, Statement next)
	{
		this.method = method;
		this.annotation = method.getAnnotation(ExceptionTest.class);
		this.next = next;
	}

	@Override
	public void evaluate() throws Throwable
	{
		if (annotation == null)
		{
			next.evaluate();
			return;
		}

		boolean compleate = false;
		try
		{
			next.evaluate();
			compleate = true;
		} catch (Throwable e)
		{
			checkException(e);
			checkExceptionPattern(e);
		}
		if (compleate)
		{
			throw new AssertionError("エラー");
		}
	}

	private void checkException(Throwable e) throws Throwable
	{
		// 未定義なら終了
		if (annotation == null
				|| annotation.exceptionClass() == ExceptionTest.NONE.class)
		{
			return;
		}

		if (!annotation.exceptionClass().isAssignableFrom(e.getClass()))
		{
			throw new Exception("例外が期待値と一致しません. expected["
					+ e.getClass().getName() + "], actual["
					+ annotation.exceptionClass().getName() + "]", e);
		}
	}

	private void checkExceptionPattern(Throwable e) throws Throwable
	{
		// 未定義なら終了
		if (annotation == null || annotation.exceptionPattern().isEmpty())
		{
			return;
		}

		String target = e.getMessage();
		Pattern pattern = Pattern.compile(annotation.exceptionPattern(), annotation.exceptionPatternFlags());
		Matcher matcher = pattern.matcher(target);

		if (!matcher.find())
		{
			throw new Exception("例外メッセージがパターンと一致しません. expected["
					+ pattern.pattern() + "], actual[" + target + "]", e);
		}
	}
}

3.テストランナーを書く

やることは2つです。

基本的な実装がされているBlockJUnit4ClassRunnerをベースにいじっていきます。

package my.test.runner;

import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;

import my.test.annotation.ExceptionTest;
import my.test.statement.ExceptionCheckStatement;

import org.junit.Test;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement;

public class MyTestRunner extends BlockJUnit4ClassRunner
{
	public MyTestRunner(Class<?> klass) throws InitializationError
	{
		super(klass);
	}

	@Override
	protected List<FrameworkMethod> computeTestMethods()
	{
		LinkedHashSet<FrameworkMethod> s = new LinkedHashSet<FrameworkMethod>();
		s.addAll(getTestClass().getAnnotatedMethods(Test.class));
		s.addAll(getTestClass().getAnnotatedMethods(ExceptionTest.class));
		return Arrays.asList(s.toArray(new FrameworkMethod[0]));
	}

	@Override
	protected Statement methodBlock(FrameworkMethod method)
	{
		Statement statement = super.methodBlock(method);
		statement = new ExceptionCheckStatement(method, statement);
		return statement;
	}
}

4.使ってみる

上手く動くかためしに動かしてみます。

package my.test;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;

import java.util.regex.Pattern;

import my.test.annotation.ExceptionTest;
import my.test.runner.MyTestRunner;

import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(MyTestRunner.class)
public class Sample
{
	@Test
	public void 普通のテスト()
	{
		assertThat("HOGE", is("HOGE"));
	}

	@ExceptionTest(exceptionClass = AssertionError.class)
	public void 独自の例外クラスのテスト()
	{
		assertThat("HOGE", is("FUGA"));
	}

	@ExceptionTest(exceptionPattern = "fuga.*hoge", exceptionPatternFlags = Pattern.DOTALL
			| Pattern.CASE_INSENSITIVE)
	public void 独自の例外パターンのテスト()
	{
		assertThat("HOGE", is("FUGA"));
	}

	@Test
	@ExceptionTest(
			exceptionClass = RuntimeException.class,
			exceptionPattern = "fuga.*hoge",
			exceptionPatternFlags = Pattern.DOTALL | Pattern.CASE_INSENSITIVE)
	public void 独自の例外テストとTestアノテーションの組み合わせ()
	{
		throw new RuntimeException("FUGA HOGE");
	}

	@Ignore
	@Test(expected = Throwable.class)
	@ExceptionTest(
			exceptionClass = RuntimeException.class,
			exceptionPattern = "fuga.*hoge",
			exceptionPatternFlags = Pattern.DOTALL | Pattern.CASE_INSENSITIVE)
	public void 独自の例外テストとTestアノテーションのexpectedを組み合わせるとダメ()
	{
		throw new RuntimeException("FUGA HOGE");
	}

	@Test
	public void a1順番確認用()
	{
		assertThat("HOGE", is("HOGE"));
	}
	@Test
	public void a2順番確認用()
	{
		assertThat("HOGE", is("HOGE"));
	}
}

HOGEをMOGEに変えたりするとちゃんとパターンに一致してないよ、とか、
例外を無理やりthrowすると、期待してる例外と違うよ、みたいな例外が出て失敗してくれます。


ただ、1件どうしても通らないケースがあるので@Ignoreしました。
というのも、これだとTestのexpectedにひっかかるので、例外を握りつぶしてしまい、最後に行われる独自のExceptionCheckStatementで、正常終了してしまう(compleate = true)ので、その後のAssertionErrorが出てしまう、というバグっぽいもんでした。
methodBlockを組みなおして、例外周りの確認は1か所で巻き取っちゃえば良いのですが、面倒なのでやりません。

まとめ。

こんな感じに出来上がってるものに手を加えると良いことなんてない、という良い例ということがわかりました。


応用としては、DBUnitを使ってやるようなテストの前処理を、
アノテーションにテスト用データのファイルパスを書くだけで巻き取れちゃう辺りですかね。


でもそれぐらいなら、@Rule書けばできそう。(この記事)何の意味もないよねー。