📖

DataSourceが複数ある場合にSpring Testの@Sqlを使う場合の落とし穴

2024/08/10に公開

やりたいこと

DataSourceが複数ある場合のテスト時に、Spring Testの@Sqlを使ってそれぞれのDataSourceにデータ投入を行いたいです。

Spring Bootで DataSource を複数作る方法は、👇の記事を参照してください。

https://qiita.com/suke_masa/items/92fcd8b30e55f67c0855

忙しい人のためのまとめ

  • @SqlConfigに必ず下記を設定しましょう
    • dataSource要素にDataSourceのBean ID
    • transactionManager要素にPlatformTransactionManagerのBean ID
  • 必須ではありませんが下記も明示的に設定した方が良いでしょう
    • transactionMode要素にISOLATED
    • errorMode要素にFAIL_ON_ERROR

検証した環境

  • Spring Framework 5.3.9
  • Spring Boot 2.5.4
  • JUnit 5.7.2
  • Oracle OpenJDK 17.0.0
  • PostgreSQL 13.2 (Docker上で2コンテナを実行)
  • macOS 11.6

未確認ですが、多少バージョンが違ってても挙動は同じだと思います。また、Spring Bootを使わなくても同様だと思います。

サンプルアプリ

アプリ側では、DB01・DB02という2つのDBに対するDataSourceJdbcTemplatePlatformTransactionManagerのBeanをそれぞれ定義しています。

勝手なコミットを防ぐためにautoCommitfalseにしています。

application.yml
datasource:
  db01:
    poolName: Pool01
    jdbcUrl: jdbc:postgresql://localhost:5001/postgres01
    username: postgres01
    password: postgres01
    driverClassName: org.postgresql.Driver
    autoCommit: false
    connectionTimeout: 500
  db02:
    poolName: Pool02
    jdbcUrl: jdbc:postgresql://localhost:5002/postgres02
    username: postgres02
    password: postgres02
    driverClassName: org.postgresql.Driver
    autoCommit: false
    connectionTimeout: 500
DB01を表すQualifier
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD})
@Inherited
@Qualifier
public @interface Db01 {
}
DB02を表すQualifier
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD})
@Inherited
@Qualifier
public @interface Db02 {
}
@Configuration
public class Db01Config {
    
    public static final String DATASOURCE_NAME = "db01DataSource";
    public static final String TRANSACTION_MANAGER_NAME = "db01TransactionManager";
    
    @Bean(name = DATASOURCE_NAME)
    @Db01
    @ConfigurationProperties(prefix = "datasource.db01")
    public DataSource db01DataSource() {
        return new HikariDataSource();
    }
    
    @Bean
    @Db01
    public JdbcTemplate db01JdbcTemplate(@Db01 DataSource dataSource) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        return jdbcTemplate;
    }
    
    @Bean(name = TRANSACTION_MANAGER_NAME)
    @Db01
    public PlatformTransactionManager db01TransactionManager(@Db01 DataSource dataSource) {
        return new JdbcTransactionManager(dataSource);
    }
}
@Configuration
public class Db02Config {
    
    public static final String DATASOURCE_NAME = "db02DataSource";
    public static final String TRANSACTION_MANAGER_NAME = "db02TransactionManager";
    
    @Bean(name = DATASOURCE_NAME)
    @Db02
    @ConfigurationProperties(prefix = "datasource.db02")
    public DataSource db02DataSource() {
        return new HikariDataSource();
    }

    @Bean
    @Db02
    public JdbcTemplate db02JdbcTemplate(@Db02 DataSource dataSource) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        return jdbcTemplate;
    }
    
    @Bean(name = TRANSACTION_MANAGER_NAME)
    @Db02
    public PlatformTransactionManager db02TransactionManager(@Db02 DataSource dataSource) {
        return new JdbcTransactionManager(dataSource);
    }
}

とりあえずやってみる

@SqlConfigにはdataSourceという要素があり、コレにDataSourceのBean Idを指定すればいいようです。

問題のあるテストコード
@SpringBootTest
public class Db01Test {
    
    @Autowired
    @Db01
    JdbcTemplate jdbcTemplate;
    
    @Sql(scripts = {"/data-db01.sql"}, config = @SqlConfig(
            dataSource = Db01Config.DATASOURCE_NAME))
    @Test
    public void test() {
        List<Map<String, Object>> maps =
            jdbcTemplate.queryForList("SELECT * FROM sample");
        assertAll(
                () -> assertEquals(1, maps.size()),
                () -> assertEquals("test01", maps.get(0).get("comment"))
        );
    }
}
data-db01.sql
DELETE FROM sample;
INSERT INTO sample(comment) VALUES('test01');

見た感じ良さそうなんですが、何故かテストがNGと言われます。

実行結果
expected: <1> but was: <0>
Comparison Failure: 
Expected :1
Actual   :0
<Click to see difference>



java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
...(以下スタックトレース省略)

ログレベルをDEBUGにしても、SQLはちゃんと実行されているように見えます。

DEBUGログ
...
2021-09-30 18:04:30.502 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from class path resource [create-table.sql]
2021-09-30 18:04:30.508 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: DROP TABLE IF EXISTS sample
2021-09-30 18:04:30.512 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: DROP SEQUENCE IF EXISTS seq_sample_id
2021-09-30 18:04:30.525 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: CREATE SEQUENCE seq_sample_id INCREMENT BY 1 START WITH 1
2021-09-30 18:04:30.561 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: CREATE TABLE sample( id INTEGER PRIMARY KEY DEFAULT nextval('seq_sample_id'), comment VARCHAR(32) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )
2021-09-30 18:04:30.562 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executed SQL script from class path resource [create-table.sql] in 60 ms.
2021-09-30 18:04:32.225  INFO 24479 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 23 endpoint(s) beneath base path '/actuator'
2021-09-30 18:04:32.287  INFO 24479 --- [           main] c.e.Db01Test      : Started Db01Test in 4.858 seconds (JVM running for 6.744)
2021-09-30 18:04:32.410 DEBUG 24479 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Fetching JDBC Connection from DataSource
2021-09-30 18:04:32.418 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executing SQL script from class path resource [data-db01.sql]
2021-09-30 18:04:32.424 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 0 returned as update count for SQL: DELETE FROM sample
2021-09-30 18:04:32.428 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : 1 returned as update count for SQL: INSERT INTO sample(comment) VALUES('test01')
2021-09-30 18:04:32.428 DEBUG 24479 --- [           main] o.s.jdbc.datasource.init.ScriptUtils     : Executed SQL script from class path resource [data-db01.sql] in 10 ms.
2021-09-30 18:04:33.022 DEBUG 24479 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing SQL query [SELECT * FROM sample]
...

調べてみる

何だコレは???

改めて@SqlConfigのJavadocを見てみると、transactionManagertransactionModeという気になる要素があります。

そういえば、トランザクションの設定ってしてなかったな・・・

transactionManager要素

Javadocによると、transactionManagerにはPlatformTransactionManagerのBean IDを指定すればいいようです。

指定しなかった場合のデフォルト値は空文字("")とのこと。そして、その場合の挙動はこちら。

Defaults to an empty string, requiring that one of the following is true:

  1. An explicit bean name is defined in a global declaration of @SqlConfig.
  2. There is only one bean of type PlatformTransactionManager in the test's ApplicationContext.
  3. TransactionManagementConfigurer has been implemented to specify which PlatformTransactionManager bean should be used for annotation-driven transaction management.
  4. The PlatformTransactionManager to use is named "transactionManager".

要訳:

デフォルト値は空文字で、次のうち1つがtrueである必要がある:

  1. グローバルな@SqlConfigに指定されたBean Id。
  2. ただ1つのPlatformTransactionManager型のBeanがApplicationContextにある。
  3. アノテーションによるトランザクション管理で利用されるPlatformTransactionManagerのBeanが、TransactionManagementConfigurerで指定されている。
  4. "transactionManager"という名前でPlatformTransactionManagerが定義されている。

先ほどのケースでは、どれも該当しませんね。

transactionMode要素

どうやらデフォルト値はSqlConfig.TransactionMode.DEFAULTのようです。

ではSqlConfig.TransactionModeのJavadocを見てみます。DEFAULTの説明には、

  • If @SqlConfig is declared only locally, the default transaction mode is INFERRED.

訳:

  • @SqlConfigがローカルにのみ(訳注:「ローカル」はメソッド、「グローバル」はクラスのことと思われます)付加されている場合、デフォルトのtransactionModeはINFERREDです。

とあります。ではINFERREDの説明を見てみましょう。

  1. If neither a transaction manager nor a data source is available, an exception will be thrown.
  2. If a transaction manager is not available but a data source is available, SQL scripts will be executed directly against the data source without a transaction.
  3. If a transaction manager is available:
    • If a data source is not available, an attempt will be made to retrieve it from the transaction manager by using reflection to invoke a public method named getDataSource() on the transaction manager. If the attempt fails, an exception will be thrown.
    • Using the resolved transaction manager and data source, SQL scripts will be executed within an existing transaction if present; otherwise, scripts will be executed in a new transaction that will be immediately committed. An existing transaction will typically be managed by the TransactionalTestExecutionListener.

要訳:

  1. トランザクションマネージャーもデータソースも利用不可(訳注:各要素にBean IDが指定されていないことを指します)の場合、例外
  2. トランザクションマネージャーは利用不可だがデータソースが利用可能な場合、SQLはトランザクション無しで実行される
  3. トランザクションマネージャーが利用可能な場合
    • データソースが利用不可の場合、トランザクションマネージャーのgetDataSource()でデータソース取得を試みる。試みが失敗した場合、例外
    • 解決されたトランザクションマネージャーとデータソースを利用して、SQLは既存トランザクション内で実行される。トランザクションが無い場合は新規に作成する。

先ほどのケースでは2.が該当します。

つまり原因は?

  • transactionManager要素を指定していなかった+PlatformTransactionManagerBeanが複数あった+transactionMode要素を指定していなかったため、トランザクション無しでSQLが実行されていた
  • それに加えて、autoCommitがfalseだったため、SQLがコミットされずにロールバックされていた

これにより、DBにSQLのデータが追加されていなかったのです。

これは分からん・・・ログに何も出ないし・・・

どうすればいいか?

まずはtransactionManager要素にPlatformTransactionManagerのBean IDを指定します。

transactionMode要素はどうしましょうか?INFERREDの他にISOLATEDという値があるようです。Javadocによると

Indicates that SQL scripts should always be executed in a new, isolated transaction that will be immediately committed.
In contrast to INFERRED, this mode requires the presence of a transaction manager and a data source.

要訳:

SQLが新規の分離されたトランザクション(訳注:テストメソッド自身のトランザクションとは別のトランザクション)で実行されすぎにコミットされることを示します。
INFERREDと比較して、このモードはトランザクションマネージャーとデータソースの存在を必須とします。

今回のSQLはテストメソッドと別トランザクションでいいし、かつトランザクションマネージャーとデータソースが必須になるということで、こちらのほうが間違いが少なくなりそうです。

念のため他の要素も見ておく

心配になってきたので、もうちょっと@SqlConfigのJavadocを読んでおきましょう。

そうすると、errorModeという要素を見つけました。他の要素はSQLのコメント・区切り文字・文字コードなどの設定なので、今回は関係無さそうです。

errorModeのJavadocを読んでみます。デフォルト値はSqlConfig.ErrorMode.DEFAULTのようです。

ではSqlConfig.ErrorModeのJavadocを見てみます。DEFAULTの説明は次のとおりです:

  • If @SqlConfig is declared only locally, the default error mode is FAIL_ON_ERROR.
  • If @SqlConfig is declared globally, the default error mode is FAIL_ON_ERROR.
  • If @SqlConfig is declared globally and locally, the default error mode for the local declaration is inherited from the global declaration.

要訳:

  • ローカルのみに@SqlConfigが付加されている場合 FAIL_ON_ERROR
  • グローバルのみに@SqlConfigが付加されている場合 FAIL_ON_ERROR
  • グローバルとローカル両方に付加されている場合、デフォルトのエラーモードはグローバルから継承される

今回だと2番目が該当します。ではFAIL_ON_ERRORの説明を見てみましょう:

Indicates that script execution will fail if an error is encountered. In other words, no errors should be ignored.

要訳:

エラーがあった場合SQLの実行が失敗する。言い換えれば、エラーは無視されない。

他のCONTINUE_ON_ERRORIGNORE_FAILED_DROPSはエラーを無視する場合があるので、明示的にFAIL_ON_ERRORを指定したほうが分かりやすそうです。

正しいテストコード

以上から、次のようにすればよいことが分かりました。

  • dataSource要素にDataSourceのBean ID
  • transactionManager要素にPlatformTransactionManagerのBean ID
  • transactionMode要素にISOLATED(必須ではないが明示的に設定)
  • errorMode要素にFAIL_ON_ERROR(必須ではないが明示的に設定)
正しいテストコード
@SpringBootTest
public class Db01Test {
    
    @Autowired
    @Db01
    JdbcTemplate jdbcTemplate;
    
    @Sql(scripts = {"/data-db01.sql"}, config = @SqlConfig(
        dataSource = Db01Config.DATASOURCE_NAME,
        transactionManager = Db01Config.TRANSACTION_MANAGER_NAME,
        transactionMode = SqlConfig.TransactionMode.ISOLATED,
        errorMode = SqlConfig.ErrorMode.FAIL_ON_ERROR
    ))
    @Test
    public void test() {
        List<Map<String, Object>> maps =
            jdbcTemplate.queryForList("SELECT * FROM sample");
        assertAll(
                () -> assertEquals(1, maps.size()),
                () -> assertEquals("test01", maps.get(0).get("comment"))
        );
    }
}

これで正しく動作しました。

今回の教訓

公式ドキュメントを見る場合、それっぽい説明を見るだけで満足するのではなく、他の部分の説明もザッとでいいので読んでおいたほうがいいなと思いました。意外な発見や落とし穴があるかもしれませんし・・・

Discussion