kagamihogeの日記

kagamihogeの日記です。

spring-bootのTestcontainersで開発と結合テストのコンテナ設定を共有

spring-bootとTestcontainersの組み合わせにより、開発と結合テストRDBなど各種ミドルウェアのセットアップを共有できる。とりあえず試すには、Spring InitializrOracleミドルウェアも一緒に起動する開発・結合テストのサンプルコードを含むプロジェクトを生成できる。

チュートリアル相当

以降の手順の前提条件はtestcontainersからdockerが起動可能な設定済みなこと。

まずSpring InitializrでTestcontainersを含むプロジェクトを生成する。ここでは自分の趣味でOracleを使うのでweb, jdbc, testcontainers, oracleをadd dependenciesから追加する。

exploreを見ると、mainを記述するDempApplication以外にsrc/test/javaにいくつかのサンプルクラスが追加されている。

以下のソースコードは特に断りの無い限り生成されたものをそのまま載せている。

以下はbuild.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'Demo project for Spring Boot'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.oracle.database.jdbc:ojdbc11'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:oracle-free'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()

}

以下のクラス群はsrc/test/java下のもの。src/mainでは無い点に注意。

import org.springframework.boot.SpringApplication;

public class TestDemoApplication {

  public static void main(String[] args) {
    SpringApplication.from(DemoApplication::main).with(TestcontainersConfiguration.class).run(args);
  }

}
@Import(TestcontainersConfiguration.class)
@SpringBootTest
class DemoApplicationTests {

  @Test
  void contextLoads() {
  }

}
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.oracle.OracleContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

  @Bean
  @ServiceConnection
  OracleContainer oracleFreeContainer() {
//    return new OracleContainer(DockerImageName.parse("gvenzl/oracle-free:latest"));
    return new OracleContainer(DockerImageName.parse("gvenzl/oracle-free:23.5-faststart"));
  }

}

上記はdocker imageを取得済みのものに修正している(時間短縮のため)。

これでsrc/test/javacom.example.demo.TestDemoApplicationを起動する。正常に起動すれば、testcontainers経由でdokcerにoracleインスタンスが起動した上でspring-bootも起動する。以下はログの一部。

tc.gvenzl/oracle-free:23.5-faststart     : Container is started (JDBC URL: jdbc:oracle:thin:@localhost:32771/freepdb1)
o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path '/'

dockerから見てもoracleが起動しているのが分かる。spring-bootを終了するとこのコンテナも削除される。

docker ps
CONTAINER ID   IMAGE                               COMMAND                  CREATED         STATUS         PORTS                                         NAMES
1391efbea242   gvenzl/oracle-free:23.5-faststart   "container-entrypoin…"   2 minutes ago   Up 2 minutes   0.0.0.0:32771->1521/tcp, :::32771->1521/tcp   distracted_golick
2f8e577a1bbf   testcontainers/ryuk:0.12.0          "/bin/ryuk"              2 minutes ago   Up 2 minutes   0.0.0.0:32770->8080/tcp, :::32770->8080/tcp   testcontainers-ryuk-43ca0794-883b-40c6-8d0d-bfea5d7a69a3

また、DemoApplicationTests結合テストのサンプルコードとなる。こちらも起動すると同様にdockerでoracleが起動してspring-bootが起動してテストを実行してコンテナ削除、という流れになる。

というわけで、Spring Initializrで生成するとoracleとかも一緒に起動するアプリケーションと結合テストコードのサンプルプロジェクトが入手可能になった。とりあえず起動するだけなら、docker run ..してspring.datasource.*を設定して、が不要になった。

色々試す

oracle起動時にテーブル作成して初期データ投入

以下はtestcontainersと直接関係は無いが、もう少しコードを追加して良くありそうな開発環境を考える。追加コードの内容は、oracle起動時にテーブル作成して初期データも投入する。

src/test/resources/application.propertiesに常にDB初期化実行する設定を追加。

spring.sql.init.mode=always

src/test/resources/下にテーブル生成のschema.sqlと初期データ投入用のdata.sqlを配置する。

CREATE TABLE test_users(
    id VARCHAR2(50) NOT NULL
    , value NUMBER NOT NULL
    , CONSTRAINT test_users_pk PRIMARY KEY (id)
);
insert into test_users(id, value) values ('1', 123);

src/main 側に適当な動作確認用にSQL実行を伴うRestControllerを追加する。

@RestController
@SpringBootApplication
public class DemoApplication {

  final DataSource dataSource;

  public DemoApplication(DataSource dataSource) {
    this.dataSource = dataSource;
  }

  @GetMapping("/sample")
  String sample() {
    JdbcClient jdbc = JdbcClient.create(dataSource);
    return jdbc.sql("select count(*) from test_users").query(Integer.class).single().toString();
  }

  public static void main(String[] args) {
    SpringApplication.run(DemoApplication.class, args);
  }

}

結合テストとしてsrc/test/java下のDemoApplicationTestsを下記のように修正する。上で追加した/sampleを実行してレスポンスをassertで検証するように修正。

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.web.client.RestClient;

@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class DemoApplicationTests {

  @LocalServerPort
  int port;

  @Test
  void contextLoads() {
    RestClient client = RestClient.builder().baseUrl("http://localhost:" + port).build();
    assertEquals("1", client.get().uri("/sample").retrieve().body(String.class));
  }

}

これにより、spring-bootを起動してブラウザや適当なweb-apiクライアントから動作確認したい場合はTestDemoApplicationから、結合テストしたい場合はDemoApplicationTestsから、その両者ともTestcontainers経由でoracleを起動することでミドルウェア設定を共有できる。

devtoolsでコンテナ起動したままspring-bootだけ再起動

上述までの設定で開発をするとspring-bootを停止・起動するたびにコンテナ削除・生成され、oracleのようにまぁまぁ重いコンテナだとまぁまぁだるい。解決案の一つに https://docs.spring.io/spring-boot/reference/using/devtools.html devtoolsがある。

https://docs.spring.io/spring-boot/reference/features/dev-services.html#features.dev-services.testcontainers.at-development-time.devtools にあるように、@ServiceConnection@RestartScopeを付与する。

  @Bean
  @ServiceConnection
  @RestartScope
  OracleContainer oracleFreeContainer() {
    //...

Spring Initializrで生成してるとspring-boot-devtoolsdevelopmentOnlyなのでこれをtestAndDevelopmentOnlyに変更する。

//    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    testAndDevelopmentOnly  'org.springframework.boot:spring-boot-devtools'

なおspring.sql.init.mode=alwaysにしてるとSQL類も再実行されてしまう。この辺は色々やりようはあると思う。例えば、CREATE TABLE IF NOT EXISTSにしたり、初期化データは毎回truncate table ..したり、とか。テストだけ@SpringBootTest(properties = {"spring.sql.init.mode=always"})にして、開発はまた別途やり方を考えるとか。

開発時の工夫

色々工夫すると色々便利になりそうだが、どこまでやるかはプロジェクト事情や自分の好みに寄るかな、という感じはする。結合テストはともかく開発の方は特に。俺自身はまだこの機能を実戦投入してないんで何とも言えないけど。

たとえば、コンテナ自身や初期化処理が重い場合はwithReuse(true)でコンテナを再利用したくなると。しばらくの間はこのコンテナのままで開発続けたいなーーみたいな。ただ、キャッシュを長期間残すと別の問題が出やすくなり、意図しないコンテナが残存していて妙な動作になってしまった……も考えられる。なのでspring-boot起動するたびにコンテナ全部再作成でも良い気はしないでもないが、それがどのくらいだるいかは場合に依るし個人の好みにも依るし。

Testcontainersで共有します、開発はプロジェクトでおおよそ標準的なやり方を定めます、その上で個人で工夫したいならしてね、になるかなぁ……などと思ったりした。

感想とか

checkoutしてspirng-boot起動するだけでoracleとか必要なミドルウェアが全部起動して初期データも投入された状態で開発や結合テスト実行できる。Testcontainersで開発と結合テストの環境設定を共有するっていうアイデアは素晴らしい。

参考URL