12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NTTコムウェアAdvent Calendar 2024

Day 8

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

Last updated at Posted at 2024-12-07

この記事は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.sqloptional:classpath*:data.sqlに一致するファイルが初期データ用のSQLスクリプトとして認識されます。
application.propertiesspring.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コンテナから正常にデータが取得できれば成功します

クラスに@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"

開発時にTestcontainersを利用する

Spring Boot開発ではDeveloper ToolsAutomatic RestartLiveReloadを活用してアプリケーションを起動しながらコーディングをすることも多々あると思います。
なのでテスト実行だけでなく、開発中にアプリケーションを起動したときにも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

感想

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


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

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?