KINTO Tech Blog
約15分で読めます
Development

Spring BatchとDBUnitを使ったテストで起きた問題

Spring BatchとDBUnitを使ったテストで起きた問題 cover

Spring BatchとDBUnitを使ったテストで起きた問題

自己紹介

こんにちは。プラットフォーム開発部/共通サービス開発グループ[1][2][3][4][5][6]/決済プラットフォームチームの竹花です。
今回は、Spring Batch + DBUnitを使ったテストで遭遇した問題について書きたいと思います。

環境

ライブラリ等 バージョン
Java 17
MySQL 8.0.23
Spring Boot 3.1.5
Spring Boot Batch 3.1.5
JUnit 5.10.0
Spring Test DBUnit 1.3.0

遭遇した問題

  • Spring Boot3 + Spring Batchのテストにおいて、DBUnitを使っている。
  • BatchはChunkモデルで、ItemReaderでDB検索、ItemWriterでDB更新を行っている。
  • 上記の前提で、Chunkサイズ以上のデータ件数でテスト実行すると、テストが終わらない...

確認したこと、試したこと

現象確認

  • コード
new StepBuilder("step", jobRepository)
        .<InputDto, OutputDto>chunk(
            CHUNK_SIZE, transactionManager)
        .reader(reader)
        .processor(processor)
        .writer(writer)
        .build();

上記のようなStepを含むバッチを以下のようにテストしていました。

@SpringBatchTest
@SpringBootTest
@TestPropertySource(
    properties = {
      "spring.batch.job.names: hoge-batch",
      "targetDate: 2023-01-01",
    })
@Transactional(isolation = Isolation.SERIALIZABLE)
@TestExecutionListeners({
  DependencyInjectionTestExecutionListener.class,
  DirtiesContextTestExecutionListener.class,
  TransactionDbUnitTestExecutionListener.class
})
@DbUnitConfiguration(dataSetLoader = XlsDataSetLoader.class)
class HogeBatchJobTest {

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @BeforeEach
    void setUp() {
    }

    @Test
    @DatabaseSetup("classpath:dbunit/test_data_import.xlsx")
    @ExpectedDatabase(
            value = "classpath:dbunit/data_expected.xlsx",
            assertionMode = DatabaseAssertionMode.NON_STRICT_UNORDERED)
    void launchJob() throws Exception {
        val jobExecution = jobLauncherTestUtils.launchJob();
        assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus());
    }
}

テストデータをchunkサイズより少なく設定すると問題なくテストをパスしますが、
テストデータをchunkサイズ以上に設定すると途中でフリーズしてしまい、テストが終わらなくなりました。
(Chunkサイズ1でデータ件数1でも発生)

  • DBのコネクションを疑ってみた
    Spring Batchは1つのchunkで1トランザクションとなります。
    これを並列で処理するのであれば、同時実行数以上のDBコネクションが必要ではと考え、poolサイズを変更して確かめてみました。
spring:
  datasource:
    hikari:
      maximum-pool-size: 10 → 100に変更など

しかし、変更しても問題は解消されませんでした...

デバッグ開始

debugログを設定して、動作させてみました。
image20240419_1.png
org.springframework.batch.core.step.item.ChunkOrientedTaskletの88行目のログ出力で止まっているようなので、ブレークポイントを貼って実行確認します。

たどり着いたのが、org.springframework.batch.core.step.tasklet.TaskletStepの408行目。
image20240419_2.png
どうやらセマフォがロックできない(=ロックの解放待ち)となっており、こちらで止まっているようでした。

Spring Batchの深淵へ

引き続き、ステップ実行で処理の流れを追跡しました。
関連する箇所について、ざっくり以下の流れとなっていました。

  1. TaskletStepdoExecuteが実行される
  2. セマフォを作成
  3. TransactionSynchronizationの実装であるChunkTransactionCallbackにセマフォを渡し、トランザクション実行と紐付けてRepeatTemplateに設定
  4. chunk分を対象にステップ処理が開始される
  5. TaskletStepdoInTransactionでセマフォのロックが行われる
  6. ステップの主処理実行
  7. TransactionSynchronizationUtils`でcommitが実行される
  8. AbstractPlatformTransactionManagertriggerAfterCompletionメソッドが呼ばれ、処理内のinvokeAfterCompletion`が実行される。
  9. invokeAfterCompletionChunkTransactionCallbackafterCompletion`メソッドで、セマフォの解放が行われる。
  10. データがまだ残っているなら、4に戻る

今回のテスト実行においては、9のセマフォの解放が行われないまま、再度4を経由して、5でフリーズした状態となっていました。

なぜ、セマフォが解放されないのか...
上記の確認の中のセマフォの解放において、該当コードに以下の判定がありました。
image20240419_3.png

status.isNewSynchronization()trueにならず、invokeAfterCompletionが実行されませんでした。

org.springframework.transaction.support.DefaultTransactionStatus#isNewSynchronizationは以下のようになっています。

/**
 * Return if a new transaction synchronization has been opened
 * for this transaction.
 */
public boolean isNewSynchronization() {
    return this.newSynchronization;
}

このトランザクションのために新しいトランザクション同期が開かれたかどうかを返す。

考察

なぜisNewSynchronizationtrueにならないのかですが、そこを追え切れていないのが現状です。
ですが、いくつか試行錯誤してみた中のログにそのヒントがある気がしました。

テストクラスに@Transactionalをつけない場合

2024-03-27T08:57:14.527+0000 [Test worker] TRACE o.s.t.i.TransactionInterceptor - Completing transaction for [org.springframework.batch.core.repository.support.SimpleJobRepository.update] hoge-batch 19
2024-03-27T08:57:14.527+0000 [Test worker] DEBUG o.s.orm.jpa.JpaTransactionManager - Initiating transaction commit hoge-batch 19
2024-03-27T08:57:14.527+0000 [Test worker] DEBUG o.s.orm.jpa.JpaTransactionManager - Committing JPA transaction on EntityManager [SessionImpl(1075727694<open>)] hoge-batch 19
2024-03-27T08:57:14.534+0000 [Test worker] DEBUG o.s.orm.jpa.JpaTransactionManager - Closing JPA EntityManager [SessionImpl(1075727694<open>)] after transaction hoge-batch 19
2024-03-27T08:57:14.536+0000 [Test worker] DEBUG o.s.b.repeat.support.RepeatTemplate - Repeat operation about to start at count=2 hoge-batch 19

テストクラスに@Transactionalをつけた場合

2024-03-27T09:04:04.600+0000 [Test worker] TRACE o.s.t.i.TransactionInterceptor - Completing transaction for [org.springframework.batch.core.repository.support.SimpleJobRepository.update] hoge-batch 20
2024-03-27T09:04:04.601+0000 [Test worker] DEBUG o.s.b.repeat.support.RepeatTemplate - Repeat operation about to start at count=2 hoge-batch 20

@Transactionalをつけた場合に、JpaTransactionManagerの「Initiating transaction commit...」が出力されていません。
テストクラスはTransactionalTestExecutionListenerを使っており、@Transactionalで同一トランザクションで実行されています。
DBUnitで登録したテストデータを、テスト対象処理で参照可能にし、テスト後に破棄(rollback)するためです。
しかし、これによって同一Stepの繰り返し実行時にも既存トランザクションが使いまわされている(=新規のトランザクションが開始されていない)ことで、isNewSynchronizationtrueにならないのではないかと結論づけました。

回避方法

  • TransactionalTestExecutionListenerを使わないようにする
    力技ですが、TransactionalTestExecutionListenerを使わず自前でテスト後のクリーンアップをすることでフリーズを回避できました。
class HogeTestExecutionListenerChain extends TestExecutionListenerChain {
  private static final Class<?>[] CHAIN = {
    HogeTransactionalTestExecutionListener.class, DbUnitTestExecutionListener.class
  };

  @Override
  protected Class<?>[] getChain() {
    return CHAIN;
  }
}

class HogeTransactionalTestExecutionListener implements TestExecutionListener {

    private static final String CREATE_BACKUP_TABLE_SQL =
            "CREATE TEMPORARY TABLE backup_%s AS SELECT * FROM %s";

    private static final String TRUNCATE_TABLE_SQL = "TRUNCATE TABLE %s";

    private static final String BACKUP_INSERT_SQL = "INSERT INTO %s SELECT * FROM backup_%s";

    private static final List<String> TARGET_TABLE_NAMES =
            List.of(
                    "hoge",
                    "fuga",
                    "dadada");

    /**
     * テスト用作業テーブルを作成する
     *
     * @param testContext
     * @throws Exception
     */
    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {
        val dataSource = (DataSource) testContext.getApplicationContext().getBean("dataSource");
        val jdbcTemp = new JdbcTemplate(dataSource);

        // テスト前に既存データを一時テーブルにbackup
        TARGET_TABLE_NAMES.forEach(
                tableName ->
                        jdbcTemp.execute(String.format(CREATE_BACKUP_TABLE_SQL, tableName, tableName)));

        // テーブル初期化
        TARGET_TABLE_NAMES.forEach(
                tableName -> jdbcTemp.execute(String.format(TRUNCATE_TABLE_SQL, tableName)));
    }

    /**
     * テスト用作業テーブルを削除する
     *
     * @param testContext
     * @throws Exception
     */
    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        val dataSource = (DataSource) testContext.getApplicationContext().getBean("dataSource");
        val jdbcTemp = new JdbcTemplate(dataSource);
        // テーブルを元に戻す
        TARGET_TABLE_NAMES.forEach(
                tableName -> jdbcTemp.execute(String.format(TRUNCATE_TABLE_SQL, tableName, tableName)));

        TARGET_TABLE_NAMES.forEach(
                tableName -> jdbcTemp.execute(String.format(BACKUP_INSERT_SQL, tableName, tableName)));
    }
}

TransactionDbUnitTestExecutionListenerを外し、TransactionalTestExecutionListenerを使わないようにします。
(エクセルのテストデータ読み込みは利用したいため、DbUnitTestExecutionListenerは使用します)
カスタムのTestExecutionListenerを作成し、 事前処理にて対象テーブルのデータを一時テーブルに移し、テスト後に元に戻すようにしています。
beforeTestMethodはテストメソッドよりも前に実行され、afterTestMethodはテストメソッドよりも後に実行されます。
上記により、Springのトランザクション管理そのままでテスト実行できるようになりました。

所感

検索してもしっくりくるような情報を見つけられず、暗中模索となった問題でした。
とはいえ、Spring Bootのソースを深掘りして見ていくことで、様々な発見もあり、学びのあるコードリーディングであったと思います。
(理解は追いついていませんが...)

そもそもSpringやテストライブラリの使い方を間違えているのではないか等、
ライブラリ作成者の前提に基づいて妥当な実装にできているか、他に適切なクラスなどがあるのではないかなど疑問をもって、
引き続き学ばなければならないことがあるなと感じるとともに、
今後も「どうなってるんだろう?」という好奇心を持って、探究と改善に取り組んでいきたいと思いました。

本記事をお読みいただきありがとうございました。
同様の問題に悩む方の参考になれば幸いです。

脚注
  1. 共通サービス開発グループメンバーによる投稿 1
    [ グローバル展開も視野に入れた決済プラットフォームにドメイン駆動設計(DDD)を取り入れた ] ↩︎

  2. 共通サービス開発グループメンバーによる投稿 2
    [入社 1 年未満メンバーだけのチームによる新システム開発をリモートモブプログラミングで成功させた話] ↩︎

  3. 共通サービス開発グループメンバーによる投稿 3
    [JIRA と GitHub Actions を活用した複数環境へのデプロイトレーサビリティ向上の取り組み] ↩︎

  4. 共通サービス開発グループメンバーによる投稿 4
    [ VSCode Dev Container を使った開発環境構築] ↩︎

  5. 共通サービス開発グループメンバーによる投稿 5
    [ Spring Bootを2系から3系へバージョンアップしました。] ↩︎

  6. 共通サービス開発グループメンバーによる投稿 6
    [ MinIOを用いたS3ローカル開発環境の構築ガイド] ↩︎

Facebook

関連記事 | Related Posts

We are hiring!

シニアQAエンジニア(責任者候補)/Quality Engineering G/東京・大阪

「テストするQA」から「品質戦略を描くQA」へ。AIがコードを書くことが当たり前になりつつある今、品質保証のあり方そのものが問われています。

PjM(新規システムの構想検討とプロジェクト推進)/ プロジェクト推進G/東京・名古屋

業務内容トヨタグループ内でデジタル領域における業務改善やシステム化を推進していただくポジションです。現場に深く入り込み、実務と企画の両面から支援を行います。

Follow Us

XConnpassWantedly

KINTOテクノロジーズの最新情報をSNSで発信中!イベント・テックブログ更新情報もお届けします。

SNS一覧を見る