DataSourceが複数ある場合にSpring Testの@Sqlを使う場合の落とし穴
やりたいこと
DataSourceが複数ある場合のテスト時に、Spring Testの@Sql
を使ってそれぞれのDataSourceにデータ投入を行いたいです。
Spring Bootで DataSource
を複数作る方法は、👇の記事を参照してください。
忙しい人のためのまとめ
-
@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に対するDataSource
・JdbcTemplate
・PlatformTransactionManager
のBeanをそれぞれ定義しています。
勝手なコミットを防ぐためにautoCommit
はfalse
にしています。
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
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.PARAMETER, ElementType.FIELD})
@Inherited
@Qualifier
public @interface Db01 {
}
@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"))
);
}
}
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はちゃんと実行されているように見えます。
...
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を見てみると、transactionManager
やtransactionMode
という気になる要素があります。
そういえば、トランザクションの設定ってしてなかったな・・・
transactionManager
要素
Javadocによると、transactionManager
にはPlatformTransactionManager
のBean IDを指定すればいいようです。
指定しなかった場合のデフォルト値は空文字(""
)とのこと。そして、その場合の挙動はこちら。
Defaults to an empty string, requiring that one of the following is true:
- An explicit bean name is defined in a global declaration of @SqlConfig.
- There is only one bean of type PlatformTransactionManager in the test's ApplicationContext.
- TransactionManagementConfigurer has been implemented to specify which PlatformTransactionManager bean should be used for annotation-driven transaction management.
- The PlatformTransactionManager to use is named "transactionManager".
要訳:
デフォルト値は空文字で、次のうち1つがtrueである必要がある:
- グローバルな
@SqlConfig
に指定されたBean Id。- ただ1つの
PlatformTransactionManager
型のBeanがApplicationContext
にある。- アノテーションによるトランザクション管理で利用される
PlatformTransactionManager
のBeanが、TransactionManagementConfigurer
で指定されている。"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の説明を見てみましょう。
- If neither a transaction manager nor a data source is available, an exception will be thrown.
- 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.
- 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.
要訳:
- トランザクションマネージャーもデータソースも利用不可(訳注:各要素にBean IDが指定されていないことを指します)の場合、例外
- トランザクションマネージャーは利用不可だがデータソースが利用可能な場合、SQLはトランザクション無しで実行される
- トランザクションマネージャーが利用可能な場合
- データソースが利用不可の場合、トランザクションマネージャーの
getDataSource()
でデータソース取得を試みる。試みが失敗した場合、例外- 解決されたトランザクションマネージャーとデータソースを利用して、SQLは既存トランザクション内で実行される。トランザクションが無い場合は新規に作成する。
先ほどのケースでは2.が該当します。
つまり原因は?
-
transactionManager
要素を指定していなかった+PlatformTransactionManager
Beanが複数あった+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_ERROR
やIGNORE_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