📦

Spring BootとTestcontainersで結合テストをする備忘録

2024/12/20に公開

この記事はNE Advent Calendar 2024の20日目の記事です。

はじめに

Spring Bootで結合テスト(インテグレーションテスト)をするにあたって、Testcontainersにより実際のDBを用いてテストを実行するためにやったことを記録しておきます。

背景

テストでも実際にDBにアクセスしたい

テストコードを書く場合、まずはテスト対象クラスの単体テストから始めますが、それだけでは不十分なケースもあります。例えばテスト対象がJpaRepositoryに依存していて、そのRepositoryに複雑な条件のクエリが記述されていた場合、Repositoryをモック(スタブ)にして開発者の意図した返り値を返すようにしてしまうと、テストの意味が薄れてしまいます。このようなデメリットを回避するため、こういったケースではテスト実行時にも実際にデータベースにアクセスしたいです。

テストでも「実際の」DBにアクセスしたい

私の参加するプロジェクトでは、本番のDBとしてMySQLを用いる一方でテスト時のDBとしてインメモリ型のH2 Databaseを用いており、結合テストをする場合は基本的にこれで事足りていました。しかしあるタイミングで、H2と互換性のないMySQL固有の関数を含むRepositoryに依存したテストを書く必要が出てきました。このままだとテストの実行ができないためRepositoryをモックにする必要があります。ただ今回、テスト対象の機能は「検索処理」であったため、それをしてしまっては結合テストの存在価値が大きく薄れてしまいます。これを回避するには、テスト実行時にも実際のMySQLにアクセスする必要があります。

Testcontainersの概要

Testcontainers is an open source library for providing throwaway, lightweight instances of databases, message brokers, web browsers, or just about anything that can run in a Docker container.
Testcontainers は、データベース、メッセージ ブローカー、Web ブラウザー、または Docker コンテナーで実行できるほぼすべてのものの使い捨ての軽量インスタンスを提供するオープン ソース ライブラリです。

https://testcontainers.com/
https://java.testcontainers.org/

「使い捨ての軽量インスタンス」という点が肝で、コンテナが立ち上がっているのはテストが実行されている間だけです。コンテナのライフサイクルを自分で管理する必要がないというのは大きなメリットかと思います。
今回使いたいのはデータベースのコンテナですが、WebブラウザのコンテナによるUIテストなんかも実行できるようです。

今回やりたいこと

  • 使用したいDBはMySQL。
  • スキーマ構造は単一のsqlファイルに定義されており、テスト実行前にそれを読み込みたい。
  • マスタデータ(商品カテゴリなど)も単一のsqlに定義されていて、これもテスト時には実際のデータを使いたい。
  • テスト時はSQLモードを緩和したい
    • 結合テストの保守コストを低減するため、テストケースにて登録するデータはできるだけNotNull制約を回避したい。
      • 例:カラムがVARCHAR(64) NOT NULLの場合、勝手に空文字が入ってほしい。

設定

application-test.properties
spring.datasource.url=jdbc:tc:mysql:8.4.0:///test?TC_INITSCRIPT=file:path/to/schema.sql&TC_MY_CNF=mysql_conf_override&jdbcCompliantTruncation=false&zeroDateTimeBehavior=convertToNull
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.username=test
spring.datasource.password=test
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=none
spring.sql.init.data-locations=file:path/to/master.sql
spring.sql.init.mode=always
mysql_conf_override/my.cnf
[mysqld]
character_set_server=utf8mb4
collation-server=utf8mb4_general_ci
# テストデータの登録を楽にするため、STRICT_TRANS_TABLESを外す
sql-mode='ONLY_FULL_GROUP_BY,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'

  • MySQLを使う
    • spring.datasource.urlにTestcontainersのJDBC URL(MySQL)を指定
    • spring.datasource.driver-class-name: TestcontainersのJDBCドライバを指定
  • スキーマ構造の読み込み
    • spring.datasource.urlにパラメータとしてTC_INITSCRIPTにスキーマ構造の定義されたSQLファイルを指定し、Testcontainer起動時に実行されるようにする。
    • spring.jpa.hibernate.ddl-autononeを指定し、エンティティ定義からDDL(データ定義言語)の自動生成・実行をしないようにする。
  • マスタデータの読み込み
    • spring.sql.init.data-locationsにマスタデータのSQLファイルパスを指定
    • spring.sql.init.modealwaysを指定し、指定のSQLでデータベースの初期化が行われるようにする。
  • NotNull制約の回避
    • TC_MY_CNFにテスト用のmy.cnfが配置されたディレクトリを相対パスで指定。
      • my.cnfでは、sql-modeからSTRICT_TRANS_TABLESを外す。
    • jdbcCompliantTruncationtrueだとmy.cnfでSQLモードを緩和してもSTRICT_TRANS_TABLESで接続してしまうためfalseにセット。

テストを書く

@SpringBootTest // 1
@Transactional // 2
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 3
@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) // 4
class SampleServiceTest {

    @Test
    @Sql({"SampleServiceTest/sampleMethod/sample_records.sql"})
    void sampleMethod() {
        // Arrange, Act, Assert
    }
}
  1. 結合テストはSpring Bootのコンテキストで実行したいので指定
  2. 各テストケースをトランザクション配下で実行することで、テスト終了後にDBの状態をテスト前の状態にrollbackするために指定
  3. Springがデフォルト提供する組み込みデータベースでなくTestcontainersが用意したものを用いたいため指定
  4. @Sqlアノテーションをクラスレベル(テスト用の初期ユーザーの用意など)とメソッドレベル(テストケースに必要なデータ)に指定した場合、両方のスクリプトがマージされて実行されるようにするため指定

これを各テストケースに毎回指定するのは面倒なので、自作のアノテーションにまとめて定義してしまうのが良いと思います。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)

@SpringBootTest
@Transactional
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@SqlMergeMode(SqlMergeMode.MergeMode.MERGE)
public @interface IntegrationTest {
}

おわりに

Testcontainersで快適なテストライフを。

NE株式会社の開発ブログ

Discussion