Spring BootにTestcontainersを導入して開発環境のDockerコンテナをテストコードで管理する

この記事は[NTTコムウェア AdventCalendar 2024](https://qiita.com/advent-calendar/2024/nttcomware) 8日目の記事です。

# はじめに
こんにちは、NTTコムウェアの田村です。
普段は[Macchinetta Framework](https://macchinetta.github.io/)、Springプロジェクトに関する社内からの問合せ対応や技術検証を行っています。
昨年は[Spring Framework 6.1 から追加された RestClient を試してみる](https://qiita.com/comware_tamura/items/35901c66579f2f8563e9)を投稿していました。

今回はSpring BootのJUnitテストでDockerコンテナ管理が可能になるJavaライブラリ[Testcontainers](https://testcontainers.com/)を導入してみます。

# 概要
アプリケーションのバックエンドサービスとしてPostgreSQLにアクセスするサンプルアプリを用意します。
サンプルアプリに対してTestcontainersを導入し、PostgreSQL環境を別途用意しなくても実行できるテストコードを作成します。

- Spring Boot 3.4.0
- Java 17
- Docker 26.1.3

# サンプルアプリ
シンプルに`/`宛てにGETするとPostgreSQLの`Dog`テーブルから全レコードを取得するサンプルを用意しました。

## Model
```java
import org.springframework.data.annotation.Id;

public record Dog(@Id Integer id, String name, String description) {
}
```
## Controller
```java
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
```java
import org.springframework.data.repository.ListCrudRepository;

public interface DogRepository extends ListCrudRepository<Dog, Integer> {  
} 
```

## データベースの初期データ
Testcontainersとは別でSpring Bootの機能として、[Initialize a Database Using Basic SQL Scripts](https://docs.spring.io/spring-boot/how-to/data-initialization.html#howto.data-initialization.using-basic-sql-scripts)に記載の通り、デフォルトで`optional:classpath*:schema.sql`、`optional:classpath*:data.sql`に一致するファイルが初期データ用のSQLスクリプトとして認識されます。
`application.properties`で`spring.sql.init.mode=always`を設定することでアプリケーション起動時にSpring Bootがこれらの初期データを自動で投入します。

## schema.sql
```sql
CREATE TABLE IF NOT EXISTS DOG (
  ID INTEGER,
  NAME TEXT NOT NULL,
  DESCRIPTION TEXT,
  primary key (id)
);
```
## data.sql
```sql
INSERT INTO DOG VALUES (1, '茶々丸', 'かわいい柴犬です。')
```

# Testcontainersの依存関係追加
Testcontainersを利用するために`pom.xml`へ依存関係を追加します。
TestcontainersはPostgreSQLのコンテナイメージサポートがあるため専用のモジュールも追加します。
Testcontainersが専用のモジュールを提供しているコンテナイメージ一覧は[Testcontainers公式サイト](https://testcontainers.com/modules/?language=java)参照してください。

```xml
<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>
```
:::note info
専用モジュールの提供がない場合でも、コンテナを普遍的に扱うことができる`GenericContainer`クラスが提供されています。
:::

# テストメソッドでTestcontainersを利用する
TestcontainersでPostgreSQLを用意して実際にRepositoryクラスを呼び出してみましょう。
テストクラスとして`DogRepositoryTest`を作成します。

```java
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コンテナから正常にデータが取得できれば成功します

:::note info
クラスに`@Testcontainers`を付与することで、各テストメソッドごとにフィールド変数で定義されたコンテナの起動/停止を実行することも可能です。
その場合、フィールド変数には`＠Container`を付与します。
:::

## テスト用に`application.properties`の設定を変更する
テスト用に設定を加えたい場合は`@DynamicPropertySource`を利用します。
例えば、初期データとして`data.sql`を用意していましたが、テストデータとして`testData.sql`を投入するよう変更します。

```java
    @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](https://docs.spring.io/spring-boot/reference/using/devtools.html)の[Automatic Restart](https://docs.spring.io/spring-boot/reference/using/devtools.html#using.devtools.restart)や[LiveReload](https://docs.spring.io/spring-boot/reference/using/devtools.html#using.devtools.livereload)を活用してアプリケーションを起動しながらコーディングをすることも多々あると思います。
なのでテスト実行だけでなく、開発中にアプリケーションを起動したときにもTestcontainersを利用してPostgreSQLコンテナが起動するようにします。
本稿では解説しませんが、アプリケーション起動時にDockerコンテナを実行する方法としては[Spring BootのDocker Composeサポート](https://docs.spring.io/spring-boot/how-to/docker-compose.html#page-title)を利用する方法も挙げられます。

Spring Bootアプリケーションとして`main`メソッドを持つ`DemoApplication`クラスがあります。
```java
@SpringBootApplication
public class DemoApplication {

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

}
```

これに対し、`src/test`ディレクトリに開発用のアプリケーション起動クラス`DemoApplicationTests`を作成します。

```java
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](https://docs.spring.io/spring-boot/reference/using/devtools.html)による再起動時に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のデータを取得できることが確認できます。
```console
$ curl localhost:8080
[{"id":1,"name":"茶々丸","description":"かわいい柴犬です。"}]
```

また、下記のように`docker`コマンドからPostgreSQLコンテナが起動していることも確認できます。
ホスト側のポート（下記例では`33158`）とコンテナ名（下記例では`wonderful_dewdney`）はTestcontainersが自動的に割り振っています。
```console
$ 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から動的に設定変更を行いたい場合は引数から参照可能です。

```java
	@Bean
	public DynamicPropertyRegistrar postgresProperties(PostgreSQLContainer<?> postgres) {
		return (properties) -> {
			properties.add("spring.sql.init.data-locations",
					() -> "optional:classpath:testData.sql");
		};
	}
```
:::note info
Spring Boot 3.4以前だと下記のようにコンテナのBean定義内で設定を変更していましたが、Testcontainersのライフサイクルの都合上正しく設定されない場合があるため3.4以降ではアプリケーション起動エラーになります。
```java
// 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が自動決定するコンテナ名やポート番号を固定化する
テストにおいてホスト名やポートの固定化は、テストの並列実施ができなくなるなどの観点から避けるべきです。
しかし、個人の開発環境においてはコンテナ名やポート番号を固定化したほうが良い場合もあります。

```java
	@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");
				});
	}
```

```console
$ 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
```
# 感想
テスト用に環境を用意する必要がなくなる（開発者間の環境差分がなくなる）、テスト用の設定をコードに集約できるため把握しやすいといったメリットがあると感じました。
デメリットとしては、やはりコンテナ起動には時間がかかりますのでテスト実行時間の増加が挙げられます。
そのためテストの実行頻度などのバランスを考えて導入する必要はありそうです。

---
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。