これは、なにをしたくて書いたもの?
ちょっと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は以下の3つの方法で利用できます。
- テストクラスやメソッドに
@ExtendWith
アノテーションで指定する - テストクラスのフィールド(インスタンスフィールド、staticフィールド)に
@RegisterExtension
アノテーションで指定する - ServiceLoaderの仕組みで自動登録する(デフォルトでは無効で、システムプロパティ
junit.jupiter.extensions.autodetection.enabled
をtrue
にして有効化する)
JUnit 5 User Guide / Extension Model / Registering Extensions
Extensionを使うと、こういうことができるようです。
- テストを実行するかどうかの条件指定
- テストインスタンス構築時の事前コールバック
- テストクラスのインスタンスを作成するファクトリー
- テストインスタンス構築後の後処理
- テストインスタンスを破棄する前のコールバック
- テスト実行時のパラメーター解決
- テスト実行結果の処理
- テストにライフサイクルに応じたコールバック(
BeforeAllCallback
、BeforeEachCallback
、BeforeTestExecutionCallback
、AfterTestExecutionCallback
、AfterEachCallback
、AfterAllCallback
) - 例外処理
- テストメソッド呼び出しのインターセプト
- テストテンプレートの呼び出し時のコンテキストの提供
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
を使うことで状態の保持もできるようです。
- ExtensionContext.Store (JUnit 5.10.3 API)
- JUnit 5 User Guide / Extension Model / Keeping State in Extensions
標準で提供されている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!!"); } } }
@BeforeEach
でJAX-RSサーバーを起動してテストを行い、@AfterEach
でJAX-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.Store
はExtensionContext.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
で取得できます。
種類としては以下があり、階層になっているようです。
- https://github.com/junit-team/junit5/blob/r5.10.3/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterEngineExtensionContext.java
- https://github.com/junit-team/junit5/blob/r5.10.3/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassExtensionContext.java
- https://github.com/junit-team/junit5/blob/r5.10.3/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodExtensionContext.java
- https://github.com/junit-team/junit5/blob/r5.10.3/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestTemplateExtensionContext.java
- https://github.com/junit-team/junit5/blob/r5.10.3/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/DynamicExtensionContext.java
先ほど作成したExtensionだとTestInstancePostProcessor
インターフェースの引数で渡される実体はClassExtensionContext
に
なっていて、BeforeEachCallbackや
AfterEachCallbackインターフェースでは
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のテストコードを書くうえで把握しておいた方がいい内容だとは思うので、これを機に使った方が良さそうなところでは使っていこうと
思います。