CLOVER🍀

That was when it all began.

Goでのビルド時に使う、-ldflagsフラグと-Xについて調べてみた(go tool link)

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

記事や書籍などで、以下のような記述を見かけます。

$ go build -ldflags '-X main.xxxx=....'

この-ldflagsと-Xの指定でプログラム内の値を変えているようなのですが、「変えられます」という情報以外のことを
あまり見かけないので調べてみることにしました。

環境

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

$ go version
go version go1.16 linux/amd64

Go 1.16ですね。

まずは試してみる

Goのプロジェクトを作成。

$ go mod init sample
go: creating new go.mod: module sample

こんなプログラムを用意。

main.go

package main

import (
    "fmt"
)

var (
    Message = "Hello World!!"
)

func main() {
    fmt.Printf("Message = %s\n", Message)
}

ビルド。

$ go build

実行。

$ ./sample 
Message = Hello World!!

このMessage変数の値を、ビルド時に変更してみましょう。

$ go build -ldflags '-X main.Message=Wow'

確かに変わりました。

$ ./sample
Message = Wow

ソースコードは変えていないのに、ビルド時のフラグ指定だけで変わりましたね。

スペースを含めたい場合は、変数名ごとクォートで囲えばよさそうです。

$ go build -ldflags '-X "main.Message=Hello Go!!!"'
$ ./sample
Message = Hello Go!!!

で、変えられることはわかったのですが、このフラグ、-ldflags自体の意味をもう少し調べたい、と。

go buildのヘルプを見る

まずは、go buildコマンドのヘルプを見てみます。

$ go help build
usage: go build [-o output] [build flags] [packages]

〜省略〜

-ldflagsについて見てみましょう。

  -ldflags '[pattern=]arg list'
        arguments to pass on each go tool link invocation.

go tool linkの呼び出しに渡す、と書かれています。

ところで、似たような考えでgo runのヘルプも見てみましょう。

$ go help run
usage: go run [build flags] [-exec xprog] package [arguments...]

〜省略〜

For more about build flags, see 'go help build'.
For more about specifying packages, see 'go help packages'.

See also: go build.

build flagsについてはgo buildを見て、と言っています。

ということは、-ldflagsも指定できそうですね。試してみましょう。

$ go run -ldflags '-X "main.Message=Hello Go!!!"' main.go
Message = Hello Go!!!

やっぱりできましたね。なるほど。

go testでも使えそうですね。今回は、確認まではしませんが…。

$ go help test
usage: go test [build/test flags] [packages] [build/test flags & test binary flags]

〜省略〜

For more about build flags, see 'go help build'.
For more about specifying packages, see 'go help packages'.

See also: go build, go vet.

以降は簡単のため、go runコマンドで-ldflagsフラグと-Xフラグを使っていこうと思います。

ドキュメントを見る

次は、goコマンドのドキュメントを見てみましょう。

go - The Go Programming Language

The -asmflags, -gccgoflags, -gcflags, and -ldflags flags accept a space-separated list of arguments to pass to an underlying tool during the build.

フラグは、スペースで区切ったリストで渡します、と。

To embed spaces in an element in the list, surround it with either single or double quotes.

要素自体にスペースを含めたい場合は、シングルクォートまたはダブルクォートで囲ってください。

The argument list may be preceded by a package pattern and an equal sign, which restricts the use of that argument list to the building of packages matching that pattern (see 'go help packages' for a description of package patterns).

ヘルプには、このフラグはgo tool linkに渡すとも書かれていました。

というわけで、go tool linkのヘルプを見てみます。

link - The Go Programming Language

こちらに、オプションの意味が書かれていました。

-Xの意味は、以下になります。

-X importpath.name=value
Set the value of the string variable in importpath named name to value.
This is only effective if the variable is declared in the source code either uninitialized
or initialized to a constant string expression. -X will not work if the initializer makes
a function call or refers to other variables.
Note that before Go 1.5 this option took two separate arguments.

importpath内のnameで指定された変数の値を設定します。ソースコード内で宣言された"変数"(variable)に対してのみ、
効果があるそうです。

他には、たとえばgdbを使ったドキュメントについては-ldflags=-wを使うことが書かれていますが、

Debugging Go Code with GDB - The Go Programming Language

これはDWARFシンボルテーブルをバイナリに含めないフラグです。要するに、デバッグ情報を含めません、と。

-w
Omit the DWARF symbol table.

DWARFについては、こちら。

dwarf - The Go Programming Language

ちなみに、これらの説明はgo doc cmd/linkでも表示できます。

$ go doc cmd/link

go tool linkでなにも指定しないで実行すると、フラグは見れますがちょっと説明が簡易ですね。

$ go tool link
usage: link [options] main.o

〜省略〜

  -X definition
        add string value definition of the form importpath.name=value

〜省略〜

とりあえず、情報の見方はわかりました。

バリエーションを試してみる

では、いくつかバリエーションを試してみましょう。-ldflagsフラグと-Xの組み合わせを試してみたいと思います。

変数を2つ定義してみます。

main.go

package main

import (
    "fmt"
)

var (
    Message1 = "Message1"
    Message2 = "Message2"
)

func main() {
    fmt.Printf("Message1 = %s\n", Message1)
    fmt.Printf("Message2 = %s\n", Message2)
}

これを1度に変えるには…?

-ldflagsの中に、2回書けばOKですね。

$ go run -ldflags '-X main.Message1=Hello -X main.Message2=World' main.go
Message1 = Hello
Message2 = World

-ldflags自体を繰り返すのはダメなようです。

$ go run -ldflags '-X main.Message1=Hello' -ldflags '-X main.Message2=World' main.go
Message1 = Message1
Message2 = World

次は、こうしてみましょう。

main.go

package main

import (
    "fmt"
)

var (
    ExportedStringVar  = "ExportedStringVar"
    unxportedStringVar = "unxportedStringVar"
    ExportedIntVar     = 10
    unxportedIntVar    = 20
)

const (
    ExportedStringConst   = "ExportedStringConst"
    unexportedStringConst = "unexportedStringConst"
    ExportedIntConst      = 100
    unexportedIntConst     = 200
)

func main() {
    fmt.Printf(`
  ExportedStringVar = %s
  unxportedStringVar = %s
  ExportedIntVar = %d
  unxportedIntVar = %d

  ExportedStringConst = %s
  unexportedStringConst = %s
  ExportedIntConst = %d
  unxportedIntConst = %d

`,
        ExportedStringVar,
        unxportedStringVar,
        ExportedIntVar,
        unxportedIntVar,
        ExportedStringConst,
        unexportedStringConst,
        ExportedIntConst,
        unexportedIntConst,
    )
}

stringおよびintなvarおよびconst、そしてそれぞれエクスポートされているもの、されていないものを用意して、変更を試みます。

var (
    ExportedStringVar  = "ExportedStringVar"
    unxportedStringVar = "unxportedStringVar"
    ExportedIntVar     = 10
    unxportedIntVar    = 20
)

const (
    ExportedStringConst   = "ExportedStringConst"
    unexportedStringConst = "unexportedStringConst"
    ExportedIntConst      = 100
    unexportedIntConst     = 200
)

最初に、varを指定してみましょう。

$ go run -ldflags '-X main.ExportedStringVar=Foo -X main.unxportedStringVar=Bar -X main.ExportedIntVar=50 -X main.unxportedIntVar=60' main.go

すると、intの部分についてはエラーになります。

# command-line-arguments
main.ExportedIntVar: cannot set with -X: not a var of type string (type.int)
main.unxportedIntVar: cannot set with -X: not a var of type string (type.int)

ドキュメントを見た時から予想はついていましたが、変更できるのはstringのみのようですね。

修正版。

$ go run -ldflags '-X main.ExportedStringVar=Foo -X main.unxportedStringVar=Bar' main.go

    ExportedStringVar = Foo
    unxportedStringVar = Bar
    ExportedIntVar = 10
    unxportedIntVar = 20

    ExportedStringConst = ExportedStringConst
    unexportedStringConst = unexportedStringConst
    ExportedIntConst = 100
    unxportedIntConst = 200

stringであれば、エクスポートの有無に関わず変更できそうです。

続いて、const。せっかく用意しましたが、先ほどの結果でintは変えられないことはわかったのでstringのみ指定します。

$ go run -ldflags '-X main.ExportedStringConst=Foo -X main.unexportedStringConst=Bar' main.go

    ExportedStringVar = ExportedStringVar
    unxportedStringVar = unxportedStringVar
    ExportedIntVar = 10
    unxportedIntVar = 20

    ExportedStringConst = ExportedStringConst
    unexportedStringConst = unexportedStringConst
    ExportedIntConst = 100
    unxportedIntConst = 200

こちらは、変更できません。

説明の時点で、「変数」と書いていましたからね。やっぱりそうですか。

Set the value of the string variable in importpath named name to value.

サブパッケージでも試してみましょう。

$ mkdir sub

sub/message.go

package sub

var (
    Message = "Hello World"
)

func GetMessage() string {
    return Message
}

mainパッケージ側は、これを呼ぶだけにします。

main.go

package main

import (
    "fmt"
    "sample/sub"
)

func main() {
    fmt.Printf("sub.GetMessage = %s\n", sub.GetMessage())
}

まずは、ふつうに実行。

$ go run main.go
sub.GetMessage = Hello World

varを変更。

$ go run -ldflags '-X sample/sub.Message=Foo' main.go 
sub.GetMessage = Foo

サブパッケージのものも、変更できましたね。

最後は、関数呼び出しの結果を使うvar。

main.go

package main

import (
    "fmt"
)

var (
    Message = GetMessage()
)

func GetMessage() string {
    return "Hello World"
}

func main() {
    fmt.Printf("Message = %s\n", Message)
}

さすがに、これはムリですね。

$ go run -ldflags '-X main.Message=Foo' main.go
Message = Hello World

説明にもこう書いていましたからね。

-X will not work if the initializer makes
a function call or refers to other variables.

つまり、パッケージのトップレベルに定義されたvarであること、リテラルで指定された値であること、が条件のようですね。
覚えておきましょう。
※トップレベルでないとダメかどうかはちゃんと確認してはいませんが、まあいいでしょう…

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、テストの前に別のサービスを動かす、あたりはそのうち使うことになるかもなぁと思ったり。

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