しおしお

IntelliJ IDEAのことなんかを書いてます

Testcontainersを使ったテストの高速化

Testcontainersを使ったテストは、コンテナの起動が毎回行われるのでどうしてもslow testになってしまいます。 そこで、一度あげたコンテナを使い回すことで2回目以降のテスト実行を高速化してみようと思います。

コンテナを使い回す設定を追加

コンテナを使い回す設定は、テストコードとTestcontainersの設定ファイルの両方に対して行う必要があります。 片方だけに設定を行っても有効にならないので注意です。

テストコード

テストコードで、コンテナを起動する際にreuse(org.testcontainers.containers.GenericContainer#withReuse)trueを設定してあげます。

    private val elasticsearchContainer: ElasticsearchContainer
    init {
        val time = measureNanoTime {
            elasticsearchContainer = ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.11.1")
                .withCreateContainerCmdModifier {
                    it.withEntrypoint("/bin/bash", "-c", "./bin/elasticsearch-plugin install analysis-kuromoji && docker-entrypoint.sh")
                }
                .withLabel("filter-label", "plugin-install-test")
                .withReuse(true)
                .apply {
                    start()
                }
        }
        println("time >>>>> ${TimeUnit.NANOSECONDS.toMillis(time)}")
    }

Testcontainersの設定

上記のテストコードの設定に加えて$HOMEディレクトリ直下にある.testcontainers.propertiesに以下の設定を加えてあげます。 ファイルがない場合には、新規で作成して設定を追加する感じになります。

testcontainers.reuse.enable=true

実行結果

1回目と2回目の時間を比較してみると、コンテナ起動が省略でき、20秒ほど早くなっていることが確認できますね。

1回目

08:57:57.666 [Test worker] INFO 🐳 [docker.elastic.co/elasticsearch/elasticsearch:7.11.1] - Container docker.elastic.co/elasticsearch/elasticsearch:7.11.1 started in PT19.649672S
time >>>>> 21072

2回目

08:58:54.287 [Test worker] INFO 🐳 [docker.elastic.co/elasticsearch/elasticsearch:7.11.1] - Container docker.elastic.co/elasticsearch/elasticsearch:7.11.1 started in PT0.049485S
time >>>>> 1676

起動しっぱなしのコンテナの終了方法

Testcontainersで上げたコンテナンにはラベルが設定されているので、そのラベルでフィルターすることで簡単に終了できます。

コマンド的には、こんな感じになります。

docker ps --filter label=org.testcontainers -q | xargs docker stop

注意点

コンテナを使い回すことになるので、テスト開始時にクリーンな状態ではない可能性があります。 テストコードでは必ず前回のテストの状態を削除するなどして、きれいな状態にする必要があります。

TestcontainersのElasticsearch containerでコンテナ起動時にpluginをインストールする方法

TestcontainersのElasticsearchコンテナ起動時にテストで必要となるプラグインをインストールする方法を調べてみました。

サンプルコード

ElasticsearchContainerの親クラスのGenericContainerが、docker-java APICreateContainerCmdに対してなにか処理を追加できるwithCreateContainerCmdModifierメソッドを提供してくれています。 このメソッドを使って、entrypointを設定することでプラグインのインストールが実現できます。

サンプルコードでは、analysis-kuromojiインストール後にdocker-entrypoint.shを実行してElasticsearchを起動してみました。

import org.junit.jupiter.api.Test
import org.testcontainers.elasticsearch.ElasticsearchContainer

class ElasticSearchTest {

    val elasticsearchContainer = ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.11.1")
        .withCreateContainerCmdModifier {
            it.withEntrypoint("/bin/bash", "-c", "./bin/elasticsearch-plugin install analysis-kuromoji && docker-entrypoint.sh")
        }
        .withLabel("filter-label", "plugin-install-test")
        .withReuse(true)
        .apply {
            start()
        }

    @Test
    internal fun test() {
        val containerId = elasticsearchContainer.containerId
        println("containerId = ${containerId}")
    }
}

pluginのインストール確認

withReusetrueにしているので、テスト終了後もコンテナが起動しっぱなしの状態となります。 そのコンテナ内に、入り込んでプラグインがインストールされていることを確認してみます。

docker ps --filter label=filter-label=plugin-install-test
CONTAINER ID   IMAGE                                                  COMMAND                  CREATED          STATUS          PORTS                                              NAMES
e705ae24a8a6   docker.elastic.co/elasticsearch/elasticsearch:7.11.1   "/bin/bash -c './bin…"   13 minutes ago   Up 13 minutes   0.0.0.0:49259->9200/tcp, 0.0.0.0:49258->9300/tcp   keen_gould
~ ❯ docker exec -it e705 bash
[root@e705ae24a8a6 elasticsearch]# ./bin/elasticsearch-plugin list
analysis-kuromoji

./bin/elasticsearch-plugin listの結果、 analysis-kuromojiが表示されたので想定通りプラグインがインストールされていますね!

Spring Cloud Contractのstubをもとにサーバを起動してみる

Spring Cloud Contractを試してみた - しおしおの続きで、Producer側で生成したstubsをサーバとして起動してエンドポイントを叩いて結果が取得できるか試してみました。

Spring Cloud Contractを適用したプロジェクトの作成

新規でプロジェクトを作るのは大変なので、Spring Cloud Contractを試してみた - しおしおで作ったプロジェクトを使っていきたいと思います。

プロジェクトは、GitHubにいるので↓でcloneしてきます。

git clone git@github.com:siosio/spring-cloud-contract-example.git

Producer側でstubを生成する

generateClientStubsタスクを実行することで、stubを生成できます。 生成されたファイルは、build/stubs:/contracts/stubs配下にあります。

サンプルプロジェクトの場合、プロジェクト直下で↓を実行することで生成できます。

./gradlew server-service:generateClientStubs

生成したstubをもとにサーバを起動する

springcloud/spring-cloud-contract-stub-runnerイメージを使って、コンテナを起動できます。

環境変数に渡す値は、基本はAutoConfigureStubRunnerと同じ値になります。 大きく異なるのは、生成したstubをもとにサーバを起動したいので、STUBRUNNER_REPOSITORY_ROOTにはローカルのディレクトリを指定します。 指定した、ディレクトリには生成したstubが格納されているディレクトリの内容をコピーしておきます。

docker run  --rm \
  -e "STUBRUNNER_IDS=siosio:server-service:1.0.0:8080" \
  -e "STUBRUNNER_REPOSITORY_ROOT=stubs://file:///contracts/stubs/" \
  -e "STUBRUNNER_STUBS_MODE=LOCAL" \
  -p "8080:8080" \
  -v `pwd`/server-service/build/stubs:/contracts/stubs \
  springcloud/spring-cloud-contract-stub-runner:2.2.5.RELEASE

stubベースのサーバにアクセスしてみる

CDCの内容

CDCの内容的に、/sample/{数値}のエンドポイントを叩けば、stubが結果を返してくれるはずです。

import org.springframework.cloud.contract.spec.Contract

Contract.make {
  priority 2
  request {
    method('GET')
    url "/sample/${regex('\\d+')}"
  }

  response {
    status(OK())

    headers {
      contentType(applicationJson())
    }
    
    body(
        id: fromRequest().path(1),
        name: "name_${fromRequest().path(1)}"
    )
  }
}

実行結果

CDCの契約に従って、サーバが結果を返してくれていることが確認できますね。

$ curl http://localhost:8080/sample/1
{"id":"1","name":"name_1"}
$ curl http://localhost:8080/sample/123
{"id":"123","name":"name_123"}

これで、Producer側の開発が終わっていないタイミングでも、早いタイミングでインタフェースを確定してしまえば、stubベースのサーバを使ったConsumer側の開発ができそうな感じがありますね。

single-spaでマイクロフロントエンドしてみる

single-spa | single-spaなるマイクロフロントエンドさくっと作れそうなフレームワークを試してみました。

準備

single-spa はプロジェクト作成用などのcliを用意してくれているようなので、まずはそれをインストールします。

npm install --global create-single-spa

こんな感じにバージョン確認できればインストールは成功です。

create-single-spa --version
2.1.1

アプリケーションの作成手順

ここから新規で、single-spaを使ったマイクロフロントエンドなアプリケーションを作る流れになります。

ルートHTMLを持つプロジェクトの作成

create-single-spaコマンドを使って、moduleTyperoot-configを指定することでルートHTMLを持つプロジェクトを作成できます。 いくつか作成するプロジェクトに関する質問をされますが、好きなものを選ぶな&入力する感じでOKです。

create-single-spa --dir root-config --moduleType root-config

プロジェクトが作成できたら、早速起動してみましょう。

cd root-config
npm run start

ブラウザで、http://localhost:9000にアクセスしてこんな感じのページが表示されればOKですね! f:id:sioiri:20210228063528p:plain

1つめのマイクロフロントエンドとなるアプリケーションの作成

マイクルフロントエンドとなるアプリケーションも、create-single-spaを使って作成できます。 frameworkには、色々なもの*1が指定できますが、今回はvueを指定して2系でプロジェクトを作ってみます。 vueを指定した場合、vue-cliが実行されプロジェクトが作成されます。

create-single-spa --dir vue2-app --framework vue

さっそくプロジェクトを起動してみましょう。

cd vue2-app
npm run serve

ブラウザでhttp://localhost:8080にアクセスすると、いつものVueなアプリケーションの画面が表示されませんね… これは、run serveで起動した場合、結合モードでマイクロフロントエンド側のアプリケーションとして実行されていて単体での画面表示ができないことが原因みたいですね。 f:id:sioiri:20210228064605p:plain

マイクロフロントエンド側のアプリケーション単体で画面表示&開発をしたい場合にはserve:standaloneで起動してあげます。

npm run serve:standalone

これで再度ブラウザで表示してみるといつもの見慣れた初期画面が表示されますね。 f:id:sioiri:20210228065025p:plain

vue2のアプリケーションであることがさくっとわかるように、src/App.vueのtemplateを書き換えておきます。

<template>
  <div id="app">
    <h1>vue2 app</h1>
    <router-view/>
  </div>
</template>

ポートかぶりをなくすために、vue.config.jsを作成して、使用するポートも変更しておきます。

module.exports = {
    devServer: {
        port: 9001,
        disableHostCheck: true
    }
}

2つめのマイクロフロントエンドとなるアプリケーションの作成

1つ目と同じようにcreate-single-spaを使って作成していきます。2つ目のサービスはVueの3系を指定して作ってみます。

create-single-spa --dir vue3-app --framework vue

以降の手順は、1つ目のプロジェクトと同じため省略します。ポートは連番でわかりやすく9002に変更しておきます。

作成した2つのサービスを使って画面表示してみる

最初に作った、root-config側を編集して、2つのマイクロサービスを切り替えて画面表示していきます。

cd root-config
vi src/index.ejs

@single-spa/welcomeが記述されている行を探し、importmapを変更していきます。 importmapの名前には、各マイクロフロントエンドサービスのpackage.jsonに指定されている名前を指定するのがわかりやすいでしょう。 JavaScriptのパスは、マイクロフロントエンドサービスをnpm run serveで起動した際に画面に表示されているパス(下の画像の赤枠のパス)を指定します。 f:id:sioiri:20210228070846p:plain

  • 変更前
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@single-spa/welcome": "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js",
        "@siosio/root-config": "//localhost:9000/siosio-root-config.js"
      }
    }
  </script>
  <% } %>
  • 変更後
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@siosio/root-config": "//localhost:9000/siosio-root-config.js",
        "@siosio/vue2-app": "http://localhost:9001/js/app.js",
        "@siosio/vue3-app": "http://localhost:9002/js/app.js"
      }
    }
  </script>
  <% } %>

続いて、各マイクロフロントエンドサービスの画面を表示する部分を変更しています。 変更する部分は、route defaultが記述されているあたりになります。 routepathを指定することでパスによって表示するマイクロフロントエンドサービスを切り替えられるようです。 applicationnameには、上で編集したimportmapの名前を指定してあげます。

  • 変更前
      <main>
        <route default>
          <application name="@single-spa/welcome"></application>
        </route>
      </main>
  • 変更後
      <main>
        <route path="/vue2">
          <application name="@siosio/vue2-app"></application>
        </route>
        <route path="/vue3">
          <application name="@siosio/vue3-app"></application>
        </route>
      </main>

アプリケーションを起動して画面表示をしてみましょう。

npm run start

ブラウザで、http://localhost:9000/vue2/http://localhost:9000/vue3で、それぞれのマイクロフロントエンドサービス側の画面が表示されれば成功ですね。

おわり。

Testcontainersのコンテナを複数テストクラスで使い回す

Testcontainersのコンテナを複数テストクラスで共有して使い回す方法になります。 コンテナを使い回すことで、テストクラスごとにコンテナが起動されテストの実行がめちゃ遅くなってしまう問題を解消することが期待できます。

コンテナをマニュアル起動するクラスを作成する

コンテナを手動起動するようなクラスを抽象クラスとして作ってあげます。

abstract class MySqlContainer {
    companion object {
        val mySqlContainer = MySQLContainer<Nothing>("mysql:5.7.32")
        init {
            mySqlContainer.start()
        }
    }
}

テストコード

コンテナを起動するクラスを親クラスに指定することで、コンテナが一度だけ起動するようになります。 Spring Bootのテストでは、@DynamicPropertySourceを使って、親クラスで起動したコンテナの情報をもとにデータベースの接続先情報などを上書きしてあげます。

@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@SpringBootTest
@AutoConfigureMockMvc
internal class UserHandlerTest(
        val mockMvc: MockMvc,
        val dataSource: DataSource
): MySqlContainer() {
    companion object {

        @DynamicPropertySource
        @JvmStatic
        fun changeProperty(registry: DynamicPropertyRegistry): Unit {
            val mySqlContainer = MySqlContainer.mySqlContainer
            registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl)
            registry.add("spring.datasource.username", mySqlContainer::getUsername)
            registry.add("spring.datasource.password", mySqlContainer::getPassword)
        }
    }

        val table = Table(dataSource, "user")
        val changes = Changes(table)
        changes.setStartPointNow()

        mockMvc.post("/api/users") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name": "siosio"}"""
        }.andExpect {
            status { isOk }
        }

        changes.setEndPointNow()
        Assertions.assertThat(changes)
                .hasNumberOfChanges(1)
                .changeOfCreation()
                .rowAtEndPoint().value("name").isEqualTo("siosio")
    }
}

テストクラスの親クラスに指定できない場合…

すでに親クラスを指定済みで、そこに手を入れられないようなケースにはテストフレームワーク側の拡張機能を使って対応できます。 例えば、JUnit5の場合は以下のような拡張実装を作ることで対応できます。

class MySqlContainer: Extension {
    companion object {
        val mySqlContainer = MySQLContainer<Nothing>("mysql:5.7.32")
        init {
            mySqlContainer.start()
        }
    }
}

テストコード側では、@RegisterExtensionで拡張実装を登録し利用できます。

    companion object {
        @JvmField
        @RegisterExtension
        val mySqlContainer = MySqlContainer()

        @DynamicPropertySource
        @JvmStatic
        fun changeProperty(registry: DynamicPropertyRegistry): Unit {
            val mySqlContainer = MySqlContainer.mySqlContainer
            registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl)
            registry.add("spring.datasource.username", mySqlContainer::getUsername)
            registry.add("spring.datasource.password", mySqlContainer::getPassword)
        }
    }

注意点

複数テストクラスでコンテナが共有されるので、クリーンな状態でテストが実行できなくなります。 Before系の処理などで、コンテナの中身をクリーンな状態にしてからテストを行う必要があります。

IntelliJ IDEAを使ってDbSetupのKotlin DSL用コードを生成する

IntelliJ IDEAのDatabase Tools and SQLプラグインの検索結果テーブルからGitHub - Ninja-Squad/DbSetup: An API for populating a database in unit testsのKotlin DSL用のデータセットアップコードを生成してみました。

既存のデータベースからデータセットアップ用のコードを出力するスクリプトIntelliJに登録する

SQL-dbsetup-Multirow.kotlin.groovy · GitHubをダウンロードして、ProjectウィンドウのScratches and Consolesの中のExtensions->Database Tools and SQL->extractorsの中に保存します。 下の画像のようになっていればOKです。

f:id:sioiri:20210129083710p:plain

セットアップコードを生成してみる

Extractorsを変更する

下の画像のようにテーブルの検索結果の右上から先程登録したExtractorsを選択します。 f:id:sioiri:20210129083950p:plain

検索結果から適当な行を選択しコピーする

適当な行を選択してコピーを行うと、選択したExtractorsによってKotlin DSL用のデータセットアップコードが生成されクリップボードに格納されます。

例えば、testテーブルの2レコードを選択してコピーしてみます。

f:id:sioiri:20210129084330p:plain

生成されたコードは以下のようになります。あとは、好きな場所に貼り付ければさくっと利用可能になります。

insertInto("test") {
columns("id", "name")
values(1, "c")
values(2, "hoge")
}

おわり。

Spring Boot2.4.0からのapplication.properties(yml)の変更点φ(..)メモメモ

Spring Boot Config Data Migration Guide · spring-projects/spring-boot Wiki · GitHubにまとまっていますが、2.4.0からConfigファイル周りの設定値や読み込み順などが変わっているようです。

2.4.0より前のバージョン

こんな感じにspring.profilesでプロファイル名を指定して、そのプロファイルがアクティブなときの設定値を書いていきます。

app:
  key1:default
---
spring:
  profiles: prod
app:
  key1: prod
---
spring:
  profiles: dev
app:
  key1: dev

複数プロファイルがアクティブな場合の適用順

アクティブなプロファイルが1つだけの場合は、問題ありませんが複数指定した場合は後勝ちで適用されます。

prod,devと指定した場合

2020-11-17 09:13:18.126  INFO 33098 --- [           main] siosio.boot240.Boot240ApplicationKt      : The following profiles are active: prod,dev
2020-11-17 09:13:18.606  INFO 33098 --- [           main] siosio.boot240.Boot240ApplicationKt      : Started Boot240ApplicationKt in 0.736 seconds (JVM running for 1.231)
props = Props(key1=dev)

dev,prodと指定した場合

2020-11-17 09:21:17.252  INFO 34864 --- [           main] siosio.boot240.Boot240ApplicationKt      : The following profiles are active: dev,prod
2020-11-17 09:21:17.856  INFO 34864 --- [           main] siosio.boot240.Boot240ApplicationKt      : Started Boot240ApplicationKt in 0.845 seconds (JVM running for 1.31)
props = Props(key1=prod)

2.4.0以降のバージョン

spring.config.activate.on-profileでプロファイル名を指定して、そのプロファイルがアクティブなときの設定値を書いていきます。 spring.profilesは非推奨になっているので、IntelliJ IDEAの場合そのキー非推奨だよと親切に教えてくれます。

---
spring:
  config:
    activate:
      on-profile: prod
app:
  key1: prod
---
spring:
  config:
    activate:
      on-profile: dev
app:
  key1: dev

複数プロファイルがアクティブな場合の適用順

複数指定した場合でも、設定値の記述順がより下のものが優先されます。上の設定ファイルの場合、常にdevが優先されることになります。

prod,devと指定した場合

2020-11-17 09:31:10.295  INFO 36947 --- [           main] siosio.boot240.Boot240ApplicationKt      : The following profiles are active: prod,dev
2020-11-17 09:31:10.781  INFO 36947 --- [           main] siosio.boot240.Boot240ApplicationKt      : Started Boot240ApplicationKt in 0.741 seconds (JVM running for 1.158)
props = Props(key1=dev)

dev,prodと指定した場合

2020-11-17 09:31:39.106  INFO 37082 --- [           main] siosio.boot240.Boot240ApplicationKt      : The following profiles are active: dev,prod
2020-11-17 09:31:39.557  INFO 37082 --- [           main] siosio.boot240.Boot240ApplicationKt      : Started Boot240ApplicationKt in 0.687 seconds (JVM running for 1.157)
props = Props(key1=dev)

2.4.0より前の動きで動作させたい場合

spring.config.use-legacy-processing=true設定値や起動時のシステムプロパティなどで指定することで、古いバージョンと全く同じ動きとなります。