CLOVER🍀

That was when it all began.

Quarkusでのテストを書いてみる

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

Quarkusでのテストのやり方、書き方を覚えてみようかなということで。

こちらのガイドに沿って、見ていきます。

Quarkus - Testing Your Application

環境

今回の環境は、こちらです。

$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment (build 11.0.10+9-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.10+9-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.10, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-66-generic", arch: "amd64", family: "unix"

Quarkusは、1.12.1.Finalを使用します。

お題

以下のお題で行います。

  • SmallRye Mutinyを使う
  • CDI管理Beanのテストをする
  • JAX-RSリソースクラスのテストをする
  • 設定ファイルの項目を読み込み、かつテスト時に値を切り替える
  • ネイティブイメージは対象外とする

なので、テストのガイド以外にも次のようなガイドも参照して書いています。

Quarkus - Getting started with Reactive

Quarkus - Writing JSON REST Services

Quarkus - Configuring Your Application

Quarkus - Configuration Reference Guide

プロジェクトを作成する

では、まずはプロジェクトを作成します。

$ mvn io.quarkus:quarkus-maven-plugin:1.12.1.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=resteasy-testing \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-mutiny,resteasy-jackson"

Extensionは、resteasy-mutiny、resteasy-jacksonの2つにしました。

-----------
selected extensions: 
- io.quarkus:quarkus-resteasy-jackson
- io.quarkus:quarkus-resteasy-mutiny


applying codestarts...
🔠 java
🧰 maven
🗃 quarkus
📜 config-properties
🛠 dockerfiles
🛠 maven-wrapper
🐒 resteasy-jackson-example

作成されたディレクトリ内に移動。

$ cd resteasy-testing

pom.xmlに書かれている依存関係は、こちらです。

pom.xml

  <dependencies>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-jackson</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-mutiny</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-arc</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

テスト回りは、quarkus-junit5が書かれているのみです。

    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>

ガイドを見ると、REST-assuredというものも使うようなので、dependencyに追加します。

Testing Your Application / Recap of HTTP based Testing in JVM mode

    <dependency>
      <groupId>io.rest-assured</groupId>
      <artifactId>rest-assured</artifactId>
      <scope>test</scope>
    </dependency>

REST-assuredはQuarkusのBOMに入っているので、バージョンの指定は不要です。

https://github.com/quarkusio/quarkus/blob/1.12.1.Final/bom/application/pom.xml#L2681-L2704

srcディレクトリの中を見てみます。

$ find src -type f
src/main/docker/Dockerfile.native-distroless
src/main/docker/Dockerfile.jvm
src/main/docker/Dockerfile.native
src/main/docker/Dockerfile.legacy-jar
src/main/resources/META-INF/resources/index.html
src/main/resources/application.properties
src/main/java/org/littlewings/resteasyjackson/JacksonResource.java
src/main/java/org/littlewings/resteasyjackson/MyObjectMapperCustomizer.java

resteasyjacksonという不思議なパッケージがありますね。

こういったファイルが生成される元は、こちらのようです。

https://github.com/quarkusio/quarkus/tree/1.12.1.Final/devtools/platform-descriptor-json/src/main/resources/codestarts/quarkus

中身を見ましたが、今回は要らない気がするので削除。

$ rm -rf src/main/java/org/littlewings/resteasyjackson

なお、削除したファイルの元はこちらにあります。

https://github.com/quarkusio/quarkus/tree/1.12.1.Final/devtools/platform-descriptor-json/src/main/resources/codestarts/quarkus/examples/resteasy-jackson-example/java/src/main/java/org/acme/resteasyjackson

気になる方は、中身をどうぞ。

アプリケーションを書く

では、テスト対象となるアプリケーションを書きましょう。

お題は書籍ということで。

src/main/java/org/littlewings/testing/entity/Book.java

package org.littlewings.testing.entity;

public class Book {
    String isbn;
    String title;
    int price;

    public static Book create(String isbn, String title, int price) {
        Book book = new Book();

        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

こちらを永続化して保持するものを持ちたいところですが、今回はConcurrentHashMapで持つことにします。
このクラスは、CDI管理Beanとして定義します。

src/main/java/org/littlewings/testing/repository/InMemoryBookRepository.java

package org.littlewings.testing.repository;

import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import javax.enterprise.context.ApplicationScoped;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import org.littlewings.testing.entity.Book;

@ApplicationScoped
public class InMemoryBookRepository {
    ConcurrentMap<String, Book> books = new ConcurrentHashMap<>();

    public Uni<Book> insert(Book book) {
        return Uni.createFrom().item(books.put(book.getIsbn(), book)).map(b -> book);
    }

    public Uni<Book> findByIsbn(String isbn) {
        return Uni.createFrom().item(books.get(isbn));
    }

    public Multi<Book> findAll() {
        return Multi
                .createFrom()
                .iterable(books.values().stream().sorted(Comparator.comparingInt(Book::getPrice).reversed()).collect(Collectors.toList()));
    }

    public Uni<Integer> size() {
        return Uni.createFrom().item(books.size());
    }

    public Uni<Book> delete(String isbn) {
        return Uni.createFrom().item(books.remove(isbn));
    }

    public Uni<Void> clear() {
        return Uni.createFrom().voidItem().onItem().invoke(() -> books.clear());
    }
}

2つのクラスを使用する、JAX-RSリソースクラス。簡単な読み書きができるだけですね。

src/main/java/org/littlewings/testing/rest/BookResource.java

package org.littlewings.testing.rest;

import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import org.littlewings.testing.entity.Book;
import org.littlewings.testing.repository.InMemoryBookRepository;

@Path("book")
public class BookResource {
    @Inject
    InMemoryBookRepository bookRepository;

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> find(@PathParam("isbn") String isbn) {
        return bookRepository.findByIsbn(isbn);
    }

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> findAll() {
        return bookRepository.findAll();
    }

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> put(@PathParam("isbn") String isbn, Book book) {
        return bookRepository.insert(book);
    }

    @DELETE
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Response> delete(@PathParam("isbn") String isbn) {
        return bookRepository
                .delete(isbn)
                .onItem()
                .transform(b -> b != null ? Response.Status.NO_CONTENT : Response.Status.NOT_FOUND)
                .onItem()
                .transform(status -> Response.status(status).build());
    }
}

設定ファイルに項目も定義しましょう。

src/main/resources/application.properties

app.config.message1=Hello World!!
app.config.message2=Hello Quarkus!!
app.config.message3=Wow!!

定義した項目を返すJAX-RSリソースクラス。

src/main/java/org/littlewings/testing/rest/ConfigResource.java

package org.littlewings.testing.rest;

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import org.eclipse.microprofile.config.inject.ConfigProperty;

@Path("config")
public class ConfigResource {
    @ConfigProperty(name = "app.config.message1")
    String message1;

    @ConfigProperty(name = "app.config.message2")
    String message2;

    @ConfigProperty(name = "app.config.message3")
    String message3;

    @GET
    @Path("message1")
    @Produces(MediaType.TEXT_PLAIN)
    public String message1() {
        return message1;
    }

    @GET
    @Path("message2")
    @Produces(MediaType.TEXT_PLAIN)
    public String message2() {
        return message2;
    }

    @GET
    @Path("message3")
    @Produces(MediaType.TEXT_PLAIN)
    public String message3() {
        return message3;
    }
}

軽く、動作確認しましょう。

$ mvn package
$ java -jar target/quarkus-app/quarkus-run.jar

データの登録。

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4295008477 -d '{ "isbn": "978-4295008477", "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", "price": 2860 }'
{"isbn":"978-4295008477","title":"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]","price":2860}k

取得。

$ curl localhost:8080/book/978-4295008477
{"isbn":"978-4295008477","title":"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]","price":2860}


$ curl localhost:8080/book
[{"isbn":"978-4295008477","title":"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]","price":2860}]

設定ファイルの項目取得。

$ curl localhost:8080/config/message1
Hello World!!


$ curl localhost:8080/config/message2
Hello Quarkus!!


$ curl localhost:8080/config/message3
Wow!!

OKですね。では、これらのテストを書いていきましょう。

Quakusでのテストを書く

Quarkusでのテストに関するガイドは、こちらになります。

Quarkus - Testing Your Application

Maven依存関係としてquarkus-junit5は必須で、rest-assuredはHTTPに関するテストを行う場合に必要に応じて追加、という感じですね。

また、Maven Surefire Pluginの設定として、JBoss Log Managerの設定を入れるようにします。といってもMavenプロジェクトを
作った段階でおの設定は入っていますが。

          <plugin>
            <artifactId>maven-failsafe-plugin</artifactId>
            <version>${surefire-plugin.version}</version>
            <executions>
              <execution>
                <goals>
                  <goal>integration-test</goal>
                  <goal>verify</goal>
                </goals>
                <configuration>
                  <systemPropertyVariables>
                    <native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
                    <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
                    <maven.home>${maven.home}</maven.home>
                  </systemPropertyVariables>
                </configuration>
              </execution>
            </executions>
          </plugin>

実際にテストを書いていく際に基本となるのは、@QuarkusTestというアノテーションのようです。

CDI管理Beanのテストを書く

ガイドに書かれている順とは異なりますが、最初にCDI管理Beanのテストを書いてみましょう。

Testing Your Application / Injection into tests

作成したテストコードは、こちら。

src/test/java/org/littlewings/testing/repository/InMemoryBookRepositoryTest.java

package org.littlewings.testing.repository;

import java.util.List;
import javax.inject.Inject;

import io.quarkus.test.junit.QuarkusTest;
import io.smallrye.mutiny.helpers.test.AssertSubscriber;
import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.littlewings.testing.entity.Book;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;

@QuarkusTest
public class InMemoryBookRepositoryTest {
    @Inject
    InMemoryBookRepository bookRepository;

    @BeforeEach
    public void setup() {
        List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4295008583", "マイクロサービスパターン[実践的システムデザインのためのコード解説]", 5280),
                Book.create("978-1492062653", "Quarkus Cookbook: Kubernetes-optimized Java Solutions", 6376),
                Book.create("978-4295007753", "クラウドネイティブ・アーキテクチャ 可用性と費用対効果を極める次世代設計の原則", 4290)
        ).forEach(b -> bookRepository.insert(b).subscribe().with(v -> {
        }));
    }

    @AfterEach
    public void teardown() {
        bookRepository.clear().subscribe().with(v -> {
        });
    }

    @Test
    public void findByIsbnTest() {
        UniAssertSubscriber<Book> subscriber =
                bookRepository.findByIsbn("978-4295008477").subscribe().withSubscriber(UniAssertSubscriber.create());

        Book book =
                subscriber
                        .assertCompleted()
                        .getItem();

        assertThat(book.getIsbn(), is("978-4295008477"));
        assertThat(book.getTitle(), is("新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"));
        assertThat(book.getPrice(), is(2860));
    }

    @Test
    public void findAllTest() {
        UniAssertSubscriber<List<String>> isbnSubscriber =
                bookRepository
                        .findAll()
                        .map(Book::getIsbn)
                        .collect()
                        .asList()
                        .subscribe()
                        .withSubscriber(UniAssertSubscriber.create());

        isbnSubscriber
                .assertCompleted()
                .assertItem(
                        List.of(
                                "978-1492062653",  // price: 6379
                                "978-4295008583",  // price: 5280
                                "978-4295007753",  // price: 4290
                                "978-4295008477"   // price: 2860
                        )
                );

        AssertSubscriber<String> subscriber =
                bookRepository
                        .findAll()
                        .map(Book::getIsbn)
                        .subscribe()
                        .withSubscriber(AssertSubscriber.create(10));

        subscriber
                .assertCompleted()
                .assertItems(
                        "978-1492062653",  // price: 6379
                        "978-4295008583",  // price: 5280
                        "978-4295007753",  // price: 4290
                        "978-4295008477"   // price: 2860
                );
    }

    @Test
    public void putTest() {
        bookRepository
                .insert(Book.create("978-4295009795", "Kubernetes完全ガイド 第2版", 4400))
                .subscribe()
                .withSubscriber(UniAssertSubscriber.create());

        UniAssertSubscriber<Integer> subscriber =
                bookRepository.size().subscribe().withSubscriber(UniAssertSubscriber.create());

        subscriber.assertCompleted().assertItem(5);
    }

    @Test
    public void deleteTest() {
        bookRepository
                .delete("978-4295007753")
                .subscribe()
                .withSubscriber(UniAssertSubscriber.create())
                .assertCompleted();

        UniAssertSubscriber<Integer> subscriber =
                bookRepository.size().subscribe().withSubscriber(UniAssertSubscriber.create());

        subscriber.assertCompleted().assertItem(3);
    }
}

テストクラスには、@QuarkusTestアノテーションを付与します。

@QuarkusTest
public class InMemoryBookRepositoryTest {

この状態で、CDI管理Beanをふつうに@Injectすることができます。

    @Inject
    InMemoryBookRepository bookRepository;

InMemoryBookRepositoryクラスで持つデータは、テストの度に初期データ登録、削除するようにしています。

    @Inject
    InMemoryBookRepository bookRepository;

    @BeforeEach
    public void setup() {
        List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4295008583", "マイクロサービスパターン[実践的システムデザインのためのコード解説]", 5280),
                Book.create("978-1492062653", "Quarkus Cookbook: Kubernetes-optimized Java Solutions", 6376),
                Book.create("978-4295007753", "クラウドネイティブ・アーキテクチャ 可用性と費用対効果を極める次世代設計の原則", 4290)
        ).forEach(b -> bookRepository.insert(b).subscribe().with(v -> {
        }));
    }

    @AfterEach
    public void teardown() {
        bookRepository.clear().subscribe().with(v -> {
        });
    }

この範囲だと、基本的にはJUnit 5を使ったテストの話なのですが、SmallRye Mutiniyに関するテストに関してだけ少し書いて
おきましょう。

SmallRye Mutinyにテストに関する情報は、こちらに書かれています。

How can I write unit / integration tests?

Uniに対するテストの場合は、UniAssertSubscriberを使います。

    @Test
    public void findByIsbnTest() {
        UniAssertSubscriber<Book> subscriber =
                bookRepository.findByIsbn("978-4295008477").subscribe().withSubscriber(UniAssertSubscriber.create());

        Book book =
                subscriber
                        .assertCompleted()
                        .getItem();

        assertThat(book.getIsbn(), is("978-4295008477"));
        assertThat(book.getTitle(), is("新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"));
        assertThat(book.getPrice(), is(2860));
    }

Multiの場合は、AssertSubscriberを使います。以下は、findAllの結果を1度Uniに変換してアサーションしているものと、
Multiのままアサーションしているものです。

    @Test
    public void findAllTest() {
        UniAssertSubscriber<List<String>> isbnSubscriber =
                bookRepository
                        .findAll()
                        .map(Book::getIsbn)
                        .collect()
                        .asList()
                        .subscribe()
                        .withSubscriber(UniAssertSubscriber.create());

        isbnSubscriber
                .assertCompleted()
                .assertItem(
                        List.of(
                                "978-1492062653",  // price: 6379
                                "978-4295008583",  // price: 5280
                                "978-4295007753",  // price: 4290
                                "978-4295008477"   // price: 2860
                        )
                );

        AssertSubscriber<String> subscriber =
                bookRepository
                        .findAll()
                        .map(Book::getIsbn)
                        .subscribe()
                        .withSubscriber(AssertSubscriber.create(10));

        subscriber
                .assertCompleted()
                .assertItems(
                        "978-1492062653",  // price: 6379
                        "978-4295008583",  // price: 5280
                        "978-4295007753",  // price: 4290
                        "978-4295008477"   // price: 2860
                );
    }

AssertSubscriberを使う時は、createの引数にリクエストする数を書いておかないと、省略すると0を指定したことになって
一切Subscribeしてくれません。最初、これにハマりました…。

        AssertSubscriber<String> subscriber =
                bookRepository
                        .findAll()
                        .map(Book::getIsbn)
                        .subscribe()
                        .withSubscriber(AssertSubscriber.create(10));

CDI管理Beanのテストは、こんな感じですね。

ちなみに、@Transactionalアノテーションをテストで使うこともできるようですが、自分はSmallRye Mutinyを中心に扱う予定なので、
この機能の出番はなさそうです。
※それとも、MicroProfile Context Propagationを使えばいいんでしょうか?

Testing Your Application / Tests and Transactions

それにしても、SmallRye Mutinyのサイト、以前からだいぶ雰囲気が変わりましたね…。

Mutiny!

テスト内でQuarkusにアクセスするURLを取得する

続いては、こちらです。

Testing Your Application / Injecting a URI

Testing Your Application / TestHTTPResource

@TestHTTPResourceというアノテーションを使用すると、QuarkusへアクセスするためのURLをインジェクションできます。

使い方は、こんな感じです。

src/test/java/org/littlewings/testing/rest/InjectUrlTest.java

package org.littlewings.testing.rest;

import java.net.URL;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

@QuarkusTest
public class InjectUrlTest {
    @TestHTTPResource
    URL rootUrl;

    @TestHTTPResource("book")
    URL pathSpecificUrl;

    @TestHTTPEndpoint(BookResource.class)
    @TestHTTPResource
    URL resourceClassSpecificUrl;

    @Test
    public void injectedUrlTest() {
        Assertions.assertEquals("http://localhost:8083/", rootUrl.toString());
        Assertions.assertEquals("http://localhost:8083/book", pathSpecificUrl.toString());
        Assertions.assertEquals("http://localhost:8083/book", resourceClassSpecificUrl.toString());
    }
}

@QuarkusTestアノテーションを付与したクラスに対して

@QuarkusTest
public class InjectUrlTest {

@TestHTTPResourceアノテーションを付与したURLを宣言すると、QuarkusへアクセスできるURL(http://localhost:[port])が
取得できます。

    @TestHTTPResource
    URL rootUrl;

パスを指定することもできます。

    @TestHTTPResource("book")
    URL pathSpecificUrl;

@TestHTTPEndpointアノテーションにJAX-RSリソースクラスを指定すると、JAX-RSリソースクラスに指定された
@Pathアノテーションの値も埋めてくれます。

    @TestHTTPEndpoint(BookResource.class)
    @TestHTTPResource
    URL resourceClassSpecificUrl;

このため、この機能は@Pathアノテーションに依存しており、JAX-RSリソースクラスに@Pathアノテーションを付与しなかった場合は

//@Path("book")
public class BookResource {

テスト実行時にエラーになります。

Caused by: java.lang.RuntimeException: Could not determine the endpoint path for class org.littlewings.testing.rest.BookResource to inject java.net.URL org.littlewings.testing.rest.UrlBookResourceTest.resourceClassSpecificUrl
    at io.quarkus.test.common.http.TestHTTPResourceManager.inject(TestHTTPResourceManager.java:78)

この3パターンで、@TestHTTPResourceアノテーションを使ってインジェクションしたURLの結果は以下になります。

    @Test
    public void injectedUrlTest() {
        Assertions.assertEquals("http://localhost:8083/", rootUrl.toString());
        Assertions.assertEquals("http://localhost:8083/book", pathSpecificUrl.toString());
        Assertions.assertEquals("http://localhost:8083/book", resourceClassSpecificUrl.toString());
    }

通常は、このURLを使ってテストコードを書いていくわけですが、今回はやりません。

ところで、Quakursでのテスト時に使われるデフォルトのポートは8081なのですが、今回こちらの設定を使って変更しています。

Testing Your Application / Controlling the test port

今回は以下のように定義しているのですが、これをどこで定義しているかはまた後で書きます。

quarkus.http.test-port=8083

とりあえず、今回のテストの間は、テストにおけるQuarkusのリッスンポートは8083となります。

REST-assuredを使ってテストする

先ほどは@TestHTTPResourceアノテーションを使って、QuarkusにアクセスするためのURLを取得しましたが、この方法だと
HTTPのテストに関するサポートがなにもありません。

もっと抽象度の高いテストの方法として、QuarkusではREST-assuredを使うことができます。

Testing Your Application / RESTassured

@TestHTTPEndpointと組み合わせて使うことで、JAX-RSリソースクラスへアクセスするテストを書きやすくなります。

REST-assured自体は独立したREST API向けのテストライブラリです。

REST Assured

使い方は、RestAssuredのJavadoc、Wikiを見るとだいたいわかります。

RestAssured - rest-assured 4.3.3 javadoc

Usage · rest-assured/rest-assured Wiki · GitHub

作成したテストコードは、こちら。

src/test/java/org/littlewings/testing/rest/BookResourceTest.java

package org.littlewings.testing.rest;

import java.util.List;
import javax.inject.Inject;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import org.apache.http.entity.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.littlewings.testing.entity.Book;
import org.littlewings.testing.repository.InMemoryBookRepository;

import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasSize;

@QuarkusTest
@TestHTTPEndpoint(BookResource.class)
public class BookResourceTest {
    @Inject
    InMemoryBookRepository bookRepository;

    @BeforeEach
    public void setup() {
        List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4295008583", "マイクロサービスパターン[実践的システムデザインのためのコード解説]", 5280),
                Book.create("978-1492062653", "Quarkus Cookbook: Kubernetes-optimized Java Solutions", 6376),
                Book.create("978-4295007753", "クラウドネイティブ・アーキテクチャ 可用性と費用対効果を極める次世代設計の原則", 4290)
        ).forEach(b -> bookRepository.insert(b).subscribe().with(v -> {
        }));
    }

    @AfterEach
    public void teardown() {
        bookRepository.clear().subscribe().with(v -> {
        });
    }

    @Test
    public void findByIsbnTest() {
        given()
                .pathParam("isbn", "978-4295008477")
                .when()
                .get("{isbn}")
                .then()
                .assertThat()
                .statusCode(200)
                .body("title", is("新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"))
                .body("price", is(2860));
    }

    @Test
    public void findAllTest() {
        given()
                .when()
                .get()
                .then()
                .assertThat()
                .statusCode(200)
                .body("price", is(List.of(6376, 5280, 4290, 2860)));
    }

    @Test
    public void putTest() {
        given()
                .pathParam("isbn", "978-4295009795")
                .contentType(ContentType.APPLICATION_JSON.getMimeType())
                .body(Book.create("978-4295009795", "Kubernetes完全ガイド 第2版", 4400))
                .when()
                .put("{isbn}")
                .then()
                .assertThat()
                .statusCode(200);

        given()
                .when()
                .get()
                .then()
                .assertThat()
                .statusCode(200)
                .body("price",  hasSize(5));  // 4 + 1
    }

    @Test
    public void deleteTest() {
        given()
                .pathParam("isbn", "978-4295008583")
                .when()
                .delete("{isbn}")
                .then()
                .assertThat()
                .statusCode(204);

        given()
                .when()
                .get()
                .then()
                .assertThat()
                .statusCode(200)
                .body("price",  hasSize(3));  // 4 - 1
    }
}

テストクラスに、@QuarkusTestアノテーションと、テスト対象のJAX-RSリソースクラスを指定して@TestHTTPEndpoint
アノテーションを付与します。

@QuarkusTest
@TestHTTPEndpoint(BookResource.class)
public class BookResourceTest {

テストデータは、CDI管理Beanのテストの時と同様、テストごとに登録、削除するようにしています。

    @Inject
    InMemoryBookRepository bookRepository;

    @BeforeEach
    public void setup() {
        List.of(
                Book.create("978-4295008477", "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]", 2860),
                Book.create("978-4295008583", "マイクロサービスパターン[実践的システムデザインのためのコード解説]", 5280),
                Book.create("978-1492062653", "Quarkus Cookbook: Kubernetes-optimized Java Solutions", 6376),
                Book.create("978-4295007753", "クラウドネイティブ・アーキテクチャ 可用性と費用対効果を極める次世代設計の原則", 4290)
        ).forEach(b -> bookRepository.insert(b).subscribe().with(v -> {
        }));
    }

    @AfterEach
    public void teardown() {
        bookRepository.clear().subscribe().with(v -> {
        });
    }

あとは、こんな感じでJAX-RSリソースクラスへHTTPリクエストを実行し、アサーションすることができます。

    @Test
    public void findByIsbnTest() {
        given()
                .pathParam("isbn", "978-4295008477")
                .when()
                .get("{isbn}")
                .then()
                .assertThat()
                .statusCode(200)
                .body("title", is("新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"))
                .body("price", is(2860));
    }

REST-assuredは初めて使ったのですが、bodyでJSONのパスを指定してアサーションしたりできて便利ですね。

パスにマッチする要素が複数あった場合は、コレクションとしてアサーションできます。

    @Test
    public void findAllTest() {
        given()
                .when()
                .get()
                .then()
                .assertThat()
                .statusCode(200)
                .body("price", is(List.of(6376, 5280, 4290, 2860)));
    }

テスト時に設定を変える

最後は、テスト時に設定ファイルで指定したプロパティの値を変更してみましょう。

もともと、Quarkusのデフォルトの設定ファイルに以下の定義を書いていました。

src/main/resources/application.properties

app.config.message1=Hello World!!
app.config.message2=Hello Quarkus!!
app.config.message3=Wow!!

テスト時はこの値の一部を変更したい、というシチュエーションを考えてみます。

ドキュメントを見ていると、どうやらProfileというものがあるようです。

Testing Your Application / Testing Different Profiles

テストを実行してみると

$ mvn test

どうやらtestというProfileになっているようです。

2021-03-07 00:42:24,190 INFO  [io.quarkus] (main) Quarkus 1.12.1.Final on JVM started in 2.173s. Listening on: http://localhost:8083
2021-03-07 00:42:24,196 INFO  [io.quarkus] (main) Profile test activated. 
2021-03-07 00:42:24,197 INFO  [io.quarkus] (main) Installed features: [cdi, mutiny, resteasy, resteasy-jackson, resteasy-mutiny, smallrye-context-propagation

ここで、以下のドキュメントを見てみます。

Configuring Your Application / Configuration Profiles

デフォルトでは、3つのProfileがあるようです。

  • dev - Activated when in development mode (i.e. quarkus:dev)
  • test - Activated when running tests
  • prod - The default profile when not running in development or test mode

そして、%Profile名.[プロパティ]という記載で対象のProfileで動作している時に有効なプロパティを設定できるようです。

たとえば、こんな感じですね。
※実際には、このようには変更していません(例です)

src/main/resources/application.properties

app.config.message1=Hello World!!
app.config.message2=Hello Quarkus!!
app.config.message3=Wow!!


%test.app.config.message2=Test Hello Quarkus!!

これでもいいのですが、テスト用の設定をsrc/main/resourcesの方に書くのはちょっとな、と。

なので、テスト用の設定ファイルを作成したいと思います。Configuration for MicroProfileのAPIで、カスタムの設定ファイルを
追加することができます。

Configuration Reference / Custom configuration sources

GitHub - eclipse/microprofile-config: MicroProfile Configuration Feature

こんな設定ファイルにしました。

src/test/resources/test-application.properties

config_ordinal = 300

quarkus.http.test-port=8083

%test.app.config.message2=Test Hello Quarkus!!
app.config.message3=Oops!!

この設定フィアルを読むようなConfigSourceProviderインターフェースの実装クラスを作成して

src/test/java/org/littlewings/testing/config/TestApplicationPropertiesConfigSourceProvider.java

package org.littlewings.testing.config;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import io.smallrye.config.PropertiesConfigSource;
import org.eclipse.microprofile.config.spi.ConfigSource;
import org.eclipse.microprofile.config.spi.ConfigSourceProvider;

public class TestApplicationPropertiesConfigSourceProvider implements ConfigSourceProvider {
    @Override
    public Iterable<ConfigSource> getConfigSources(ClassLoader forClassLoader) {
        return List
                .of("test-application.properties")
                .stream()
                .map(forClassLoader::getResource)
                .filter(Objects::nonNull)
                .map(path -> {
                    try {
                        return new PropertiesConfigSource(path);
                    } catch (IOException e) {
                        throw new UncheckedIOException(e);
                    }
                })
                .collect(Collectors.toList());
    }
}

以下のファイルを作り、作成したConfigSourceProviderインターフェースの実装クラス名を書いておきます。

src/test/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSourceProvider

org.littlewings.testing.config.TestApplicationPropertiesConfigSourceProvider

また、追加した設定ファイルに書いているconfig_ordinalという値は、優先度です。

src/test/resources/test-application.properties

config_ordinal = 300

quarkus.http.test-port=8083

%test.app.config.message2=Test Hello Quarkus!!
app.config.message3=Oops!!

今回、%test.app.config.message2というProfile別の指定と、application.propertiesでの定義とまったく同じキーである
app.config.message3を使い、両方ともapplication.propertiesより優先されることを確認します。

application.properties自体がどのような優先度になっているかですが、これはソースコードを見るとわかります。

JARファイルの中の場合、250。

https://github.com/quarkusio/quarkus/blob/1.12.1.Final/core/runtime/src/main/java/io/quarkus/runtime/configuration/ApplicationPropertiesConfigSource.java#L57

META-INF/microprofile-config.propertiesファイルとして登録した場合、240。

https://github.com/quarkusio/quarkus/blob/1.12.1.Final/core/runtime/src/main/java/io/quarkus/runtime/configuration/ApplicationPropertiesConfigSource.java#L80

ファイルシステム上の場合、260。

https://github.com/quarkusio/quarkus/blob/1.12.1.Final/core/runtime/src/main/java/io/quarkus/runtime/configuration/ApplicationPropertiesConfigSource.java#L100

なので、新しく作成したtest-application.propertiesファイルの優先度はconfig_ordinal = 300とし、application.propretiesよりも
高く設定しています。

あとは、テストを書くだけです。こちらも、REST-assuredを使ってテストを書きます。

src/test/java/org/littlewings/testing/rest/ConfigResourceTest.java

package org.littlewings.testing.rest;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;

@QuarkusTest
@TestHTTPEndpoint(ConfigResource.class)
public class ConfigResourceTest {
    @Test
    public void message1Test() {
        given()
                .when()
                .get("message1")
                .then()
                .assertThat()
                .statusCode(200)
                .body(is("Hello World!!"));
    }

    @Test
    public void message2Test() {
        given()
                .when()
                .get("message2")
                .then()
                .assertThat()
                .statusCode(200)
                .body(is("Test Hello Quarkus!!"));
    }

    @Test
    public void message3Test() {
        given()
                .when()
                .get("message3")
                .then()
                .assertThat()
                .statusCode(200)
                .body(is("Oops!!"));
    }
}

このテストに書いた通り、test-application.propertiesで定義したプロパティについては、そちらの方が優先されていることが
確認できます。

今回確認した内容は、こんな感じです。

テストに関する話題で、扱わなかったこと

テストのガイドに載っていて、今回扱わなかったのはこのあたりです。

Testing Your Application / Applying Interceptors to Tests

Testing Your Application / Tests and Transactions

Testing Your Application / Enrichment via QuarkusTest*Callback

Testing Your Application / Testing Different Profiles

Testing Your Application / Mock Support

Testing Your Application / Starting services before the Quarkus application starts

Testing Your Application / Native Executable Testing

Testing Your Application / Running @QuarkusTest from an IDE

一部、少し触れたものもありますが、だいたいこんな感じです。

モック、トランザクション、Profile、テストの前に別のサービスを動かす、あたりはそのうち使うことになるかもなぁと思ったり。

今回は、こんなところでおしまい。