Road to JUnit 5 (はじめてのExtension Model編)

Java, JUnit, プログラミング, 開発環境・ツール

こんにちは、ソリューション開発部の本間です。

この記事は アプレッソ 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 を題材にします。

  1. まずは仕組み無しのコードを書いて
  2. JUnit 4 で仕組みを作ります
  3. そして、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 を利用できるようになります! 新しい道具を揃えて、使い方を身につけて、より良い成果物を作っていくことは、プログラマの喜びではないでしょうか。 良いテストコードを書いて、良い製品を作りましょう!

ではまた。

  • 株式会社アークシステムの来訪管理・会議室予約システム BRoomHubs
  • 低コスト・短納期で提供するまるごとおまかせZabbix