この記事はNTTコムウェア AdventCalendar 2024 8日目の記事です。
はじめに
こんにちは、NTTコムウェアの田村です。
普段はMacchinetta Framework、Springプロジェクトに関する社内からの問合せ対応や技術検証を行っています。
昨年はSpring Framework 6.1 から追加された RestClient を試してみるを投稿していました。
今回はSpring BootのJUnitテストでDockerコンテナ管理が可能になるJavaライブラリTestcontainersを導入してみます。
概要
アプリケーションのバックエンドサービスとしてPostgreSQLにアクセスするサンプルアプリを用意します。
サンプルアプリに対してTestcontainersを導入し、PostgreSQL環境を別途用意しなくても実行できるテストコードを作成します。
- Spring Boot 3.4.0
- Java 17
- Docker 26.1.3
サンプルアプリ
シンプルに/
宛てにGETするとPostgreSQLのDog
テーブルから全レコードを取得するサンプルを用意しました。
Model
import org.springframework.data.annotation.Id;
public record Dog(@Id Integer id, String name, String description) {
}
Controller
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
public class DogController {
private final DogRepository dogRepository;
public DogController(DogRepository dogRepository) {
this.dogRepository = dogRepository;
}
@GetMapping
public List<Dog> getAllDogs() {
return this.dogRepository.findAll();
}
}
Repository
import org.springframework.data.repository.ListCrudRepository;
public interface DogRepository extends ListCrudRepository<Dog, Integer> {
}
データベースの初期データ
Testcontainersとは別でSpring Bootの機能として、Initialize a Database Using Basic SQL Scriptsに記載の通り、デフォルトでoptional:classpath*:schema.sql
、optional:classpath*:data.sql
に一致するファイルが初期データ用のSQLスクリプトとして認識されます。
application.properties
でspring.sql.init.mode=always
を設定することでアプリケーション起動時にSpring Bootがこれらの初期データを自動で投入します。
schema.sql
CREATE TABLE IF NOT EXISTS DOG (
ID INTEGER,
NAME TEXT NOT NULL,
DESCRIPTION TEXT,
primary key (id)
);
data.sql
INSERT INTO DOG VALUES (1, '茶々丸', 'かわいい柴犬です。')
Testcontainersの依存関係追加
Testcontainersを利用するためにpom.xml
へ依存関係を追加します。
TestcontainersはPostgreSQLのコンテナイメージサポートがあるため専用のモジュールも追加します。
Testcontainersが専用のモジュールを提供しているコンテナイメージ一覧はTestcontainers公式サイト参照してください。
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
専用モジュールの提供がない場合でも、コンテナを普遍的に扱うことができるGenericContainer
クラスが提供されています。
テストメソッドでTestcontainersを利用する
TestcontainersでPostgreSQLを用意して実際にRepositoryクラスを呼び出してみましょう。
テストクラスとしてDogRepositoryTest
を作成します。
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
@SpringBootTest
public class DogRepositoryTest {
@Autowired
DogRepository dogRepository;
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:17");
@BeforeAll
static void beforeAll() {
postgres.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
}
@Test
void getAllDogs() {
List<Dog> expected = new ArrayList<Dog>();
expected.add(new Dog(1,"茶々丸","かわいい柴犬です。"));
Assertions.assertEquals(expected, dogRepository.findAll());
}
}
-
PostgreSQLContainer
- Testcontainersが提供するPostgreSQL用のコンテナクラスです
-
@ServiceConnection
を付与することにより、application.properties
等で指定されたDBへの接続情報などを自動で読み込みます- 指定がなければ
PostgreSQLContainer
クラスで定義されているデフォルト値が利用されます
- 指定がなければ
-
@BeforeAll
、@AfterAll
- テスト実行前にコンテナを起動し、実行後に停止しています
-
getAllDogs
テストメソッド- DIされた
DogRepository
を使ってDBからデータを取得して評価します - Testcontainersによって起動されたPostgreSQLコンテナから正常にデータが取得できれば成功します
- DIされた
クラスに@Testcontainers
を付与することで、各テストメソッドごとにフィールド変数で定義されたコンテナの起動/停止を実行することも可能です。
その場合、フィールド変数には@Container
を付与します。
テスト用にapplication.properties
の設定を変更する
テスト用に設定を加えたい場合は@DynamicPropertySource
を利用します。
例えば、初期データとしてdata.sql
を用意していましたが、テストデータとしてtestData.sql
を投入するよう変更します。
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry properties) {
properties.add("spring.sql.init.data-locations",
() -> "classpath:testData.sql");
}
-
@DynamicPropertySource
- メソッド引数の
DynamicPropertyRegistry
を利用してapplication.properties
の設定を変更するような操作が可能です
- メソッド引数の
-
"spring.sql.init.data-locations"
- データベースの初期データで投入するSQLスクリプトを指定します
開発時にTestcontainersを利用する
Spring Boot開発ではDeveloper ToolsのAutomatic RestartやLiveReloadを活用してアプリケーションを起動しながらコーディングをすることも多々あると思います。
なのでテスト実行だけでなく、開発中にアプリケーションを起動したときにもTestcontainersを利用してPostgreSQLコンテナが起動するようにします。
本稿では解説しませんが、アプリケーション起動時にDockerコンテナを実行する方法としてはSpring BootのDocker Composeサポートを利用する方法も挙げられます。
Spring Bootアプリケーションとしてmain
メソッドを持つDemoApplication
クラスがあります。
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
これに対し、src/test
ディレクトリに開発用のアプリケーション起動クラスDemoApplicationTests
を作成します。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistrar;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false)
class DemoApplicationTests {
@Bean
@ServiceConnection
@RestartScope
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:17");
}
public static void main(String[] args) {
SpringApplication.from(DemoApplication::main)
.with(DemoApplicationTests.class)
.withAdditionalProfiles("test").run(args);
}
}
-
@TestConfiguration(proxyBeanMethods = false)
- テスト用のJavaconfigクラスとして宣言します
- 別に専用のJavaconfigクラスを用意しても問題ありません
-
PostgreSQLContainer
- Testcontainersが提供するPostgreSQL用のコンテナクラスです
- Bean登録をすることで実行時にコンテナが起動します
-
@ServiceConnection
を付与することにより、application.properties
等で指定されたDBへの接続情報などを自動で読み込みます -
@RestartScope
を付与することにより、Spring BootのDeveloper Toolsによる再起動時にBeanの再作成(=PostgreSQLコンテナの再作成)がされなくなります- 利用には依存関係に
spring-boot-devtools
が必要です
- 利用には依存関係に
-
SpringApplication.from(DemoApplication::main)
- テスト起動時に実行する
main
メソッドを指定します -
.with(DemoApplicationTests.class)
- テスト用のJavaConfigクラスを指定します
- ここでは
DemoApplicationTests
クラス内で定義しているBeanを登録します
-
.withAdditionalProfiles("test")
- テスト起動用のプロファイルとして
test
を指定します - このメソッドはSpring Boot 3.4でリリースされました
- テスト起動用のプロファイルとして
- テスト起動時に実行する
開発用のアプリケーションを実行後、以下のようにControllerで定義したAPIからDBのデータを取得できることが確認できます。
$ curl localhost:8080
[{"id":1,"name":"茶々丸","description":"かわいい柴犬です。"}]
また、下記のようにdocker
コマンドからPostgreSQLコンテナが起動していることも確認できます。
ホスト側のポート(下記例では33158
)とコンテナ名(下記例ではwonderful_dewdney
)はTestcontainersが自動的に割り振っています。
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e4a5da836026 postgres:17 "docker-entrypoint.s…" 7 seconds ago Up 6 seconds 0.0.0.0:33158->5432/tcp wonderful_dewdney
03b0675b0e0c testcontainers/ryuk:0.11.0 "/bin/ryuk" 8 seconds ago Up 6 seconds 0.0.0.0:33157->8080/tcp testcontainers-ryuk-01796fb1-b9cd-47b6-bf1f-dc168cc93991
開発用にapplication.properties
の設定を変更する
DynamicPropertyRegistrar
をBean登録することで可能です。
登録したコンテナのBeanから動的に設定変更を行いたい場合は引数から参照可能です。
@Bean
public DynamicPropertyRegistrar postgresProperties(PostgreSQLContainer<?> postgres) {
return (properties) -> {
properties.add("spring.sql.init.data-locations",
() -> "optional:classpath:testData.sql");
};
}
Spring Boot 3.4以前だと下記のようにコンテナのBean定義内で設定を変更していましたが、Testcontainersのライフサイクルの都合上正しく設定されない場合があるため3.4以降ではアプリケーション起動エラーになります。
// Before Spring Boot 3.4
@Bean
@ServiceConnection
@RestartScope
public PostgreSQLContainer<?> postgresContainer(DynamicPropertyRegistry properties) {
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
properties.add("spring.sql.init.data-locations", () -> "optional:classpath*:testData.sql");
return postgres;
}
Testcontainersが自動決定するコンテナ名やポート番号を固定化する
テストにおいてホスト名やポートの固定化は、テストの並列実施ができなくなるなどの観点から避けるべきです。
しかし、個人の開発環境においてはコンテナ名やポート番号を固定化したほうが良い場合もあります。
@Bean
@ServiceConnection
@RestartScope
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:17")
.withCreateContainerCmdModifier(cmd -> {
cmd.getHostConfig().withPortBindings(
new PortBinding(Ports.Binding.bindPort(5432), new ExposedPort(5432)));
cmd.withName("demo_postgres");
});
}
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e80721190982 postgres:17 "docker-entrypoint.s…" 10 seconds ago Up 8 seconds 0.0.0.0:5432->5432/tcp demo_postgres
a5ba0b9d5765 testcontainers/ryuk:0.11.0 "/bin/ryuk" 11 seconds ago Up 9 seconds 0.0.0.0:33163->8080/tcp testcontainers-ryuk-7bef1c7f-eaa6-4141-98dd-e530ee2d2559
感想
テスト用に環境を用意する必要がなくなる(開発者間の環境差分がなくなる)、テスト用の設定をコードに集約できるため把握しやすいといったメリットがあると感じました。
デメリットとしては、やはりコンテナ起動には時間がかかりますのでテスト実行時間の増加が挙げられます。
そのためテストの実行頻度などのバランスを考えて導入する必要はありそうです。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。