CLOVER🍀

That was when it all began.

JUnit 5のExtension Modelを試す

これは、なにをしたくて書いたもの?

ちょっとJUnit 5の拡張(Extension Model)について知っておきたいなと思いまして。

JUnit 5のExtension Model

JUnit 5を拡張する機能がExtension Modelです。

JUnit 5 User Guide / Extension Model

参考)

JUnit 5の拡張機能を完全にマスターした - Speaker Deck

利用者から見ると、@ExtendWithアノテーションで付けられているものですね。

@ExtendWith(WebServerExtension.class)
class MyTests {
    // ...
}

ExtensionはExtensionインターフェースを実装することで作成します。

Extension自体はなにもメソッドが定義されていないマーカーインターフェースですが、様々な目的のためにこのインターフェースを拡張した
インターフェースが用意されています。

Extension (JUnit 5.10.3 API)

作成したExtensionは以下の3つの方法で利用できます。

JUnit 5 User Guide / Extension Model / Registering Extensions

Extensionを使うと、こういうことができるようです。

Extensionがどういうタイミングで実行されるかは、こちらを見るのがよいでしょう。

JUnit 5 User Guide / Extension Model / Relative Execution Order of User Code and Extensions

複数のExtensionを適用した場合は、@ExtendWithアノテーションを使っている場合はソースコードでの宣言順

Extensions registered declaratively via @ExtendWith at the class level, method level, or parameter level will be executed in the order in which they are declared in the source code.

JUnit 5 User Guide / Extension Model / Registering Extensions / Declarative Extension Registration

@RegisterExtensionアノテーションおよび@ExtendWithアノテーションを使用している場合は、一定の順序付けは行われるようですが
@Orderアノテーションで明示的に指定することもできるようです。

By default, extensions registered programmatically via @RegisterExtension or declaratively via @ExtendWith on fields will be ordered using an algorithm that is deterministic but intentionally nonobvious. This ensures that subsequent runs of a test suite execute extensions in the same order, thereby allowing for repeatable builds. However, there are times when extensions need to be registered in an explicit order. To achieve that, annotate @RegisterExtension fields or @ExtendWith fields with @Order.

JUnit 5 User Guide / Extension Model / Registering Extensions / Programmatic Extension Registration

また、ExtensionContext.Storeを使うことで状態の保持もできるようです。

標準で提供されているExtensionはこちら。

JUnit 5 User Guide / Writing Tests / Built-in Extensions

ドキュメントを見るのはこれくらいにして、Extensionを作ってみましょう。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.3 2024-04-16
OpenJDK Runtime Environment (build 21.0.3+9-Ubuntu-1ubuntu122.04.1)
OpenJDK 64-Bit Server VM (build 21.0.3+9-Ubuntu-1ubuntu122.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.8 (36645f6c9b5079805ea5009217e36f2cffd34256)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 21.0.3, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-113-generic", arch: "amd64", family: "unix"

お題とテスト対象

Java SE環境でJakarta RESTful Web Services(JAX-RS)サーバーを動作させ、こちらに対するテストコードを書いていきます。

Maven依存関係など。

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-core</artifactId>
            <version>6.2.9.Final</version>
        </dependency>
        <dependency>
            <groupId>org.jboss.resteasy</groupId>
            <artifactId>resteasy-undertow-cdi</artifactId>
            <version>6.2.9.Final</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.26.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

リソースクラス。

src/main/java/org/littlewings/junit5/HelloResource.java

package org.littlewings.junit5;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;

@Path("/hello")
public class HelloResource {
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String message(@QueryParam("word") String word) {
        return String.format("Hello %s!!", word != null ? word : "World");
    }
}

Applicationクラス。

src/main/java/org/littlewings/junit5/RestApplication.java

package org.littlewings.junit5;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

import java.util.Set;

@ApplicationPath("/")
public class RestApplication extends Application {
    @Override
    public Set<Class<?>> getClasses() {
        return Set.of(HelloResource.class);
    }
}

サーバー。

src/main/java/org/littlewings/junit5/JaxrsServer.java

package org.littlewings.junit5;

import jakarta.ws.rs.SeBootstrap;
import org.jboss.logging.Logger;

import java.util.concurrent.ExecutionException;

public class JaxrsServer {
    private Logger logger = Logger.getLogger(JaxrsServer.class);

    private SeBootstrap.Configuration configuration;

    private SeBootstrap.Instance serverInstance;

    JaxrsServer(SeBootstrap.Configuration configuration) {
        this.configuration = configuration;
    }

    public static JaxrsServer create(String host, int port) {
        return new JaxrsServer(
                SeBootstrap.Configuration
                        .builder()
                        .host(host)
                        .port(port)
                        .build()
        );
    }

    public void start() {
        try {
            serverInstance =
                    SeBootstrap
                            .start(new RestApplication(), configuration)
                            .toCompletableFuture()
                            .get();

            logger.infof("server[%s:%d] startup.", configuration.host(), configuration.port());
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }

    public void stop() {
        try {
            serverInstance.stop().toCompletableFuture().get();

            logger.infof("server[%s:%d] shutdown", configuration.host(), configuration.port());
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }
}

これに対する、簡単なテストを考えます。

src/test/java/org/littlewings/junit5/SimpleJaxrsTest.java

package org.littlewings.junit5;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;

class SimpleJaxrsTest {
    private JaxrsServer server;

    @BeforeEach
    void setUp() {
        server = JaxrsServer.create("localhost", 8080);
        server.start();
    }

    @AfterEach
    void tearDown() {
        server.stop();
    }

    @Test
    void getMessage() throws IOException, InterruptedException {
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/hello")).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello World!!");
        }
    }

    @Test
    void withQuery() throws IOException, InterruptedException {
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/hello?word=Jaxrs")).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello Jaxrs!!");
        }
    }
}

@BeforeEachJAX-RSサーバーを起動してテストを行い、@AfterEachJAX-RSサーバーを終了するというものにしています。

つまり、テストの都度サーバーを起動・停止します。ここにExtensionを導入していってみましょう。

はじめてのJUnit 5 Extension

もとのテストコードの内容をどうExtensionに置き換えるかはいろいろパターンがある気がしますが、今回は素直に@BeforeEach
@AfterEachを置き換えましょう。つまりテストのライフサイクルコールバックを使います。

JUnit 5 User Guide / Extension Model / Test Lifecycle Callbacks

こんな感じのExtensionを作成。

src/test/java/org/littlewings/junit5/JaxrsServerExtension.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class JaxrsServerExtension implements BeforeEachCallback, AfterEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        String host = "localhost";
        int port = 8080;

        JaxrsServer server = JaxrsServer.create(host, port);
        server.start();
        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", server);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        JaxrsServer server =
                (JaxrsServer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).remove("jaxrsServer");
        if (server != null) {
            server.stop();
        }
    }
}

BeforeEachCallbackインターフェースを実装することでテストおよび@BeforeEachの実行前に、AfterEachCallbackインターフェースを
実装することでテストおよび@AfterEachの実行後にコールバックで処理を行えます。

public class JaxrsServerExtension implements BeforeEachCallback, AfterEachCallback {

今回は、それぞれのタイミングでJAX-RSサーバーの起動と停止を行います。

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        String host = "localhost";
        int port = 8080;

        JaxrsServer server = JaxrsServer.create(host, port);
        server.start();
        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", server);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        JaxrsServer server =
                (JaxrsServer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).remove("jaxrsServer");
        if (server != null) {
            server.stop();
        }
    }

JAX-RSサーバーのインスタンスそのものはステートになりますが、これはExtensionContext.Storeに格納してメソッド間で持ち回ることに
します。

ExtensionContext.Store (JUnit 5.10.3 API)

beforeEachで登録して

        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", server);

afterEachで取得(削除)しています。

        JaxrsServer server =
                (JaxrsServer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).remove("jaxrsServer");

ExtensionContext.StoreExtensionContext.Namespaceという単位で区切られたMapのイメージで良さそうです。

このExtensionを使ってみましょう。

src/test/java/org/littlewings/junit5/UseExtensionJaxrsTest.java

package org.littlewings.junit5;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(JaxrsServerExtension.class)
public class UseExtensionJaxrsTest {
    @Test
    void getMessage() throws IOException, InterruptedException {
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/hello")).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello World!!");
        }
    }

    @Test
    void withQuery() throws IOException, InterruptedException {
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/hello?word=Jaxrs")).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello Jaxrs!!");
        }
    }
}

作成したExtensionを、@ExtendWithアノテーションに指定します。

@ExtendWith(JaxrsServerExtension.class)
public class UseExtensionJaxrsTest {

これで、このテストクラス内のテストの実行都度、JAX-RSサーバーの起動と停止が行われるようになりました。

JAX-RSサーバーのポートをテストクラスに設定してみる

もう少し、拡張してみましょう。テストコード側にJAX-RSサーバーのポートがハードコードされているのが気になるので、これを
追い出してExtensionから注入するようにしてみます。

具体的には、@LocalPortというアノテーションを付与したフィールドに設定するようにしてみましょう。

src/test/java/org/littlewings/junit5/LocalPort.java

package org.littlewings.junit5;

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

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LocalPort {
}

Extensionは先ほど作成したものを拡張してもよかったのですが、今回は別々にしたいと思います。

src/test/java/org/littlewings/junit5/InjectLocalPortExtension.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.platform.commons.support.AnnotationSupport;

import java.lang.reflect.Field;
import java.util.List;

public class InjectLocalPortExtension implements TestInstancePostProcessor {
    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
        List<Field> fields =
                AnnotationSupport.findAnnotatedFields(testInstance.getClass(), LocalPort.class);

        if (fields != null) {
            Field field = fields.getFirst();
            field.setAccessible(true);

            int port = 8080;
            field.set(testInstance, port);
        }
    }
}

今回はこちら(TestInstancePostProcessor)を使っています。

JUnit 5 User Guide / Extension Model / Test Instance Post-processing

これで2つのExtensionを作ったわけですが、この2つはセットで使うことになります。

両方指定するのもなんなので、メタアノテーションを作成しましょう。

src/test/java/org/littlewings/junit5/JaxrsServerTest.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.ExtendWith;

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

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(JaxrsServerExtension.class)
@ExtendWith(InjectLocalPortExtension.class)
public @interface JaxrsServerTest {
}

Meta-Annotations and Composed Annotations

これらを使用したテスト。

src/test/java/org/littlewings/junit5/UseMultipleExtensionJaxrsTest.java

package org.littlewings.junit5;

import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;

@JaxrsServerTest
public class UseMultipleExtensionJaxrsTest {
    @LocalPort
    private int port;

    @Test
    void getMessage() throws IOException, InterruptedException {
        System.out.printf("local port = %d%n", port);

        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create(String.format("http://localhost:%d/hello", port))).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello World!!");
        }
    }

    @Test
    void withQuery() throws IOException, InterruptedException {
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpRequest request =
                    HttpRequest.newBuilder().uri(URI.create(String.format("http://localhost:%d/hello?word=Jaxrs", port))).GET().build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

            assertThat(response.body()).isEqualTo("Hello Jaxrs!!");
        }
    }
}

これでExtensionを有効にして、ポートはExtensionから教えてもらいます。

@JaxrsServerTest
public class UseMultipleExtensionJaxrsTest {
    @LocalPort
    private int port;

ところで、ポートが2つのExtensionでハードコードになってしまいました。これもExtensionContext.Storeで持ち回るようにしてみましょう。

こうですね。ポートは9080にずらしてみました。

src/test/java/org/littlewings/junit5/InjectLocalPortExtension.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.platform.commons.support.AnnotationSupport;

import java.lang.reflect.Field;
import java.util.List;

public class InjectLocalPortExtension implements TestInstancePostProcessor {
    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
        List<Field> fields =
                AnnotationSupport.findAnnotatedFields(testInstance.getClass(), LocalPort.class);

        if (fields != null) {
            Field field = fields.getFirst();
            field.setAccessible(true);

            int port = 9080;
            field.set(testInstance, port);

            context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("localPort", port);
        }
    }
}

JAX-RSサーバーの起動および停止を行うExtensionでは、ポートをExtensionContext.Storeから取得し、なければ8080にするようにしました。

src/test/java/org/littlewings/junit5/JaxrsServerExtension.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class JaxrsServerExtension implements BeforeEachCallback, AfterEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        String host = "localhost";
        // int port = 8080;
        int port;

        Integer storedPort = (Integer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).get("localPort");
        if (storedPort != null) {
            port = storedPort;
        } else {
            port = 8080;
        }

        JaxrsServer server = JaxrsServer.create(host, port);
        server.start();
        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", server);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        JaxrsServer server =
                (JaxrsServer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).remove("jaxrsServer");
        if (server != null) {
            server.stop();
        }
    }
}

この置き換え方で、ここまでのテストは全部パスします。

ExtensionContextの階層とライフサイクル

ここからは、少しオマケ的な内容を。

ExtensionContextには階層があるようです。ExtensionContext#getParentで取得できます。

種類としては以下があり、階層になっているようです。

先ほど作成したExtensionだとTestInstancePostProcessorインターフェースの引数で渡される実体はClassExtensionContext
なっていて、BeforeEachCallbackAfterEachCallbackインターフェースではMethodExtensionContext`になっています。

この階層はExtensionContext.Storeの利用時には意識せずに済むようになっていて、階層をたどって検索などを行ってくれます。

あと使っている感じ、ExtensionContextはテストの実行単位(テストメソッド)で作成されると思った方がよさそうです。

Storeに保存したリソースを自動的にクローズする

ExtensionContext.Storeに保存したリソースですが、CloseableResourceとして登録することでExtensionContextの終了時に
クローズしてもらえるようになります。

An extension context store is bound to its extension context lifecycle. When an extension context lifecycle ends it closes its associated store. All stored values that are instances of CloseableResource are notified by an invocation of their close() method in the inverse order they were added in.

JUnit 5 User Guide / Extension Model / Keeping State in Extensions

ExtensionContext.Store.CloseableResource (JUnit 5.10.3 API)

今回の例でいくと、こうでしょうか。

src/test/java/org/littlewings/junit5/JaxrsServerExtension.java

package org.littlewings.junit5;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class JaxrsServerExtension implements BeforeEachCallback, AfterEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        String host = "localhost";
        // int port = 8080;
        int port;

        Integer storedPort = (Integer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).get("localPort");
        if (storedPort != null) {
            port = storedPort;
        } else {
            port = 8080;
        }

        JaxrsServer server = JaxrsServer.create(host, port);
        server.start();
        // context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", server);

        ExtensionContext.Store.CloseableResource wrappedServer = () -> server.stop();
        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", wrappedServer);
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        /*
        JaxrsServer server =
                (JaxrsServer) context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).remove("jaxrsServer");
        if (server != null) {
            server.stop();
        }
         */
    }
}

こちらですね。

        ExtensionContext.Store.CloseableResource wrappedServer = () -> server.stop();
        context.getStore(ExtensionContext.Namespace.create("jaxrs.test.server")).put("jaxrsServer", wrappedServer);

こうなるとafterEachの内容は要らなくなりましたが…。

このまま全テストを実行すると問題なく終了します。ということは、ExtensionContextはテストメソッドの終了時に破棄されていることに
なりますね(そうでないと利用するポートが重複するので)。

おわりに

JUnit 5のExtension Model、いわゆる拡張を試してみました。

なんとなく雰囲気は見ていたのですが、自分で実際に触ってみて理解が深まった感じがします。

Javaのテストコードを書くうえで把握しておいた方がいい内容だとは思うので、これを機に使った方が良さそうなところでは使っていこうと
思います。