

Road to JUnit 5 (はじめてのExtension Model編)
こんにちは、ソリューション開発部の本間です。
この記事は アプレッソ Advent Calendar 2017 の20日目です。アプレッソ Advent Calendar に協力会社も参加しても良いとのことで、書かせていただいています。
去る9月に JUnit 5 がリリースされました! 「今すぐ JUnit 5 でテストを書きたい!」そんな前のめりになる開発者っていますよね。わかります、私もそんな一人です。
ですが現場のテストコードは JUnit 4 で書かれており、加えてテスト用の仕組みも JUnit 4 用に作ってあり、JUnit 5 へ移行するにはそれなりの準備が必要です。
この記事ではそんな課題の1つ、JUnit 4 で活用していた @Rule の仕組みを、JUnit 5 向けにリメイクしたことを紹介します。
この記事で扱わないこと:
- JUnit 5 で assert 系メソッドの使い方や @Test の FQCN が変わったこと
- テストファーストなど
環境・ライブラリのバージョン:
- Java 8
- JUnit 4.12
- JUnit 5.02
JUnit 4 ではどうしていたか: テストでの共通処理を @Rule で仕組み化する
この記事では、テストコードでの Teardown を題材にします。
- まずは仕組み無しのコードを書いて
- JUnit 4 で仕組みを作ります
- そして、JUnit 5 へ仕組みを作り直します
という順で説明します。JUnit 5のことが気になるせっかちさんは、後半から読んでくださってOKです。
まずはベタに Teardown を書いてみる
テストコードで外部リソースなどを使う場合は、Teardown フェーズで外部リソースを元の状態に戻すことが基本です。 テストメソッドの中でリソースを元に戻そうとすると、まずは次のようなコードを考えるのではないでしょうか。(“Teardown” とコメントしてある箇所)
public class FacilityReservationTest {
@Test
public void 空室を予約できること_inlineTeardown() throws Exception {
// Setup
final User user = createUser("user1");
try {
final Facility room = createFacility("RoomA");
try {
// Exercise
final String title = "team meeting";
final String from = "2017-12-10 10:00";
final String to = "2017-12-10 11:00";
final ReservationResponse response = room.reserve(createRequest(user, title, from, to));
// Verify
assertEquals(ReservationResult.SUCCEEDED, response.getResult());
} finally {
// Teardown
deleteFacility(room);
}
} finally {
// Teardown
deleteUser(user);
}
}
// 以下略
}
ちゃんと動きそうなテストメソッドですが、teardownを確実にするためfinallyが二重になっているあたりが、ちょっと気がかりですね。 次に書くテストメソッドでも同じようにfinallyを書くのは避けたいな… という気持ちが湧いてきます。
レビューする立場に観点を変えてみると、本当に teardown できているよね!? という疑念を持ちそうです。 またテストメソッドに占めるテスト本体部分(Setup〜Exercise〜Verify)の割合が半分くらいであり、テストの主題がぼやけてしまっていることも気がかりです。
@Rule を作る: Teardown ロジックをテストメソッドから追い出す
そこで、Teardown をテストメソッドから追い出して、テストメソッドを読みやすく、かつ確実に Teardown する方法を考えます。 まず先のテストメソッドから Teardown を追い出してみます。
import static org.junit.Assert.assertEquals;
import org.junit.Rule;
import org.junit.Test;
public class FacilityReservationTest {
// テストメソッド終了時に登録しておいた処理を実行してくれる
@Rule
public final AutomatedTeardownRule teardowns_ = new AutomatedTeardownRule();
@Test
public void reserve_vacant_room() throws Exception {
// Setup
final User user = createUser("user1");
final Facility room = createFacility("RoomA");
// Exercise
final String title = "team meeting";
final String from = "2017-12-10 10:00";
final String to = "2017-12-10 11:00";
final SoldeblogExample.ReservationResponse response = room.reserve(createRequest(user, title, from, to));
// Verify
assertEquals(ReservationResult.SUCCEEDED, response.getResult());
}
private User createUser(final String name) {
final User user = newUser(name);
// userを削除する処理を、あらかじめ登録しておく
teardowns_.add(() -> deleteUser(user));
return user;
}
private Facility createFacility(final String name) {
final Facility facility = newFacility(name);
// facilityを削除する処理を、あらかじめ登録しておく
teardowns_.add(() -> deleteFacility(facility));
return facility;
}
// 以下略
}
Teardown ロジックを @Rule AutomatedTeardownRule に追い出しました。 Teardown に関する記述が消えただけですが、テストメソッドが読みやすくなりましたね!
また、リソースの作成と AutomatedTeardownRule への登録を一緒にやっているので、Teardown 漏れは無さそうだと安心できます。 追い出された Teardown はテストメソッドの外にあります。
import java.util.Deque;
import java.util.LinkedList;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
public class AutomatedTeardownRule implements TestRule {
private final Deque<AutoCloseable> tasks_ = new LinkedList<>();
@Override
public Statement apply(final Statement base, final Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try {
// ここでテストメソッドが実行される。
base.evaluate();
} finally {
// テストメソッド実行後に Teardown する。
// finally に置くことで、テストメソッドの成功・失敗にかかわらず Teardown する。
tearDown();
}
}
};
}
private void tearDown() {
while (!tasks_.isEmpty()) {
// 登録されたのとは逆順にTeardownする。
final AutoCloseable task = tasks_.removeLast();
try {
task.close();
} catch (final Exception e) {
// 全てのリソースを解放したいので、例外が起きてもTeardownを中断しない。
e.printStackTrace();
}
}
}
public void add(final AutoCloseable task) {
tasks_.add(task);
}
}
JUnit 4 では TestRule 実装クラスを @Rule にするとテストメソッド実行ごとに TestRule#apply メソッドが呼ばれる仕組みになっているため、上記コードではテストメソッドを実行し終える毎に tearDown が呼ばれます。 JUnit 4 利用時には、このように Teardown 機構とテストメソッドを分けることによって、
- テストメソッドの可読性向上と
- 開発者がテストメソッドに集中できるようにすることと
- Teardownの確実性
を獲得していました。
JUnit 5 ではどう実現する?
いよいよ JUnit 5 です。 JUnit 5 では、なんと @Rule の仕組みが廃止されてしまいました! な…なんだってーーー!!
Extension Model
代わりに導入されたのが、Extension Model という仕組みです。(以下 “Extension” と書きます) JUnit を拡張する仕組みとして JUnit 4 では Runner、@Rule, @ClassRule がありましたが、JUnit 5 では Extension に統一されました。 Extension で提供さえている拡張ポイントは10種類以上あり、この記事ではそのうち2つを取り上げます。
Extension を使うようテストコードを直す
JUnit 5 で Extension を使って書き直したテストコードです。
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(AutomatedTeardownExtension.class)
class FacilityReservationTest {
// テストメソッド終了時に登録しておいた処理を実行してくれる
private final AutomatedTeardown teardowns_;
FacilityReservationTest(final AutomatedTeardown teardowns) {
teardowns_ = teardowns;
}
@Test
void reserve_vacant_room() throws Exception {
// Setup
final User user = createUser("user1");
final Facility room = createFacility("RoomA");
// Exercise
final String title = "team meeting";
final String from = "2017-12-10 10:00";
final String to = "2017-12-10 11:00";
final ReservationResponse response = room.reserve(createRequest(user, title, from, to));
// Verify
assertEquals(ReservationResult.SUCCEEDED, response.getResult());
}
private User createUser(final String name) {
final User user = newUser(name);
// userを削除する処理を、あらかじめ登録しておく
teardowns_.add(() -> deleteUser(user));
return user;
}
private Facility createFacility(final String name) {
final Facility facility = newFacility(name);
// facilityを削除する処理を、あらかじめ登録しておく
teardowns_.add(() -> deleteFacility(facility));
return facility;
}
// 以下略
}
JUnit 4 でのテストコードと似ていますが、変わっている箇所があります:
- @ExtendWith がクラスに付きました。AutomatedTeardownExtension クラス(後述)によってテストコードを拡張することを示しています
- コンストラクタ引数に AutomatedTeardown が増えました。これは AutomatedTeardownExtension による仕業です
テストメソッド引数の AutomatedTeardown はインタフェースです。(このインタフェースを実装するコードは Extension 側にあります。)
public interface AutomatedTeardown {
void add(final AutoCloseable task);
}
TestRule を Extension Modelで作り直す
AutomatedTeardownRule(JUnit 4) を、Extension Model(JUnit 5) で作り直したコードです。
import java.util.Deque;
import java.util.LinkedList;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
public class AutomatedTeardownExtension implements AfterTestExecutionCallback, ParameterResolver {
private final ExtensionContext.Namespace namespace = ExtensionContext.Namespace.create(getClass());
private final Object STORE_KEY = AutomatedTeardown.class;
// ParameterResolver
// テストメソッドなどのパラメータに、任意のオブジェクトを渡したい場合はtrueを返す。
// ここではコンストラクタ引数 or テストメソッド引数の型が AutomatedTeardown だったらtrueを返しています。
@Override
public boolean supportsParameter(final ParameterContext parameterContext, final ExtensionContext extensionContext) {
return parameterContext.getParameter().getType() == AutomatedTeardown.class;
}
// ParameterResolver
// supportsParameterでtrueを返した場合に呼ばれる、テストメソッドなどのパラメータに渡すオブジェクトを返却する。
// ここではコンストラクタ引数 or テストメソッド引数が AutomatedTeardown だったら、AutomatedTeardownImpl が渡されるようにしています。
@Override
public Object resolveParameter(final ParameterContext parameterContext, final ExtensionContext extensionContext) {
final ExtensionContext.Store store = getStore(extensionContext);
final AutomatedTeardown o = store.getOrComputeIfAbsent(STORE_KEY, (v) -> new AutomatedTeardownImpl(), AutomatedTeardownImpl.class);
return o;
}
// AfterTestExecutionCallback
// テストメソッドを実行し終えたタイミングで呼ばれる。
// ここでは AutomatedTeardown へ add されたコードを実行しています。
@Override
public void afterTestExecution(final ExtensionContext context) throws Exception {
final ExtensionContext.Store store = getStore(context);
final AutomatedTeardownImpl o = store.get(STORE_KEY, AutomatedTeardownImpl.class);
if (o != null) {
o.tearDown();
}
}
private ExtensionContext.Store getStore(final ExtensionContext context) {
return context.getStore(namespace);
}
private static class AutomatedTeardownImpl implements AutomatedTeardown {
private final Deque<AutoCloseable> tasks_ = new LinkedList<>();
private void tearDown() {
while (!tasks_.isEmpty()) {
// 登録されたのとは逆順にTeardownする。
final AutoCloseable task = tasks_.removeLast();
try {
task.close();
} catch (final Exception e) {
// 全てのリソースを解放したいので、例外が起きてもTeardownを中断しない。
e.printStackTrace();
}
}
}
public void add(final AutoCloseable task) {
tasks_.add(task);
}
}
}
AutomatedTeardownExtension では、Extension Model の2つの拡張ポイント AfterTestExecutionCallback と ParameterResolver を使用しています。
- ParameterResolverの2つのメソッドでは、AutomatedTeardownをテストクラスへ渡すことを
- AfterTestExecutionCallbackのメソッドでは、テストメソッド終了時にAutomatedTeardown#tearDown を呼ぶことを
それぞれ実現しています。 拡張ポイントを含めた解説をコメント形式で記載したので、参照してください。 以上で、JUnit 4 での カスタム TestRule を、JUnit 5 向けに作り直すことができました!
パチパチパチパチ
補足
それ、junit-jupiter-migrationsupport でできるよ!
はい、そのとおりです。 junit-jupiter-migrationsupport は、JUnit 4 の Rule (の幾つか)を JUnit 5 でも利用できるようにするモジュールです。 実は、AutomatedTeardownRule を org.junit.rules.ExternalResource のサブクラスにすることで、JUnit 5 でも @Rule を使い続けることができるのです。 この記事では Extension Model を取り上げたかったため、junit-jupiter-migrationsupport には触れませんでした。 ちなみに junit-jupiter-migrationsupport は Extension Model を使って実現されています。Extension Model を利用するにあたって参考になると思うので、そんな方はソースコードを読むことをオススメします。
Automated Teardown って?
記事の題材として Teardown を仕組み化した “Automated Teardown” を扱いました。(読む人によっては唐突感があったかと思います。) Automated Teardown とは XUnit Test Patterns: Refactoring Test Code という書籍の22章「Fixture Teardown Patterns」で紹介されているテスト戦略の1つです。 機会がありましたら、書いてみたいと思います。
おわりに
Extension Modelすごい!
Extension Model には他にも機能が提供されていて、テストコードを改善するための仕組みを作り易くなっています。 個人的に大きな発見は、JUnit 4 で大きく不満に感じていたことが JUnit 5 で解決されていることに気が付いたことです。 これも機会がありましたら、そのあたりのことを書いてみたいと思います。
おわりにのおわりに
ライブラリのバージョンアップへの追従は、テストコードであってもたいへんです。 とは言え製品側のコードに比べればテストコード向けのライブラリはバージョンアップしやすい現場が多いと感じています。 紹介したように Rule を Extension で作り変えることで、これから書くテストコードでは JUnit 5 を利用できるようになります! 新しい道具を揃えて、使い方を身につけて、より良い成果物を作っていくことは、プログラマの喜びではないでしょうか。 良いテストコードを書いて、良い製品を作りましょう!
ではまた。