【Spring Batch ハンズオン】単体テスト徹底解説!CompositeItemWriterで複数出力先へ堅牢にデータ書き込みロジックを徹底検証する!

はじめに: CompositeItemWriterテストのベストプラクティスとこの記事で学ぶこと

バッチ処理で複数のデータソースに書き込む際、エラーでデータが不整合になった経験はありませんか? この記事では、そんなあなたの悩みを解決するCompositeItemWriterと、それを堅牢にするテスト手法を伝授します!

Spring Batchは強力なバッチ処理フレームワークですが、一歩踏み込んだ実装をしようとすると、思わぬ落とし穴にはまることも少なくありません。

このドキュメントは、単なる機能ガイドではありません。

Spring BatchのCompositeItemWriterを使って「正常系処理」と「異常系処理」を同居させるという少し複雑な要件に挑戦し、データ書き込みロジックが期待通りに機能するかを検証する方法をハンズオン形式で解説します。


目次

  1. CompositeItemWriterとは?
    1-1. Spring Batchのデータ処理フローとItemWriterの位置づけ
    1-2. CompositeItemWriterの概要
    1-3. CompositeItemWriterの主要な特徴
    1-4. CompositeItemWriterの主要なユースケース
  2. テスト戦略の概要
    テストコードとの連携: CompositeItemWriterの動作検証
  3. テスト対象のCompositeItemWriterと関連コンポーネント
    3-1. 今回のシナリオとコンポーネント仕様
    3-2. 実装: 関連コンポーネント
    3-3. 実装: CompositeProductWriterJobConfig (ジョブ設定)
  4. テスト環境の準備
    4-1. 補足:モックオブジェクトの準備と振る舞いの定義
    4-2. @Mockと@InjectMocksの役割 (CompositeItemWriterのテストにおける考慮事項)
    4-3. 依存関係の追加
  5. CompositeItemWriterの単体テスト実装
    ProductArchiveWriterUnitTest.java
    ExceptionThrowingProductWriterの単体テスト
    CompositeProductWriterUnitTest.java
  6. 実装したジョブの実行方法
    6-1. すべてのテストを実行する
    6-2. 個別の単体テストを実行する
  7. まとめ:CompositeItemWriterでSpring Batchをより堅牢に
  8. 【補足1】テストデータの準備
  9. 【補足2】Job全体の動作を確認する(結合テスト)

対象読者

  • Spring Batchの基本的な概念を理解しており、さらに実践的なテスト技術を習得したい方
  • CompositeItemWriterを用いた複数の書き込み処理の実装と、その堅牢なテスト手法に関心のある開発者
  • Mockitoなどのモックフレームワークを用いたSpring Batchコンポーネントの単体テストの具体例を知りたいエンジニア
  • Spring Batchアプリケーションの品質と信頼性向上を目指すアーキテクトやチームリーダー
  • バッチ処理におけるトランザクション管理と例外処理のベストプラクティスを学びたい方

1. CompositeItemWriterとは?

1-1. Spring Batchのデータ処理フローとItemWriterの位置づけ

Spring Batchは、大量データを効率的に処理するためのフレームワークです。基本的な処理フローは、以下の3つの主要なコンポーネントで構成されます。

  1. ItemReader: データソースからデータを「読み込む」役割を担います。
  2. ItemProcessor: 読み込んだデータを「処理・変換する」役割を担います。
  3. ItemWriter: 処理されたデータを永続層や外部システムに「書き込む」役割を担います。

1-2. CompositeItemWriterの概要

Spring Batchでデータ処理を行う際、「読み込んだデータをデータベースに保存するだけでなく、別のシステムへ連携するためにCSVファイルにも出力したい」といった複数の異なる書き込み処理を一度に行いたい、という要件に直面することがよくあります。

例えば、

  • ECサイトの注文データを処理する際、注文情報をデータベースに保存し、同時に在庫管理システムへ連携するためのメッセージをキューに書き込む。
  • ユーザーの活動ログを収集し、分析用のデータウェアハウスに保存しつつ、リアルタイム監視システムへアラート情報を送信する。

このようなシナリオで、それぞれの書き込み処理を独立したItemWriterとして実装し、それらを効率的かつ安全に連携させる必要がある場合、Spring Batchが提供するCompositeItemWriterが非常に強力なツールとなります。

CompositeItemWriterは、このItemWriterのフェーズにおいて、複数の異なる書き込み処理を統合し、あたかも一つのWriterであるかのように扱うための強力なツールです。これにより、複雑なデータ永続化要件や外部システム連携を、クリーンかつ宣言的に実現できます。

ItemReaderでデータを読み込み、ItemProcessorで処理した後、CompositeItemWriterが複数のデリゲートItemWriterに処理を委譲し、最終的にデータがデータベースや外部システムに書き込まれるまでの全体像

この図は、ItemReaderでデータを読み込み、ItemProcessorで処理した後、CompositeItemWriterが複数のデリゲートItemWriterに処理を委譲し、最終的にデータがデータベースや外部システムに書き込まれるまでの全体像を示しています。CompositeItemWriterが、バッチ処理のデータ出力層において、いかに柔軟性と堅牢性を提供するかがお分かりいただけるでしょう。


1-3. CompositeItemWriterの主要な特徴

CompositeItemWriterは、まるで「複数の配送業者(各ItemWriter)に荷物(データ)の配送を依頼し、全ての配送が滞りなく完了するまで、そのプロセス全体を一つの取引(トランザクション)として厳密に管理する物流センター」のような役割を果たします。その動作を理解する上で、特に重要な3つの特徴があります。

CompositeItemWriterを理解する上で、特に重要な3つの特徴があります。

  • 複数のItemWriterを束ねる「デリゲート」パターン:
    • CompositeItemWriterは、内部に複数のItemWriter(これらを「デリゲート」と呼びます)のリストを持ちます。これらのデリゲートは、setDelegates()メソッドを使って、処理を実行させたい順番で設定します。
    • このように複数のWriterを一つにまとめることで、バッチ処理の設定がシンプルになり、各ItemWriterが自身の責務に集中できるため、コードの可読性やメンテナンス性が向上します。
  • 設定順序に基づく「順次実行」:
    • CompositeItemWriterwrite()メソッドが呼び出されると、setDelegates()で設定されたデリゲートのリストの順番通りに、一つずつwrite()メソッドが実行されます。
    • この順序は非常に重要です。例えば、最初にデータベースに書き込み、次にその結果を基にファイル出力を行う、といった依存関係のある処理の場合、正しい順序でデリゲートを設定する必要があります。
  • 堅牢な「トランザクションの一貫性」:
    • Spring Batchのチャンク指向処理の強力な特徴の一つとして、CompositeItemWriter内部のすべてのデリゲートItemWriterが、同じトランザクションコンテキスト内で動作する点が挙げられます。
    • これにより、もしデリゲートの一つでも書き込み処理中にエラーが発生した場合、そのトランザクション全体がロールバックされ、それまでのすべての書き込み処理が「なかったこと」になります。
    • 例えば、データベースへの書き込みは成功したが、その後のファイル出力で失敗した場合でも、データベースへの書き込みは自動的に取り消されます。これにより、データの一部だけが書き込まれてしまうような「データ不整合」を防ぎ、バッチ処理の堅牢性と信頼性を大きく高めます。これは、特にミッションクリティカルなバッチ処理において極めて重要な特性です。

1-4. CompositeItemWriterの主要なユースケース

CompositeItemWriterは、単一のItemWriterでは対応しきれない、以下のような多様なデータ書き込み要件を持つバッチ処理でその真価を発揮します。

  • 同じデータを複数の異なる形式で出力したい場合:
    • : 顧客情報のバッチ処理において、処理後のデータをリレーショナルデータベースに更新するJpaItemWriterと、同時にその変更履歴を監査ログとしてCSVファイルに出力するFlatFileItemWriterを組み合わせる。
    • これにより、データの永続化と監査の両方を一回の処理で行うことができます。
  • 同じデータを複数の異なるシステムに連携したい場合:
    • : ECサイトの注文確定バッチで、注文データを基幹システム(データベース)に登録するItemWriterと、配送システムへの連携(APIコールやメッセージキューへの書き込み)を行うItemWriterを組み合わせる。
    • 各システムへの連携ロジックを分離しつつ、一貫した処理を実現できます。
  • 複数のテーブルにデータを書き込みたい場合:
    • : 商品データの一括更新バッチにおいて、商品基本情報をPRODUCTSテーブルに、関連する商品カテゴリ情報をPRODUCT_CATEGORIESテーブルに、といった形で、一つの入力データから複数の異なるテーブルへ書き込みたい場合。
    • 各テーブルへの書き込みロジックを独立したItemWriterとして定義し、CompositeItemWriterでまとめて実行することで、複雑なデータ構造の永続化も簡潔に記述できます。
  • 複雑な後処理をまとめて実行したい場合:
    • : データ処理後にキャッシュのクリア、外部システムへの通知、レポート生成のトリガーなど、複数の異なる後処理を順次実行したい場合。
    • それぞれの後処理を専用のItemWriterとして定義し、CompositeItemWriterに登録することで、一連の処理フローを明確にし、管理しやすくします。

これらのシナリオでは、CompositeItemWriterを利用することで、個々の書き込みロジックをシンプルに保ちつつ、全体として複雑な要件に対応できる柔軟で堅牢なバッチ処理を構築することが可能になります。


2. テスト戦略の概要

テストコードとの連携: CompositeItemWriterの動作検証

ここまででCompositeItemWriterの基本的な概念と役割、そしてその強力な特徴について理解を深めました。この後に続くテスト実装のセクションでは、実際にCompositeItemWriterが複数のデリゲートに処理を正しく委譲し、設定された順序で実行されること、そしてトランザクションの一貫性がどのように保証されるのかを、具体的な単体テストコードを通じて検証していきます。

特に、CompositeProductWriterUnitTest.javaでは、モック化したデリゲートItemWriterが期待通りに呼び出されていることを確認することで、CompositeItemWriterの委譲ロジックと順次実行の振る舞いを検証します。また、ExceptionThrowingProductWriterを用いたシナリオでは、トランザクションのロールバックが適切に行われることを示唆しており、CompositeItemWriterの堅牢なデータ整合性維持機能の重要性を浮き彫りにします。

本記事では、CompositeItemWriterの単体テストに特化し、以下の戦略で進めます。

  1. テスト対象の特定:
    • CompositeItemWriterをテスト対象とし、その内包するItemWriter(デリゲート)を明確にします。
  2. モック化:
    • Mockitoを用いて、CompositeItemWriterが内包する各ItemWriterをモック化します。これにより、実際の書き込み処理を行うことなく、CompositeItemWriterのデリゲート呼び出しロジックのみをテストします。
  3. テストデータの準備:
    • CompositeItemWriterwrite()メソッドに渡す入力データ(Productエンティティのリスト)を準備します。
    • 様々なシナリオ(複数アイテム、単一アイテム、空リスト、例外発生時など)をシミュレートします。
  4. 振る舞いの検証:
    • CompositeItemWriterが、内包する各モックItemWriterwrite()メソッドを正しい引数で、正しい回数呼び出しているかをMockito.verify()などを用いて検証します。

この戦略により、CompositeItemWriterが、デリゲートに正しく処理を委譲していることを保証します。


3. テスト対象のCompositeItemWriterと関連コンポーネント

3-1. 今回のシナリオとコンポーネント仕様

今回は、「複数の書き込み処理を順に実行し、途中でエラーが起きたら、それまでの処理をすべて無かったことにする(ロールバックする)」という、堅牢なデータ書き込み処理を実装します。

本記事では、以下に示すシナリオに基づいてCompositeItemWriterを実装し、そのテスト方法を解説します。


今回実装するシナリオ:

  1. Productテーブルから商品を読み込む。
  2. 読み込んだ商品をPRODUCT_ARCHIVESテーブルにアーカイブする(正常系処理)。
  3. 特定の条件(商品IDが5の倍数)に合致した場合、意図的に例外を発生させる(異常系処理)。

このシナリオを実現するためのコンポーネント仕様は以下の通りです。


関連コンポーネントの設計と役割

ProductArchive エンティティ

  • 役割: 商品の履歴を保存します。
  • クラス名: ProductArchive / テーブル名: PRODUCT_ARCHIVES
フィールド名説明
idLong主キー
productIdLong元の商品ID
nameString商品名
descriptionString商品説明
priceBigDecimal価格
archivedAtLocalDateTimeアーカイブ日時

ProductArchiveWriter (ItemWriter 1)

  • 役割: 商品データをProductArchiveテーブルに書き込みます。
  • インターフェース: ItemWriter<Product>

ExceptionThrowingProductWriter (ItemWriter 2)

  • 役割: 特定条件で例外を発生させ、ロールバックの挙動を確認します。
  • インターフェース: ItemWriter<Product>
  • 処理: 商品IDが5の倍数ならRuntimeExceptionをスローします。

この2つのItemWriterCompositeItemWriterで束ねることで、ゴールを達成します。

2つのItemWriterをCompositeItemWriterで束ねることで、ゴールを達成する

3-2. 実装: 関連コンポーネント

設計に基づき、各コンポーネントの実装を進めます。


ProductArchive.java (エンティティ)

この例では、ProductArchiveWriterProduct データを ProductArchive という別のエンティティに変換して保存するシナリオを想定します。

ProductArchive エンティティの定義は以下の通りです。

ProductArchive.java

package com.example.springbatchh2crud.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity // JPAエンティティであることを示す
@Table(name = "PRODUCT_ARCHIVES") // マッピングするテーブル名を指定
@Data // Lombokによるgetter/setter, equals, hashCode, toStringの自動生成
@NoArgsConstructor // Lombokによる引数なしコンストラクタの自動生成
@AllArgsConstructor // Lombokによる全フィールド引数コンストラクタの自動生成
public class ProductArchive {

    @Id // 主キーであることを示す
    @GeneratedValue(strategy = GenerationType.IDENTITY) // IDがデータベースによって自動生成されることを示す
    private Long id; // アーカイブID (主キー)
    private Long productId; // 元の商品ID
    private String name; // 商品名
    private String description; // 商品説明
    private BigDecimal price; // 価格
    private LocalDateTime archivedAt; // アーカイブ日時
}

ProductArchiveRepository.java (リポジトリ)

そして、この ProductArchive エンティティを永続化するために ProductArchiveRepository を使用します。

ProductArchiveRepository の定義は以下のようになります。

ProductArchiveRepository.java

package com.example.springbatchh2crud.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.example.springbatchh2crud.model.ProductArchive;

@Repository // リポジトリであることを示す
public interface ProductArchiveRepository extends JpaRepository<ProductArchive, Long> {
    // JpaRepositoryを継承することで、基本的なCRUD操作が提供される
}

ProductArchiveWriter.java

このリポジトリを利用して、実際に ProductProductArchive に変換して書き込む ItemWriterProductArchiveWriter です。

ProductArchiveWriter.java

package com.example.springbatchh2crud.writer;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import org.springframework.batch.item.Chunk; // Spring BatchのChunkインターフェースをインポート
import org.springframework.batch.item.ItemWriter; // ItemWriterインターフェースをインポート
import org.springframework.beans.factory.annotation.Autowired; // 自動インジェクション用のアノテーション

import com.example.springbatchh2crud.model.Product; // Productエンティティをインポート
import com.example.springbatchh2crud.model.ProductArchive; // ProductArchiveエンティティをインポート
import com.example.springbatchh2crud.repository.ProductArchiveRepository; // ProductArchiveRepositoryをインポート

public class ProductArchiveWriter implements ItemWriter<Product> { // Productを処理するItemWriterとして実装

    @Autowired // ProductArchiveRepositoryを自動的に注入
    private ProductArchiveRepository productArchiveRepository;

    @Override
    public void write(Chunk<? extends Product> chunk) throws Exception {
        // ChunkからProductのリストを取り出し、ProductArchiveに変換
        List<ProductArchive> archives = new ArrayList<>();
        for (Product product : chunk.getItems()) { // Chunk内の各Productに対して
            archives.add(toArchive(product)); // ProductをProductArchiveに変換してリストに追加
        }
        productArchiveRepository.saveAll(archives); // 変換したProductArchiveを一括で保存
    }

    // ProductをProductArchiveに変換するヘルパーメソッド
    private ProductArchive toArchive(Product product) {
        ProductArchive archive = new ProductArchive();
        archive.setProductId(product.getId()); // 元の商品IDを設定
        archive.setName(product.getName()); // 商品名を設定
        archive.setDescription(product.getDescription()); // 商品説明を設定
        archive.setPrice(product.getPrice()); // 価格を設定
        archive.setArchivedAt(LocalDateTime.now()); // 現在時刻をアーカイブ日時として設定
        return archive;
    }
}

【補足:コンストラクタインジェクションの推奨】

この例では簡潔さのために@Autowiredを用いたフィールドインジェクションを使用していますが、Springの最新のベストプラクティスとしては、コンストラクタインジェクションが推奨されます。コンストラクタインジェクションを使用することで、以下の利点が得られます。

  • テスト容易性: 依存関係が明確になり、単体テスト時にモックオブジェクトを簡単に注入できます。
  • 依存関係の明確化: クラスが必要とする依存関係が一目で分かり、可読性が向上します。
  • 不変性の保証: finalキーワードと組み合わせることで、依存関係の不変性を保証できます。

このProductArchiveWriterをコンストラクタインジェクションで記述すると、以下のようになります。

// コンストラクタインジェクションの例 (ProductArchiveWriter)
import org.springframework.stereotype.Component; // または @Service など、適切なアノテーションを付与

@Component // あるいは @Service など
public class ProductArchiveWriter implements ItemWriter<Product> {

    private final ProductArchiveRepository productArchiveRepository;

    public ProductArchiveWriter(ProductArchiveRepository productArchiveRepository) {
        this.productArchiveRepository = productArchiveRepository;
    }

    // ... writeメソッドやtoArchiveメソッドは省略 ...
}

詳細については、Spring Framework 公式ドキュメントの依存性の注入に関するセクション を参照してください。


ExceptionThrowingProductWriter.java

ItemWriterでの書き込み中に発生する例外は、バッチ処理の安定性に大きく影響します。Spring Batchは、特定の例外発生時にアイテムのスキップや再試行を行うメカニズムを提供しており、これらの挙動も単体テストで検証することが重要です。

例外処理のテストシナリオで使用するために、意図的に例外をスローする ItemWriter を用意します。以下の ExceptionThrowingProductWriter は、IDが5の倍数である Product が渡された場合に RuntimeException をスローします。

ExceptionThrowingProductWriter.java

package com.example.springbatchh2crud.writer;

import org.springframework.batch.item.Chunk; // Spring BatchのChunkインターフェースをインポート
import org.springframework.batch.item.ItemWriter; // ItemWriterインターフェースをインポート

import com.example.springbatchh2crud.model.Product; // Productエンティティをインポート

public class ExceptionThrowingProductWriter implements ItemWriter<Product> { // Productを処理するItemWriterとして実装

    @Override
    public void write(Chunk<? extends Product> chunk) throws Exception {
        // Chunk内の各Productをチェック
        for (Product product : chunk.getItems()) {
            // 商品IDが5の倍数の場合、意図的にRuntimeExceptionをスロー
            if (product.getId() % 5 == 0) {
                throw new RuntimeException("Intentional exception for product id: " + product.getId());
            }
        }
        // 例外がスローされなければ、正常終了
    }
}

3-3. 実装: CompositeProductWriterJobConfig (ジョブ設定)

CompositeProductWriterJobConfig.java (ジョブ設定)

CompositeItemWriterの定義例

CompositeItemWriter を使用したバッチジョブ全体の設定例として、CompositeProductWriterJobConfig を見てみましょう。

この設定では、Product を読み込み、ProductArchiveWriter と後述する ExceptionThrowingProductWriter の2つの ItemWriter に処理を委譲します。

CompositeProductWriterJobConfig.java

package com.example.springbatchh2crud.config;

import com.example.springbatchh2crud.model.Product;
import com.example.springbatchh2crud.writer.ExceptionThrowingProductWriter;
import com.example.springbatchh2crud.writer.ProductArchiveWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.database.JdbcPagingItemReader;
import org.springframework.batch.item.database.Order;
import org.springframework.batch.item.database.support.MySqlPagingQueryProvider;
import org.springframework.batch.item.support.CompositeItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration // Springの設定クラスであることを示す
public class CompositeProductWriterJobConfig {

    @Bean // JobをSpring Beanとして定義
    public Job compositeProductWriterJob(
        JobRepository jobRepository, // ジョブレポジトリ(Spring Batchの実行状態を管理)
        Step compositeProductWriterStep // 実行するステップ
    ) {
        return new JobBuilder("compositeProductWriterJob", jobRepository) // ジョブ名を指定してジョブビルダーを生成
            .start(compositeProductWriterStep) // 最初に実行するステップを設定
            .build(); // ジョブを構築
    }

    @Bean // StepをSpring Beanとして定義
    public Step compositeProductWriterStep(
        JobRepository jobRepository, // ジョブレポジトリ
        PlatformTransactionManager transactionManager, // トランザクションマネージャー
        JdbcPagingItemReader<Product> compositeProductReader, // データを読み込むItemReader
        CompositeItemWriter<Product> compositeProductWriter // データを書き込むItemWriter
    ) {
        return new StepBuilder("compositeProductWriterStep", jobRepository) // ステップ名を指定してステップビルダーを生成
            .<Product, Product>chunk(10, transactionManager) // チャンクサイズを10に設定し、トランザクションマネージャーを指定
            .reader(compositeProductReader) // ItemReaderを設定
            .writer(compositeProductWriter) // ItemWriterを設定
            .build(); // ステップを構築
    }

    @Bean // JdbcPagingItemReaderをSpring Beanとして定義
    public JdbcPagingItemReader<Product> compositeProductReader(
        DataSource dataSource // データソースを注入
    ) {
        JdbcPagingItemReader<Product> reader = new JdbcPagingItemReader<>();
        reader.setDataSource(dataSource); // 使用するデータソースを設定
        reader.setPageSize(10); // ページサイズ(チャンクサイズに合わせる)を設定
        reader.setRowMapper(new BeanPropertyRowMapper<>(Product.class)); // データベースの行をProductオブジェクトにマッピングするRowMapperを設定

        // ページングクエリプロバイダーを設定 (MySQLの場合)
        MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider();
        queryProvider.setSelectClause("id, name, description, price"); // SELECT句
        queryProvider.setFromClause("from products"); // FROM句

        // ソートキーを設定(ページングに必須)
        Map<String, Order> sortKeys = new HashMap<>();
        sortKeys.put("id", Order.ASCENDING); // IDで昇順ソート
        queryProvider.setSortKeys(sortKeys);

        reader.setQueryProvider(queryProvider); // クエリプロバイダーを設定
        return reader;
    }

    @Bean // ProductArchiveWriterをSpring Beanとして定義
    public ProductArchiveWriter productArchiveWriter() {
        return new ProductArchiveWriter(); // ※DIコンテナ管理外のインスタンス生成
    }

    @Bean // ExceptionThrowingProductWriterをSpring Beanとして定義
    public ExceptionThrowingProductWriter exceptionThrowingProductWriter() {
        return new ExceptionThrowingProductWriter(); // ※DIコンテナ管理外のインスタンス生成
    }

    @Bean // CompositeItemWriterをSpring Beanとして定義
    public CompositeItemWriter<Product> compositeProductWriter(
        ProductArchiveWriter productArchiveWriter, // ProductArchiveWriterを注入
        ExceptionThrowingProductWriter exceptionThrowingProductWriter // ExceptionThrowingProductWriterを注入
    ) {
        CompositeItemWriter<Product> writer = new CompositeItemWriter<>();
        // 委譲するItemWriterのリストを設定
        writer.setDelegates(
            Arrays.asList(productArchiveWriter, exceptionThrowingProductWriter)
        );
        return writer;
    }
}

【補足:DIコンテナとコンストラクタインジェクション】

CompositeProductWriterJobConfigにおけるproductArchiveWriter()およびexceptionThrowingProductWriter()メソッドでは、newキーワードを用いてItemWriterのインスタンスを直接生成しています。これにより、これらのItemWriterはSpringのDIコンテナによって管理されず、内部の@Autowired(もしあれば)は機能しません。

Spring Batchのジョブ設定においては、通常、各コンポーネントをSpring Beanとして適切に定義し、DIコンテナに管理させることが推奨されます。これにより、依存性の注入(コンストラクタインジェクションを含む)が適切に機能し、コンポーネントのテスト容易性や再利用性が向上します。

ItemWriterをSpring Beanとして管理し、コンストラクタインジェクションを利用する例を以下に示します。

// ProductArchiveWriterをSpring Beanとして定義する例
@Configuration
public class ItemWriterConfig {

    @Bean
    public ProductArchiveWriter productArchiveWriter(ProductArchiveRepository productArchiveRepository) {
        return new ProductArchiveWriter(productArchiveRepository);
    }

    // ExceptionThrowingProductWriterは依存がないため、シンプルにBeanとして定義
    @Bean
    public ExceptionThrowingProductWriter exceptionThrowingProductWriter() {
        return new ExceptionThrowingProductWriter();
    }
}

このようにすることで、CompositeItemWriterproductArchiveWriterexceptionThrowingProductWriterをインジェクションする際に、それらがDIコンテナによって適切に管理されたBeanとして取得されるようになります。


4. テスト環境の準備

CompositeItemWriterの単体テストを行うために、以下の準備を行います。


4-1. 補足:モックオブジェクトの準備と振る舞いの定義

CompositeItemWriterの単体テストでは、内包する各ItemWriterの実際の書き込み処理を伴わないように、これらのItemWriterをモック化します。これにより、テストの実行速度を向上させ、外部環境に左右されない安定したテストを実現します。

モック化前: 本番環境での依存関係

本番環境では、CompositeItemWriterは内包する各ItemWriterを介して実際の外部システムとのやり取りを行います。

CompositeItemWriterは内包する各ItemWriterを介して実際の外部システムとのやり取りを行う

モック化後: テスト環境での依存関係

単体テストでは、CompositeItemWriterが内包する各ItemWriterをMockitoでモック化し、CompositeItemWriterがこれらのモックオブジェクトとやり取りするように設定します。これにより、実際の外部システムへのアクセスなしにCompositeItemWriterの委譲ロジックを検証できます。

CompositeItemWriterが内包する各ItemWriterをMockitoでモック化し、CompositeItemWriterがこれらのモックオブジェクトとやり取りするように設定する

4-2. @Mockと@InjectMocksの役割 (CompositeItemWriterのテストにおける考慮事項)

  • @Mock:
    • このアノテーションをフィールドに付与すると、Mockitoがその型のモックオブジェクトを自動的に生成し、テスト実行前に注入してくれます。
    • CompositeItemWriterが内包する各ItemWriter(デリゲート)がある場合に利用します。
    • モックオブジェクトは、実際のオブジェクトの振る舞いをシミュレートするために使用され、特定のメソッド呼び出しに対して定義した値を返したり、例外をスローしたりすることができます。
  • @InjectMocks:
    • このアノテーションをテスト対象のクラスのフィールドに付与すると、Mockitoがそのインスタンスを自動的に生成し、@Mockで作成されたモックオブジェクトをそのインスタンスのコンストラクタやフィールドに自動的に注入してくれます。
    • CompositeItemWriter自体をテストする場合に利用しますが、CompositeItemWriter@AutowiredではなくsetDelegatesItemWriterを受け取るため、テスト対象のCompositeItemWriterを直接@InjectMocksで作成するよりも、手動でインスタンス化してsetDelegatesでモックをセットする方が一般的です。
    • しかし、個々のデリゲートItemWriterのテストでは、そのItemWriter自体が持つ依存関係(例: ProductArchiveRepositoryなど)を@Mockでモック化し、@InjectMocksでテスト対象のデリゲートItemWriter(例: ProductArchiveWriter)に注入します。

4-3. 依存関係の追加

ItemWriterの単体テストを行うために、以下の依存関係をpom.xml(Mavenの場合)またはbuild.gradle(Gradleの場合)に追加します。

Mavenの場合 (pom.xml):

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Gradleの場合 (build.gradle):

testImplementation 'org.mockito:mockito-junit-jupiter'
testImplementation 'org.junit.jupiter:junit-jupiter-api'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
testImplementation 'org.assertj:assertj-core'

5. CompositeItemWriterの単体テスト実装

「習うより慣れろ」とばかりに、いきなり全体を動かしたくなる気持ちを抑え、まずは各部品(ItemWriter)が正しく動作するかを個別に検証する単体テストを作成します。

これにより、後のデバッグ作業が格段に楽になります。


ProductArchiveWriterUnitTest.java

この ProductArchiveWriter の単体テストは以下のようになります。

ProductArchiveRepository をモック化し、saveAll メソッドが正しく呼び出されることを検証します。

ProductArchiveWriterUnitTest.java

package com.example.springbatchh2crud.writer;

import static org.mockito.Mockito.*; // Mockitoのstaticメソッドをインポート

import com.example.springbatchh2crud.model.Product; // Productモデルをインポート
import com.example.springbatchh2crud.repository.ProductArchiveRepository; // ProductArchiveRepositoryをインポート
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach; // テストメソッド実行前に毎回実行されるメソッドのアノテーション
import org.junit.jupiter.api.Test; // テストメソッドのアノテーション
import org.mockito.InjectMocks; // モックを注入する対象のインスタンスを生成するアノテーション
import org.mockito.Mock; // モックオブジェクトを生成するアノテーション
import org.mockito.MockitoAnnotations; // Mockitoのアノテーションを初期化

import org.springframework.batch.item.Chunk; // Spring BatchのChunkインターフェース

public class ProductArchiveWriterUnitTest {

    @Mock // ProductArchiveRepositoryのモックを作成
    private ProductArchiveRepository productArchiveRepository;

    @InjectMocks // productArchiveWriterインスタンスを生成し、@Mockで作成したモックを注入
    private ProductArchiveWriter productArchiveWriter;

    @BeforeEach // 各テストメソッドの前に実行
    public void setUp() {
        MockitoAnnotations.openMocks(this); // 現在のテストクラスのモックを初期化
    }

    @Test // テストメソッド
    public void testWrite() throws Exception {
        // Given: テストデータの準備
        // Productオブジェクトをbuilderパターンで構築
        Product product1 = Product.builder()
            .id(1L) // ID
            .name("Test Product 1") // 名前
            .description("Description 1") // 説明
            .price(new BigDecimal("100")) // 価格
            .status("ACTIVE") // ステータス
            .invalid(false) // 無効フラグ
            .build();
        Product product2 = Product.builder()
            .id(2L)
            .name("Test Product 2")
            .description("Description 2")
            .price(new BigDecimal("200"))
            .status("ACTIVE")
            .invalid(false)
            .build();
        List<Product> products = new ArrayList<>();
        products.add(product1);
        products.add(product2);
        Chunk<Product> chunk = new Chunk<>(products); // Productのチャンクを作成

        // When: テスト対象メソッドの実行
        productArchiveWriter.write(chunk); // ProductArchiveWriterのwriteメソッドを実行

        // Then: 結果の検証
        // productArchiveRepositoryのsaveAllメソッドが、任意の引数で1回呼び出されたことを検証
        verify(productArchiveRepository, times(1)).saveAll(any());
    }
}

解説:

  • Productオブジェクトを2つ含むリストを準備し、ProductArchiveWriter.write()メソッドにChunkとして渡します。
  • verify(productArchiveRepository, times(1)).saveAll(any())を使用し、productArchiveRepositoryモックのsaveAll()メソッドが、任意の引数で正確に1回呼び出されたことを検証します。
  • これにより、ProductArchiveWriterが受け取ったProductのチャンクをProductArchiveに変換し、リポジトリを介して永続化処理を正しく委譲していることを確認できます。実際のデータベースへの接続は行われないため、テストは高速かつ独立して実行されます。

ExceptionThrowingProductWriterの単体テスト

この ExceptionThrowingProductWriter の単体テストは以下のようになります。

特定の条件下で例外がスローされること、そしてそれ以外の条件下では例外がスローされないことを確認します。

ExceptionThrowingProductWriterUnitTest.java

package com.example.springbatchh2crud.writer;

import static org.junit.jupiter.api.Assertions.assertThrows; // 特定の例外がスローされることを検証
import static org.junit.jupiter.api.Assertions.fail; // 予期せぬ例外でテストを失敗させる

import com.example.springbatchh2crud.model.Product; // Productモデルをインポート
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach; // テストメソッド実行前に毎回実行されるメソッドのアノテーション
import org.junit.jupiter.api.Test; // テストメソッドのアノテーション
import org.springframework.batch.item.Chunk; // Spring BatchのChunkインターフェース

public class ExceptionThrowingProductWriterUnitTest {

    private ExceptionThrowingProductWriter exceptionThrowingProductWriter;

    @BeforeEach // 各テストメソッドの前に実行
    public void setUp() {
        exceptionThrowingProductWriter = new ExceptionThrowingProductWriter(); // テスト対象のWriterを初期化
    }

    @Test // テストメソッド
    public void testWrite_throwsExceptionForMultipleOfFive() {
        // Given: テストデータの準備 - IDが5の倍数のProduct
        Product product = new Product(
            5L, // id: 5 (例外発生条件)
            "Test Product 5", // name
            "Description 5", // description
            new BigDecimal("500"), // price
            "ACTIVE", // status
            false // invalid
        );
        List<Product> products = new ArrayList<>();
        products.add(product);
        Chunk<Product> chunk = new Chunk<>(products); // Productのチャンクを作成

        // When & Then: writeメソッド実行時にRuntimeExceptionがスローされることを検証
        assertThrows(RuntimeException.class, () -> {
            exceptionThrowingProductWriter.write(chunk);
        });
    }

    @Test // テストメソッド
    public void testWrite_doesNotThrowException() {
        // Given: テストデータの準備 - IDが5の倍数ではないProduct
        Product product1 = new Product(
            1L, // id: 1
            "Test Product 1", // name
            "Description 1", // description
            new BigDecimal("100"), // price
            "ACTIVE", // status
            false // invalid
        );
        Product product2 = new Product(
            2L, // id: 2
            "Test Product 2", // name
            "Description 2", // description
            new BigDecimal("200"), // price
            "ACTIVE", // status
            false // invalid
        );
        List<Product> products = new ArrayList<>();
        products.add(product1);
        products.add(product2);
        Chunk<Product> chunk = new Chunk<>(products); // Productのチャンクを作成

        // When & Then: writeメソッド実行時に例外がスローされないことを検証
        try {
            exceptionThrowingProductWriter.write(chunk);
        } catch (Exception e) {
            fail("Should not have thrown any exception"); // 例外がスローされた場合はテスト失敗
        }
    }
}

解説:

ExceptionThrowingProductWriterの単体テストでは、以下の2つの主要なシナリオを検証します。

  • testWrite_throwsExceptionForMultipleOfFive():
    • 意図: 商品IDが5の倍数である場合に、ExceptionThrowingProductWriterが正しくRuntimeExceptionをスローすることを確認します。これは、CompositeItemWriterが組み込みのItemWriterからの例外を適切に処理できるか(例えば、ロールバックトリガーとして)を検証する上で重要です。
    • 使用方法: assertThrows(RuntimeException.class, () -> { ... }) を使用して、指定された処理が期待する例外をスローすることを確認します。
  • testWrite_doesNotThrowException():
    • 意図: 商品IDが5の倍数でない場合に、ExceptionThrowingProductWriterが例外をスローしないことを確認します。これにより、正常系のデータが問題なく処理されることを保証します。
    • 使用方法: try-catchブロックと fail("Should not have thrown any exception") を組み合わせて、予期せぬ例外がスローされないことを検証します。

CompositeProductWriterUnitTest.java

CompositeItemWriterのテストでは、内部で委譲されている各ItemWriterが期待通りに呼び出されているかを検証します。

各デリゲートItemWriterはモック化し、CompositeItemWriterがそれらのwriteメソッドを呼び出していることを確認します。

CompositeProductWriterUnitTest.java

package com.example.springbatchh2crud.writer;

import static org.mockito.Mockito.*; // Mockitoのstaticメソッドをインポート

import com.example.springbatchh2crud.model.Product; // Productモデルをインポート
import java.math.BigDecimal;
import java.util.Arrays; // 配列をリストに変換するために使用
import org.junit.jupiter.api.BeforeEach; // テストメソッド実行前に毎回実行されるメソッドのアノテーション
import org.junit.jupiter.api.Test; // テストメソッドのアノテーション
import org.mockito.Mock; // モックオブジェクトを生成するアノテーション
import org.mockito.MockitoAnnotations; // Mockitoのアノテーションを初期化
import org.springframework.batch.item.Chunk; // Spring BatchのChunkインターフェース
import org.springframework.batch.item.support.CompositeItemWriter; // CompositeItemWriterクラス

public class CompositeProductWriterUnitTest {

    @Mock // ProductArchiveWriterのモックを作成
    private ProductArchiveWriter mockProductArchiveWriter;

    @Mock // ExceptionThrowingProductWriterのモックを作成
    private ExceptionThrowingProductWriter mockExceptionThrowingProductWriter;

    private CompositeItemWriter<Product> compositeWriter; // テスト対象のCompositeItemWriter

    @BeforeEach // 各テストメソッドの前に実行
    public void setUp() {
        MockitoAnnotations.openMocks(this); // 現在のテストクラスのモックを初期化
        compositeWriter = new CompositeItemWriter<>(); // CompositeItemWriterのインスタンスを生成
        // モック化したItemWriterをデリゲートとして設定
        compositeWriter.setDelegates(
            Arrays.asList(
                mockProductArchiveWriter, // 1つ目のデリゲート
                mockExceptionThrowingProductWriter // 2つ目のデリゲート
            )
        );
    }

    @Test // テストメソッド
    public void testCompositeWriter_delegatesAreCalled() throws Exception {
        // Given: テストデータの準備
        // Productオブジェクトを生成し、Chunkに格納
        Chunk<Product> chunk = new Chunk<>(
            Arrays.asList(
                new Product(
                    1L, // id
                    "Test", // name
                    "Test Desc", // description
                    new BigDecimal("100"), // price
                    "ACTIVE", // status
                    false // invalid
                )
            )
        );
        // 【Chunkとは?】
        // Spring Batchでは、ItemReaderが一度に複数のアイテム(レコード)を読み込み、
        // そのグループを「Chunk」(チャンク)と呼びます。
        // ItemProcessorがこのChunk内のアイテムを処理し、最終的にItemWriterがChunk全体を書き込みます。
        // Chunkは単なるListとは異なり、Spring Batchのトランザクション管理やエラー処理の単位となります。

        // When: テスト対象メソッドの実行
        compositeWriter.write(chunk); // CompositeItemWriterのwriteメソッドを実行

        // Then: 結果の検証
        // mockProductArchiveWriterのwriteメソッドが、指定されたchunkで1回呼び出されたことを検証
        verify(mockProductArchiveWriter, times(1)).write(chunk);
        // mockExceptionThrowingProductWriterのwriteメソッドが、指定されたchunkで1回呼び出されたことを検証
        verify(mockExceptionThrowingProductWriter, times(1)).write(chunk);
    }
}

解説:

  • CompositeItemWriterが内包するProductArchiveWriterExceptionThrowingProductWriter@Mockとして準備します。
  • setUp()メソッド内でCompositeItemWriterをインスタンス化し、setDelegates()メソッドを使ってモック化したItemWriterたちを登録します。これにより、CompositeItemWriterがこれらのモックに対してwriteメソッドを呼び出すように設定されます。
  • testCompositeWriter_delegatesAreCalled()テストでは、Chunk<Product>compositeWriter.write()に渡します。
  • verify(mockProductArchiveWriter, times(1)).write(chunk)およびverify(mockExceptionThrowingProductWriter, times(1)).write(chunk)を使用し、CompositeItemWriterが受け取ったChunkをそれぞれのデリゲートItemWriterwrite()メソッドに正確に1回ずつ渡していることを検証します。
  • このテストにより、CompositeItemWriterが、その責務である「複数のItemWriterへの処理の委譲」を正しく行っていることを確認できます。

6. 実装したジョブの実行方法

今回実装したcompositeProductWriterJobは、アプリケーションを起動しただけでは自動実行されません。動作を検証するには、主に以下の方法があります。


6-1. すべてのテストを実行する

プロジェクト全体の品質を保証するため、以下のコマンドで全てのテストを実行できます。これまでのデバッグ作業は、すべてこのコマンドによってエラーを検知してきました。

./mvnw test

6-2. 個別の単体テストを実行する

特定のItemWriterのロジックだけを個別にテストしたい場合は、以下のようにテストクラスを指定して実行します。

# ProductArchiveWriterのテスト
./mvnw test -Dtest=ProductArchiveWriterUnitTest

# ExceptionThrowingProductWriterのテスト
./mvnw test -Dtest=ExceptionThrowingProductWriterUnitTest

# CompositeProductWriterのテスト
./mvnw test -Dtest=CompositeProductWriterUnitTest

まとめ:CompositeItemWriterでSpring Batchをより堅牢に

本記事では、Spring BatchにおけるCompositeItemWriterの単体テストに焦点を当て、複数のItemWriterへの処理委譲とその検証方法を詳細に解説しました。

Mockitoを用いた依存関係(デリゲートItemWriter)のモック化により、外部システムに依存せず、CompositeItemWriterの委譲ロジックが期待通りに機能することを確認するテスト戦略を確立できます。
具体的なテストケースを通じて、CompositeItemWriterが内包する各ItemWriterを正しく呼び出すこと、および個々のItemWriterが独立してその責務を果たすことの重要性を実感いただけたかと思います。

この知識を活用し、信頼性の高いSpring Batchアプリケーション開発にお役立てください。


【補足1】テストデータの準備

compositeProductWriterJob のロールバックシナリオを確実にテストできるよう、src/main/resources/sql/data.sql にサンプルのテストデータを記載します。

このデータには、例外を発生させるアイテム(IDが5の倍数)が含まれていないチャンクと、含まれているチャンクの両方が含まれるように調整されています。

data.sql

-- src/main/resources/sql/data.sql

-- テスト用のユーザーデータを挿入
INSERT INTO users (name, email, status) VALUES ('Alice', 'alice@example.com', 'ACTIVE');
INSERT INTO users (name, email, status) VALUES ('Bob', 'bob@example.com', 'INACTIVE');

-- 例外を発生させない安全なチャンク用のProductデータを挿入 (ID: 1-4, 6-9)
INSERT INTO products (id, name, description, price, invalid, status) VALUES (1, 'Safe Product 1', 'Description 1', 10.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (2, 'Safe Product 2', 'Description 2', 20.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (3, 'Safe Product 3', 'Description 3', 30.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (4, 'Safe Product 4', 'Description 4', 40.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (6, 'Safe Product 6', 'Description 6', 60.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (7, 'Safe Product 7', 'Description 7', 70.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (8, 'Safe Product 8', 'Description 8', 80.00, false, 'OK');
INSERT INTO products (id, name, description, price, invalid, status) VALUES (9, 'Safe Product 9', 'Description 9', 90.00, false, 'OK');

-- 例外を発生させる可能性があるProductデータを挿入 (ID: 5の倍数でinvalid=true)
-- シフトされた元のデータブロック (ID: 101-125)
INSERT INTO products (id, name, description, price, invalid, status) VALUES (101, 'Product 1', 'Description for Product 1', 10.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (102, 'Product 2', 'Description for Product 2', 20.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (103, 'Product 3', 'Description for Product 3', 30.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (104, 'Product 4', 'Description for Product 4', 40.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (105, 'Product 5', 'Description for Product 5', 50.00, true, null); -- IDが5の倍数で例外発生
INSERT INTO products (id, name, description, price, invalid, status) VALUES (106, 'Product 6', 'Description for Product 6', 60.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (107, 'Product 7', 'Description for Product 7', 70.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (108, 'Product 8', 'Description for Product 8', 80.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (109, 'Product 9', 'Description for Product 9', 90.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (110, 'Product 10', 'Description for Product 10', 100.00, true, null); -- IDが5の倍数で例外発生
INSERT INTO products (id, name, description, price, invalid, status) VALUES (111, 'Product 11', 'Description for Product 11', 110.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (112, 'Product 12', 'Description for Product 12', 120.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (113, 'Product 13', 'Description for Product 13', 130.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (114, 'Product 14', 'Description for Product 14', 140.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (115, 'Product 15', 'Description for Product 15', 150.00, true, null); -- IDが5の倍数で例外発生
INSERT INTO products (id, name, description, price, invalid, status) VALUES (116, 'Product 16', 'Description for Product 16', 160.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (117, 'Product 17', 'Description for Product 17', 170.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (118, 'Product 18', 'Description for Product 18', 180.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (119, 'Product 19', 'Description for Product 19', 190.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (120, 'Product 20', 'Description for Product 20', 200.00, true, null); -- IDが5の倍数で例外発生
INSERT INTO products (id, name, description, price, invalid, status) VALUES (121, 'Product 21 (High Price)', 'Description for Product 21', 12000.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (122, 'Product 22 (Premium)', 'Description for Product 22', 5500.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (123, 'Product 23 (Filtered)', 'Description for Product 23', 99.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (124, 'Product 24', 'Description for Product 24', 240.00, false, null);
INSERT INTO products (id, name, description, price, invalid, status) VALUES (125, 'Product 25', 'Description for Product 25', 250.00, true, null); -- IDが5の倍数で例外発生

【補足2】Job全体の動作を確認する(結合テスト)

compositeProductWriterJob全体の動作、特に「例外発生時のロールバック」という本丸の機能を確認するには、JobLauncherTestUtilsを使用した結合テストを作成するのが最も効果的です。

このドキュメントでは結合テストの実装までは行いませんでしたが、もし作成する場合は以下のようになります。

このテストを実行することで、IDが5の倍数のデータを含むチャンクでジョブがFAILEDとなり、PRODUCT_ARCHIVESテーブルにデータが一切書き込まれない(=ロールバック成功)ことを自動的に検証できます。

CompositeProductWriterJobTests.java

// src/test/java/com/example/springbatchh2crud/job/CompositeProductWriterJobTests.java
@SpringBatchTest
@SpringBootTest
class CompositeProductWriterJobTests {

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @Autowired
    private JobRepositoryTestUtils jobRepositoryTestUtils;

    @Autowired
    private ProductArchiveRepository productArchiveRepository;

    @Autowired
    private Job compositeProductWriterJob;

    @BeforeEach
    void setUp() {
        jobRepositoryTestUtils.removeJobExecutions();
        productArchiveRepository.deleteAll();
        // JobLauncherTestUtilsにテスト対象のJobをインジェクト
        this.jobLauncherTestUtils.setJob(compositeProductWriterJob);
    }

    @Test
    void testJob_whenExceptionOccurs_shouldFailAndRollback() throws Exception {
        // Given: data.sqlにより、例外を引き起こすデータ(ID=5, 10など)が投入済み

        // When
        JobExecution jobExecution = jobLauncherTestUtils.launchJob();

        // Then
        // ID 5 のデータで例外がスローされるため、Jobは失敗する
        assertEquals(BatchStatus.FAILED, jobExecution.getStatus());

        // トランザクションがロールバックされるため、ARCHIVESテーブルにデータは残らない
        assertEquals(0, productArchiveRepository.count());
    }
}

免責事項

この記事は、情報提供のみを目的としており、特定の目的への適合性、正確性、完全性、信頼性を保証するものではありません。
この記事の内容に基づいて行動を起こす場合は、読者自身の責任において行ってください。
記事内のコードスニペット、設定例、およびその他の技術情報は、説明のためのものであり、本番環境での使用には追加のテスト、セキュリティ対策、および最適化が必要になる場合があります。
著者は、この記事の内容から生じるいかなる損害や不利益に対しても一切の責任を負いません。


SNSでもご購読できます。

コメントを残す

*