Spring Boot 3におけるTestcontainersを使用したIntegration test

1. 概要

H2で実行するintegration testは開発マシンでは「緑」になることが多いですが、本番環境のPostgreSQLでエラーになります。理由はSQL構文、データ型、トランザクション動作が2つのDBMS間で異なるからです。TestcontainersはこれをDockerコンテナー内で実行時にデータベースを起動することで解決します。

このガイドでは、Spring Boot 3.x アプリケーションにTestcontainersを設定し、@ServiceConnectionを使用してコンテナーをSpringに接続します。その後、複数のテストクラス間でコンテナーを共有し、よくあるエラーを理解します。

2. 準備

2.1. 依存関係

Dockerがマシンで実行されている必要があります。または互換性のあるDocker daemonが必要です。その後、pom.xmlに以下の依存関係を追加します:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <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>
</dependencies>

Spring Bootの親pomファイルはTestcontainersのバージョン管理を行うため、<version>を宣言する必要はありません。spring-boot-testcontainersアーティファクトはSpring Boot 3.1以降で利用可能で、@ServiceConnectionアノテーションを提供します。

2.2. テスト対象のソースコード

テスト対象としてシンプルなOrderエンティティを使用します:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

RepositoryはPostgreSQLのILIKEを使用するネイティブクエリを持ちます:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

ネイティブクエリは「H2では動作するがPostgresで壊れる」タイプのコードです。逆もしかりです。こここそが本物のデータベースでテストすべき箇所です。

3. @ServiceConnectionを使用したリポジトリのテスト

3.1. コンテナーの宣言

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

各要素は以下のように動作します:

  • @TestcontainersはJUnit 5 extensionを有効にします。このextensionは@Containerを持つフィールドを検出し、ライフサイクルを管理します。
  • staticフィールド上の@Container:コンテナーはクラス内のすべてのテストの前に一度だけ起動し、最後のテストの後に停止します。フィールドがstaticでない場合、コンテナーはテストメソッドごとに再作成されます。
  • @ServiceConnection:Spring Bootはコンテナーからホスト、ランダムなポート、ユーザー名とパスワードを読み取り、JdbcConnectionDetails beanを作成します。これにより、spring.datasource.urlを手動で設定する必要がありません。
  • @AutoConfigureTestDatabase(replace = NONE):デフォルトでは@DataJpaTestはDataSourceを組み込みデータベースに置き換える可能性があります。Spring Boot 3.4以降、フレームワークは@ServiceConnectionから来るDataSourceを自動認識します。ただし、明示的な宣言はすべての3.xバージョンで正しく動作するのに役立ちます。
  • ddl-auto=create-drop:組み込みでないデータベースでは、Spring Bootはデフォルトではスキーマを自動的に作成しません。実際のプロジェクトでは、FlyawayまたはLiquibaseを使用して、テストが本番環境のマイグレーションを正しく実行することを確認する必要があります。

テストを実行すると、ログは以下のようになります:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

初回実行はDocker がイメージをpullする必要があるため低速です。その後の実行は通常数秒で完了します。

3.2. 古い方法:@DynamicPropertySource

Spring Boot 3.1より前、または@ServiceConnectionでまだサポートされていないコンテナーの場合、プロパティを手動で設定します:

@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

このアプローチは依然として機能します。ただし、PostgreSQL、MySQL、Redis、Kafka、MongoDBおよび他の多くの一般的なサービスでは、@ServiceConnectionは簡潔であり、プロパティ名の入力間違いが少ないです。

4. @SpringBootTestを使用したアプリケーション全体のテスト

RepositoryテストはJPAレイヤーのみをロードします。HTTP → service → databaseのフローをテストするには、コンテキスト全体をロードする必要があります。次のコントローラーがあると仮定します:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. @TestConfigurationを使用したコンテナーの共有

各テストクラスでコンテナーを宣言する代わりに、beanとして配置します:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

ここではコンテナーはJUnitではなくSpringによって管理されます。SpringはDataSourceを作成する前にコンテナーを起動し、contextが閉じられるときに停止します。Spring TestContextは同じ設定を持つテストクラス間でcontextをキャッシュするため、それらのクラスは同じコンテナーを共有します。

4.2. テストの記述

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanDatabase() {
        orderRepository.deleteAll();
    }

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

注意すべき点:

  • RANDOM_PORTは実際のサーバーを起動します。TestRestTemplateは自動的にそのポートを指します。
  • このテストには@Transactionalがありません。HTTPリクエストはテストと異なるスレッドで実行されるためです。データは実際にPostgresにコミットされるため、@BeforeEachでクリーンアップする必要があります。
  • isEqualByComparingToはBigDecimalを値で比較します。このアプローチにより、スケールの違いによる250000と250000.00の違いを回避できます。
  • OrderクラスはJacksonによってデシリアライズされる必要があります。デシリアライズエラーが発生した場合は、デフォルトコンストラクタとgetterを確認するか、レスポンス用の別々のレコードDTOを使用してください。

4.3. ローカルでアプリケーションを実行する際の設定の再利用

TestcontainersConfigには、あまり知られていない用途があります。Postgresをインストールせずにアプリケーションをローカルで実行します。src/test/javaに次のクラスを作成します:

public class TestOrderApplication {

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

その後、コマンドを実行します:

./mvnw spring-boot:test-run

アプリケーションは同じPostgresコンテナーとともに起動します。アプリケーションをシャットダウンするとコンテナーが停止します。

5. よくあるエラー

5.1. 複数のクラスで共有contextを使用する場合の静的@Container

これは最も不愉快なエラーです。@Container staticと@ServiceConnectionを含む抽象基本クラスがあり、多くのテストクラスがそれを継承する場合を想定してください:

OrderControllerIT  -> コンテナーはポート55001で起動、contextはキャッシュ
CustomerControllerIT -> JUnitが古いコンテナーを停止、新しいコンテナーをポート55017で起動
                    -> Springはキャッシュされたcontextを再利用、仍然として55001を指す
                    -> Connection refused / HikariPool timeout

JUnitは各クラスの後にコンテナーを停止しますが、Springはキャッシュ内の古いcontextを保持します。2つの修正方法があります:

  1. セクション4.1のようなbeanコンテナーを使用します。その場合、コンテナーのライフサイクルはcontextに関連付けられます。これが推奨される方法です。
  2. シングルトンコンテナーを使用します。@Containerと@Testcontainersを削除し、staticブロック内で手動で起動します。Ryukはプログラム終了時にコンテナーをクリーンアップします。
public abstract class AbstractIntegrationTest {

    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }
}

5.2. latestタグの使用

**注意:**常にイメージバージョンを固定にしてください(postgres:16-alpine)。可能な限り本番環境のバージョンと一致させてください。latestタグは新しいイメージがリリースされたときにテストの動作が予期せず変わる可能性があります。

5.3. テスト間のデータリーク

@DataJpaTestはデフォルトで各テスト後にロールバックしますが、@SpringBootTestでRANDOM_PORTの場合はそうではありません。コンテナーを共有する場合、前のクラスからのデータはデータベース内に残ります。@BeforeEach内でデータをクリーンアップするか、@Sqlを使用してTRUNCATEスクリプトを実行してください。テスト実行順序に依存するテストを記述しないでください。

5.4. DockerなしのCI環境

Dockerが実行されていない場合、テストはエラーを報告します:

java.lang.IllegalStateException: Could not find a valid Docker environment.

CIランナーはDockerを持つ必要があります。Dockerがない場合にテストをスキップし、失敗を報告しない場合は、@Testcontainers(disabledWithoutDocker = true)を使用してください。テストが静かにスキップされる傾向があるため、このオプションを有効にする前に慎重に検討してください。

5.5. 再利用によるローカルの高速化

ローカルでテスト実行間でコンテナーをアライブに保つには、~/.testcontainers.propertiesファイル内でreuseを有効にします:

testcontainers.reuse.enable=true

その後、コンテナーを作成する際に.withReuse(true)を呼び出します。CIでこのオプションを有効にしないでください。コンテナーが自動的にクリーンアップされないためです。

6. Testcontainersを使用すべきでない場合

Testcontainersはテストをより信頼できるものにしますが、各テストは低速になり、Dockerが必要です。いくつかのケースで使用すべきでない場合:

  • 純粋なロジック 価格計算、検証またはマッピングなど:mockまたは実際のオブジェクトでユニットテストを記述すれば十分です。ユニットテストは数百倍高速に実行されます。
  • 各テストクラスに異なる設定 各設定は新しいcontextを作成します。コンテナーがcontextに関連付けられている場合、複数のコンテナーが並行して実行されます。テストを少数の共通設定にグループ化してください。
  • HTTP経由のサードパーティサービス:通常、コンテナー内で実際のサービスを実行しようとする代わりにWireMockを使用する方が良いです。

合理的な比率は:大部分はユニットテストですが、Testcontainersとのintegration testはリポジトリ、ネイティブクエリ、マイグレーション、メッセージブローカーなどのインフラストラクチャにタッチするセクションに焦点を当てています。

7. 結論

このガイドでは、Testcontainersを使用してPostgreSQL上でintegration testを実行しました。@ServiceConnectionはデータソースの手動設定を完全に排除するのに役立ちます。@TestConfiguration内でコンテナーをbeanとして配置することで、複数のテストクラス間でコンテナーを共有でき、spring-boot:test-runを使用してアプリケーションをローカルで実行できます。

覚えるべき点:

  • イメージバージョンを本番環境と一致するように固定してください。
  • 静的な@ContainerをSpringのcontextキャッシュと混合しないでください。
  • テストが transaction 内で実行されない場合、データを自分でクリーンアップしてください。
  • 実際のデータベースが本当に必要な場所でのみTestcontainersを使用してください。
spring-boottestcontainersintegration-testjunit5postgresql