【Spring Batch ハンズオン】JPA ItemReaderの単体テスト徹底解説!Mockitoでデータ読み込みロジックを高速検証する

はじめに: Spring Batchテストの重要性とこの記事で学ぶこと

Spring Batchのテスト、どこから手をつければいいか迷っていませんか?
特に、データベースに繋がずにItemReaderの動きを確認したいけど、どうすればいいんだろう? と悩んでいる初心者のエンジニアの方もいるかもしれません。

Spring Batchアプリケーション開発において、バッチ処理の各コンポーネントが期待通りに動作するかを検証することは非常に重要です。

本記事では、Spring Batchの主要コンポーネントの一つであるItemReaderに焦点を当て、そのデータ読み込みロジックを単体テストで効果的に検証する方法を、実際のプロジェクトで動作するコードを用いてハンズオン形式で解説します。

  • この記事の目的:
    • Spring BatchのItemReaderの単体テストに焦点を当て、データ読み込みロジックの検証方法を学びます。
  • 対象読者:
    • Spring Batchの基本的な概念を理解しており、テストコードの書き方を学びたいジュニアエンジニア。
    • JPAとSpring Batchを組み合わせたバッチ処理のテスト方法に興味がある開発者。
    • Mockitoを使った単体テストの実装に自信がない、または実践的な知見を深めたい方。
  • この記事で学ぶこと:
    • ItemReaderの役割、単体テストの重要性、Mockitoを使ったモック化、具体的なテストコードの実装と検証。
    • 【実践】 テスト実装で発生しがちな「ハマりどころ」とその解決策。
  • 前提知識:

目次


1. ItemReaderとは?Spring Batchにおける役割と重要性

1-1. ItemReaderの役割と重要性

ItemReaderは、Spring Batchにおける「読み込み」フェーズを担当するコンポーネントです。

データソース(データベース、ファイル、メッセージキューなど)からデータを1件ずつ(またはチャンク単位で)読み込み、後続のItemProcessorItemWriterに渡す役割を担います。

  • ItemReaderの基本:
    • データソースからデータを1件ずつ読み込むコンポーネント。
  • 主な実装例:
    • JpaPagingItemReader(JPAエンティティをページングで読み込む)、FlatFileItemReader(フラットファイルから読み込む)など、様々なデータソースに対応した実装が提供されています。
  • なぜ重要か:
    • ItemReaderはバッチ処理の入力部分であり、ここでの問題(例: 誤ったデータ読み込み、パフォーマンス問題)は、後続のItemProcessorItemWriter、ひいてはバッチ処理全体に大きな影響を及ぼします。

1-2. 主要なItemReaderの種類と用途

Spring Batchは、様々なデータソースに対応するための豊富なItemReader実装を提供しています。以下に、代表的なItemReaderとその用途をまとめます。

ItemReaderクラス名用途
FlatFileItemReaderCSVやテキストファイルなどのフラットファイルからデータを読み込みます。
JdbcCursorItemReaderJDBCカーソルを使用してリレーショナルデータベースからデータを読み込みます。大量データの読み込みに適していますが、カーソルを維持するためトランザクションの考慮が必要です。
JdbcPagingItemReaderJDBCページングを使用してリレーショナルデータベースからデータを読み込みます。メモリ効率が良く、再起動可能性に優れています。
JpaPagingItemReaderJPA(Java Persistence API)を使用してリレーショナルデータベースからデータをページングで読み込みます。JPAエンティティを直接扱えるため、オブジェクト指向的なデータアクセスが可能です。
StaxEventItemReaderXMLファイルからデータを読み込みます。StAX (Streaming API for XML) を利用し、メモリ効率良くXMLを処理します。
MongoItemReaderMongoDBからドキュメントを読み込みます。NoSQLデータベースからのデータ読み込みに利用されます。
KafkaItemReaderApache Kafkaトピックからメッセージを読み込みます。ストリーミングデータ処理やイベント駆動型バッチ処理に利用されます。
AmqpItemReaderAMQP (Advanced Message Queuing Protocol) テンプレートを使用してメッセージキューからデータを読み込みます。
JmsItemReaderJMS (Java Message Service) を使用してメッセージキューからデータを読み込みます。
CompositeItemReader複数のItemReaderを組み合わせて、複数のソースからデータを読み込んだり、特定の順序でデータを処理したりする場合に便利です。
ListItemReaderメモリ上のListオブジェクトからデータを読み込みます。主にテストや小規模なデータセットの処理に利用されます。
IteratorItemReaderIteratorインターフェースを実装したオブジェクトからデータを読み込みます。ListItemReaderと同様に、メモリ上のデータ処理に適しています。

これらのItemReaderを適切に選択することで、様々なデータソースからの効率的かつ堅牢なデータ読み込みを実現できます。


2. なぜItemReaderの単体テストが必要なのか?

ItemReaderの単体テストは、バッチ処理全体の信頼性を高める上で不可欠です。

  • ロジックの独立検証:
    • 外部システム(データベース、ファイルシステム、外部APIなど)に依存せず、ItemReader自身の読み込みロジック(例: クエリ生成、パラメータバインド、データ変換、特定の条件でのフィルタリングなど)が正しいかを検証できます。
  • 高速なフィードバック:
    • 実際のデータベース接続やファイルI/Oを伴わないため、テストが非常に高速に実行できます。これにより、開発サイクルを短縮し、開発者は迅速にフィードバックを得ることができます。
  • 問題の早期発見:
    • 開発の早い段階でItemReader内のバグを発見し、修正コストを大幅に削減できます。後工程で発見されるバグほど、修正にかかる時間とコストは増大します。
  • テスト容易性:
    • 依存関係をモック化することで、複雑な環境構築なしにテストが可能です。これにより、テストコードの記述が容易になり、テストカバレッジの向上にも繋がります。

3. テスト戦略の概要

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

  1. テスト対象の特定:
    • JpaPagingItemReaderをテスト対象とし、その依存関係(EntityManagerFactoryEntityManagerTypedQuery)を明確にします。
  2. モック化:
    • Mockitoを用いて、データベースアクセスに関連する依存関係をモック化します。これにより、実際のデータベースに接続することなく、ItemReaderのロジックのみをテストします。
  3. テストデータの準備:
    • モックオブジェクトが返すデータを定義し、様々なシナリオ(データが存在する場合、存在しない場合など)をシミュレートします。
  4. 振る舞いの検証:
    • ItemReaderread()メソッドの戻り値が期待通りであるかを検証します。

この戦略により、ItemReaderのデータ読み込みロジックが、外部環境に左右されずに正しく機能することを保証します。


4. ハンズオンの準備

単体テストを実装する前に、テスト対象となるバッチジョブと、その関連コンポーネントを準備します。

4.1. 読み込み対象エンティティ (Product.java)

ItemReaderが読み込む対象となるJPAエンティティです。テストでの使いやすさを考慮し、Lombokの@Builderアノテーションなどを活用しています。

package com.example.springbatchh2crud.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "products")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Product {

    @Id
    private Long id;

    private String name;
    private BigDecimal price;
    private boolean invalid;
}

単体テストとしてどのようなコンストラクタが必要か

単体テストでは、特定のシナリオを検証するために、エンティティのインスタンスを柔軟に生成できることが重要です。

  • デフォルトコンストラクタ (@NoArgsConstructor):
    • JPAがデータベースからエンティティを復元する際に内部的に使用します。
  • 全フィールドコンストラクタ (@AllArgsConstructor):
    • 全てのフィールドを初期化する際に便利です。
  • ビルダー (@Builder):
    • 多くのフィールドを持つオブジェクトを、可読性高く生成するためのパターンです。
    • テストデータの作成において、Product.builder().id(1L).name("...").build() のように、どのフィールドにどの値を設定しているかが一目瞭然となり、非常に役立ちます。

4.2. バッチジョブ全体の作成 (ProductJobConfig.java)

ItemReaderがジョブ全体の中でどのように使われるかを理解するため、Job, Step, ItemReader, ItemProcessor, ItemWriterをすべて含んだ設定クラスを作成します。

ItemReaderは、このProductJobConfigクラスの一部として定義されているproductItemReaderをテスト対象とします。

package com.example.springbatchh2crud.config;

import com.example.springbatchh2crud.model.Product;
import jakarta.persistence.EntityManagerFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.ItemProcessor;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class ProductJobConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager transactionManager;
    private final EntityManagerFactory entityManagerFactory;

    @Bean
    public Job productJob() {
        return new JobBuilder("productJob", jobRepository)
                .start(productStep())
                .build();
    }

    @Bean
    public Step productStep() {
        return new StepBuilder("productStep", jobRepository)
                .<Product, Product>chunk(10, transactionManager)
                .reader(productItemReader())
                .processor(loggingProductProcessor())
                .writer(loggingProductWriter())
                .build();
    }

    @Bean
    public JpaPagingItemReader<Product> productItemReader() {
        return new JpaPagingItemReaderBuilder<Product>()
                .name("productItemReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT p FROM Product p ORDER BY p.id ASC")
                .pageSize(10)
                .build();
    }

    @Bean
    public ItemProcessor<Product, Product> loggingProductProcessor() {
        return product -> {
            log.info("Processing product: {}", product);
            return product;
        };
    }

    @Bean
    public ItemWriter<Product> loggingProductWriter() {
        return items -> {
            log.info("Writing {} products.", items.size());
            for (Product item : items) {
                log.debug("  - {}", item);
            }
        };
    }
}

テスト対象の仕様

今回テスト対象とするproductItemReaderの仕様を以下に示します。

仕様説明
読み込み対象Productエンティティ
データソースJPA(データベース)
クエリ条件全てのProductエンティティを対象とします。
ソート順idフィールドの昇順でソートされます。
ページングpageSize10に設定されており、1回のDBアクセスで最大10件のProductエンティティを読み込みます。
名前productItemReaderという名前が設定されています。

4.3. 初期データの準備 (data.sql)

ジョブが実際にデータを読み込めるように、H2データベースに初期データを投入しておきます。

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');

-- Add sample data for products table
INSERT INTO products (id, name, price, invalid) VALUES (1, 'Laptop', 1200.00, false);
INSERT INTO products (id, name, price, invalid) VALUES (2, 'Smartphone', 800.00, false);
INSERT INTO products (id, name, price, invalid) VALUES (3, 'Keyboard', 100.50, false);
INSERT INTO products (id, name, price, invalid) VALUES (4, 'Mouse', 25.00, false);
INSERT INTO products (id, name, price, invalid) VALUES (5, 'Monitor', 300.00, true);

4.4. APIエンドポイントの作成 (JobLaunchController.java)

作成したバッチジョブを手動で起動できるよう、APIエンドポイントを用意します。既存のJobLaunchControllerに以下の修正を加えます。

JobLaunchController.java

package com.example.springbatchh2crud.controller;
// JobLaunchController.java の追加・修正箇所

// 1. クラスフィールドにproductJobを追加
private final Job productJob;

// 2. コンストラクタの引数にproductJobを追加
public JobLaunchController(
    // ... 他の中略 ...
    @Qualifier("launcherDemoJob") Job launcherDemoJob,
    @Qualifier("productJob") Job productJob // 追加
) {
    // ... 他の中略 ...
    this.launcherDemoJob = launcherDemoJob;
    this.productJob = productJob; // 追加
}

// ... 中略 ...

// 3. productJobを起動するメソッドを追加
// --- Product Job ---
@PostMapping("/product-job")
public ResponseEntity<String> launchProductJob() {
    return launchJob(productJob);
}

// ... 以下略 ...

5. テスト環境構築

5.1. 依存関係の確認

このハンズオンプロジェクトでは、pom.xmlspring-boot-starter-testが含まれており、これにJUnit 5, Mockito, AssertJなどが同梱されているため、追加の依存関係は不要です。

Mavenの場合 (pom.xml):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.batch</groupId>
    <artifactId>spring-batch-test</artifactId>
    <scope>test</scope>
</dependency>

Gradleの場合 (build.gradle):

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.batch:spring-batch-test'

5.2. モック化の概念

モック化とは

モック化とは、例えるなら料理のレシピをテストする際に、実際に食材を全て用意するのではなく、特定の工程だけを「模擬的な食材」で試すようなものです。

例えば、ソースの味見をするのに、わざわざメインの肉を焼く必要はありませんよね。

テストしたい部分(ItemReaderのロジック)だけを切り離し、その周りの依存関係(データベース)を「模擬的なもの」に置き換えることで、素早く、かつ正確にテストを行うことができます。


実際のモック

JpaPagingItemReaderは、設定されたEntityManagerFactoryからEntityManagerを生成し、そのEntityManagerを使ってデータベースクエリを実行します。

単体テストでは、ItemReaderが依存する以下のオブジェクトをMockitoでモック化し、期待するデータを返すように振る舞いを定義します。

  • EntityManagerFactory:
    • JPAの永続性ユニット(persistence.xmlなどで定義)に基づいてEntityManagerインスタンスを生成するためのファクトリです。アプリケーションの起動時に一度だけ生成され、スレッドセーフです。
    • モック化の目的: JpaPagingItemReaderEntityManagerを取得する際に、モックのEntityManagerを返すように制御します。
  • EntityManager:
    • JPAにおける主要なインターフェースで、永続性コンテキストを管理し、エンティティのライフサイクル(永続化、更新、削除)を操作します。データベースとの対話、クエリの実行、トランザクション管理も行います。スレッドセーフではありません。
    • モック化の目的: JpaPagingItemReaderがデータベース操作(クエリ生成、トランザクション取得など)を行う際に、モックのTypedQueryEntityTransactionを返すように制御します。
  • EntityTransaction:
    • リソースローカルなEntityManager上でのトランザクションを制御するためのインターフェースです。begin(), commit(), rollback()などのメソッドを提供し、プログラムによるトランザクション管理を可能にします。
    • モック化の目的: JpaPagingItemReaderがトランザクションを開始・コミット・ロールバックする際に、その振る舞いをシミュレートし、テストの独立性を保ちます。
  • TypedQuery:
    • JPA 2.0で導入されたインターフェースで、クエリの結果の型をコンパイル時に指定できる型安全なクエリを提供します。これにより、実行時エラーのリスクを減らし、コードの可読性と保守性を向上させます。
    • モック化の目的: JpaPagingItemReaderが実行するクエリの結果(getResultList())を制御し、テストデータを返すように設定します。また、ページング処理(setFirstResult(), setMaxResults())のメソッドチェーンもシミュレートします。

図解:モックによるオブジェクトの差し替え

モック化の前後で、JpaPagingItemReaderが依存するオブジェクトがどのように変化するかを視覚的に理解しましょう。

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

本番環境では、JpaPagingItemReaderは実際のEntityManagerFactoryEntityManager、そして最終的にデータベースに依存しています。

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

単体テストでは、JpaPagingItemReaderが依存するオブジェクトをすべてモックに置き換えます。これにより、実際のデータベースへのアクセスを伴わずに、ItemReaderのロジックだけを高速かつ独立して検証できるようになります。

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

5.3. テストクラスの基本構造

ItemReaderの単体テストを行うための基本的なテストクラスの構造は以下のようになります。@ExtendWith(MockitoExtension.class)でMockitoを有効にし、@Mockで依存オブジェクトをモック化します。@BeforeEachアノテーションを付けたsetUpメソッドで、各テストの実行前にモックの振る舞いを定義します。

JpaProductItemReaderTests.java

package com.example.springbatchh2crud.reader;

/**
 * JpaProductItemReaderの単体テストクラス。
 * Mockitoを利用して、データベースに接続せずにテストを行います。
 */
@ExtendWith(MockitoExtension.class)
class JpaProductItemReaderTests {

    // --- モックオブジェクト定義 ---
    @Mock
    private EntityManagerFactory entityManagerFactory;
    @Mock
    private EntityManager entityManager;
    @Mock
    private TypedQuery<Product> query;
    @Mock
    private EntityTransaction entityTransaction;

    // --- テスト対象のクラス ---
    private JpaPagingItemReader<Product> itemReader;

    /**
     * 各テストメソッドが実行される「前」に毎回呼ばれる設定用メソッド。
     */
    @BeforeEach
    void setUp() throws Exception {
        // --- モックの振る舞いを定義 ---

        // JpaPagingItemReaderが内部で呼び出す可能性のある、引数なし・あり両方の
        // createEntityManagerメソッドの振る舞いを定義し、テストの安定性を高めます。
        lenient()
            .when(entityManagerFactory.createEntityManager())
            .thenReturn(entityManager);
        lenient()
            .when(entityManagerFactory.createEntityManager(anyMap()))
            .thenReturn(entityManager);

        // EntityManagerがトランザクションを要求した際の振る舞いを定義
        when(entityManager.getTransaction()).thenReturn(entityTransaction);

        // EntityManagerがクエリ生成を要求した際の振る舞い
        when(entityManager.createQuery(anyString())).thenReturn(query);

        // Queryがページング処理のメソッドを呼ばれた際の振る舞いを定義
        // メソッドチェーンを維持するため、自分自身(query)を返すように設定します。
        when(query.setFirstResult(anyInt())).thenReturn(query);
        when(query.setMaxResults(anyInt())).thenReturn(query);

        // --- テスト対象オブジェクトの生成 ---
        itemReader = new JpaPagingItemReaderBuilder<Product>()
                .name("productItemReader")
                .entityManagerFactory(entityManagerFactory)
                .queryString("SELECT p FROM Product p ORDER BY p.id ASC")
                .pageSize(2)
                .build();

        // ItemReaderの初期化とオープン
        itemReader.afterPropertiesSet();
        itemReader.open(new org.springframework.batch.item.ExecutionContext());
    }

    /**
     * 各テストメソッドが実行された「後」に毎回呼ばれる後片付け用メソッド。
     */
    @AfterEach
    void tearDown() {
        itemReader.close();
    }

    // --- テストメソッド ---
    // (ここにテストケースを記述していく)
}

6. 【実践】ItemReader単体テストの実装

それでは、JpaPagingItemReaderの単体テストを実装します。以下のコードは、数々のデバッグを経て完成した、実際に動作する最終版のテストコードです。

JpaProductItemReaderTests.java

package com.example.springbatchh2crud.reader;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;

import com.example.springbatchh2crud.model.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.TypedQuery;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.batch.item.database.JpaPagingItemReader;
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;

/**
 * JpaProductItemReaderの単体テストクラス。
 * Mockitoを利用して、データベースに接続せずにテストを行います。
 */
@ExtendWith(MockitoExtension.class) // JUnit 5でMockitoを有効にするためのアノテーション
class JpaProductItemReaderTests {

    // --- モックオブジェクト定義 ---
    // @Mockアノテーションで、本物のオブジェクトの「偽物(モック)」を作成します。
    // これにより、DBなど外部システムに依存しないテストが可能になります。
    @Mock
    private EntityManagerFactory entityManagerFactory; // JPAの必須コンポーネント

    @Mock
    private EntityManager entityManager; // 実際にクエリを実行する役割

    @Mock
    private TypedQuery<Product> query; // クエリ結果を保持する役割

    @Mock
    private EntityTransaction entityTransaction; // トランザクションを管理する役割

    // テスト対象のクラス
    private JpaPagingItemReader<Product> itemReader;

    /**
     * 各テストメソッドが実行される「前」に毎回呼ばれる設定用メソッド。
     */
    @BeforeEach
    void setUp() throws Exception {
        // --- モックの振る舞いを定義 ---
        // JpaPagingItemReaderが内部で呼び出す可能性のある、引数なし・あり両方の
        // createEntityManagerメソッドの振る舞いを定義し、テストの安定性を高めます。
        lenient()
            .when(entityManagerFactory.createEntityManager())
            .thenReturn(entityManager);

        lenient()
            .when(entityManagerFactory.createEntityManager(anyMap()))
            .thenReturn(entityManager);

        // EntityManagerがトランザクションを要求した際の振る舞いを定義
        when(entityManager.getTransaction()).thenReturn(entityTransaction);

        // EntityManagerがクエリ生成を要求した際の振る舞い
        when(entityManager.createQuery(anyString())).thenReturn(query);

        // Queryがページング処理のメソッドを呼ばれた際の振る舞いを定義
        // メソッドチェーンを維持するため、自分自身(query)を返すように設定します。
        when(query.setFirstResult(anyInt())).thenReturn(query);
        when(query.setMaxResults(anyInt())).thenReturn(query);

        // --- テスト対象オブジェクトの生成 ---
        // モック化したEntityManagerFactoryを渡して、テスト対象のItemReaderを組み立てます。
        itemReader = new JpaPagingItemReaderBuilder<Product>()
            .name("productItemReader")
            .entityManagerFactory(entityManagerFactory)
            .queryString("SELECT p FROM Product p ORDER BY p.id ASC")
            .pageSize(2)
            .build();

        // ItemReaderの初期化とオープン
        itemReader.afterPropertiesSet();
        itemReader.open(new org.springframework.batch.item.ExecutionContext());
    }

    /**
     * 各テストメソッドが実行された「後」に毎回呼ばれる後片付け用メソッド。
     */
    @AfterEach
    void tearDown() {
        itemReader.close();
    }

    /**
     * テストケース1: データベースに複数アイテムが存在する場合
     */
    @Test
    void testRead_whenMultipleItemsExist_thenReadAllItems() throws Exception {
        // Arrange: テストの前提条件を設定
        // 1. DBから返されるダミーのProductリストを作成
        List<Product> mockProducts = List.of(
            Product.builder()
                .id(1L)
                .name("Laptop")
                .price(BigDecimal.valueOf(1200.00))
                .invalid(false)
                .build(),
            Product.builder()
                .id(2L)
                .name("Smartphone")
                .price(BigDecimal.valueOf(800.00))
                .invalid(false)
                .build()
        );

        // 2. クエリ実行時のシナリオを設定
        //    - 1回目のread()では、2件のProductリストを返す
        //    - 2回目のread()(次のページ)では、空のリストを返す(=データ終了)
        when(query.getResultList())
            .thenReturn(mockProducts)
            .thenReturn(Collections.emptyList());

        // Act & Assert: 実際のテスト実行と結果検証
        // 1件目を読み込み、期待通りのデータか検証
        Product product1 = itemReader.read();
        assertNotNull(product1);
        assertEquals("Laptop", product1.getName());

        // 2件目を読み込み、期待通りのデータか検証
        Product product2 = itemReader.read();
        assertNotNull(product2);
        assertEquals("Smartphone", product2.getName());

        // 3件目はもうデータがないので、nullが返ることを検証
        Product product3 = itemReader.read();
        assertNull(product3);
    }

    /**
     * テストケース2: データベースにアイテムが存在しない場合
     */
    @Test
    void testRead_whenNoItemsExist_thenReadReturnsNull() throws Exception {
        // Arrange: クエリが常に空リストを返すシナリオを設定
        when(query.getResultList()).thenReturn(Collections.emptyList());

        // Act & Assert: 最初のread()でいきなりnullが返ることを検証
        Product product = itemReader.read();
        assertNull(product);
    }
}

6.1. テストケース1: 複数アイテムの正常読み込み

テストケースの仕様

このテストケースでは、JpaPagingItemReaderが複数のProductエンティティを期待通りに読み込むことを検証します。

  • シナリオ:
    1. データベース(モック)に複数のProductエンティティが存在する状況をシミュレートします。
    2. ItemReaderread()メソッドを複数回呼び出すことで、これらのエンティティを順次取得できることを確認します。
    3. 全てのデータが読み込まれた後、read()メソッドがnullを返すことを確認します。
  • 検証ポイント:
    • read()メソッドが正しい順序でProductエンティティを返すこと。
    • ItemReaderがデータの終端に達した際にnullを返すこと。

解説

  • Product.builder()を使って、テストデータとなるProductオブジェクトを2つ作成します。
  • when(query.getResultList()).thenReturn(mockProducts).thenReturn(Collections.emptyList())の部分がシナリオ定義の肝です。
    • Mockitoでは、.thenReturn()を繋げることで、メソッドが呼ばれるたびに異なる値を返すように設定できます。
    • ここでは「最初の呼び出しでは2件のProductリストを返し、2回目以降の呼び出しでは空のリストを返す」という振る舞いを定義しています。
  • itemReader.read()を3回呼び出し、1回目、2回目は期待するProductオブジェクトが返ってくること、そしてデータがなくなった3回目にはnullが返ってくることをassertで検証します。

6.2. テストケース2: アイテムが存在しない場合の挙動

テストケースの仕様

このテストケースでは、JpaPagingItemReaderが読み込むべきProductエンティティが存在しない場合に、正しくnullを返すことを検証します。

  • シナリオ:
    1. データベース(モック)にProductエンティティが全く存在しない状況をシミュレートします。
    2. ItemReaderread()メソッドが、初回からnullを返すことを確認します。
  • 検証ポイント:
    • read()メソッドが、データが存在しない場合にnullを返すこと。

解説

  • when(query.getResultList()).thenReturn(Collections.emptyList())と設定することで、ItemReaderがデータを問い合わせた際に、常に0件の結果が返ってくる状況をシミュレートします。
  • この状態でitemReader.read()を呼び出し、戻り値がnullであることを検証します。これは、ItemReaderが「読み込むべきデータが存在しない」と正しく判断したことを意味します。

6.3. 補足:TypedQueryのモックの働き

TypedQueryのモック設定は、テスト対象のItemReaderが正しく動作するかを確認するために、その「入力」となる依存オブジェクトの挙動を制御し、必要な「テストデータ」を供給する役割を担っていると言えます。

  • テストデータの仕込みの側面:
    when(mockQuery.getResultList()).thenReturn(products, Collections.emptyList()); のように、モックが返す具体的なデータ(productsリスト)を用意する部分は、まさに「テストデータの仕込み」と言えます。
  • 依存オブジェクトの振る舞いを定義する側面:
    when(entityManager.createQuery(...)).thenReturn(mockQuery);when(mockQuery.setFirstResult(...)).thenReturn(mockQuery); といった設定は、JpaPagingItemReaderが内部でEntityManagerTypedQueryのメソッドを呼び出したときに、それらが「どのように応答すべきか」という振る舞いを定義しています。

7. テストの実行と結果確認

作成した単体テストは、プロジェクトのルートディレクトリで以下のコマンドを実行することで確認できます。

./mvnw test -Dtest=JpaProductItemReaderTests

テストが成功すると、コンソールに以下のようなログが出力されます。これは、2つのテストケースがエラーなく正常に完了したことを示しています。

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.springbatchh2crud.reader.JpaProductItemReaderTests
...
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.460 s -- in com.example.springbatchh2crud.reader.JpaProductItemReaderTests
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

8. アプリケーションの実行と動作確認

単体テストだけでなく、実際にアプリケーションを起動してバッチジョブが動作することを確認します。


8.1. アプリケーションの起動

まず、以下のコマンドでSpring Bootアプリケーションを起動します。

./mvnw spring-boot:run

8.2. ジョブの実行

アプリケーションが起動したら、別のターミナルから以下のcurlコマンドを実行して、productJobを起動します。

curl -X POST http://localhost:8080/launch-job/product-job

成功すると、ターミナルに以下のメッセージが返ってきます。

ジョブ 'productJob' は正常に起動されました。

8.3. 実行ログの確認

アプリケーションを起動したターミナルのコンソールには、ジョブが実行されたことを示す以下のようなログが出力されます。ItemProcessorItemWriterが正しく動作していることが確認できます。

...
2025-11-24T16:34:51.572+09:00  INFO 57839 --- [nio-8080-exec-1] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=productJob]] launched with the following parameters: ...
2025-11-24T16:34:51.580+09:00  INFO 57839 --- [nio-8080-exec-1] o.s.batch.core.job.SimpleStepHandler     : Executing step: [productStep]
Hibernate:
    select ... from products p1_0 ...
2025-11-24T16:34:51.601+09:00  INFO 57839 --- [nio-8080-exec-1] c.e.s.config.ProductJobConfig            : Processing product: Product(id=1, name=Laptop, price=1200.00, invalid=false)
2025-11-24T16:34:51.601+09:00  INFO 57839 --- [nio-8080-exec-1] c.e.s.config.ProductJobConfig            : Processing product: Product(id=2, name=Smartphone, price=800.00, invalid=false)
...
2025-11-24T16:34:51.602+09:00  INFO 57839 --- [nio-8080-exec-1] c.e.s.config.ProductJobConfig            : Writing 5 products.
2025-11-24T16:34:51.603+09:00  INFO 57839 --- [nio-8080-exec-1] o.s.batch.core.step.AbstractStep         : Step: [productStep] executed in 22ms
2025-11-24T16:34:51.604+09:00  INFO 57839 --- [nio-8080-exec-1] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=productJob]] completed ... and the following status: [COMPLETED] in 28ms
...

9. 【実践的知見】テスト実装のハマりどころと解決策

JpaPagingItemReaderのような、内部で多くのコンポーネントと連携するクラスの単体テストでは、Mockitoのモック設定が複雑になりがちです。ここでは、今回の実装過程で直面した具体的なエラーとその解決策を、実践的な知見として体系的にまとめます。


9.1. 問題:Unable to obtain an EntityManager

原因

ItemReaderopen()される際にEntityManagerを取得しようとして失敗するケースです。これは、entityManagerFactory.createEntityManager()のモック設定が不足しているか、ItemReaderの内部実装と一致していない場合に発生します。

解決策

ItemReaderが内部でどちらのcreateEntityManagerメソッド(引数なしか、引数ありか)を呼び出すか不明確な場合も考慮し、両方のオーバーロードをモックします。これにより、テストの堅牢性が向上します。

// 解決策コード (setUpメソッド内)
lenient().when(entityManagerFactory.createEntityManager()).thenReturn(entityManager);
lenient().when(entityManagerFactory.createEntityManager(anyMap())).thenReturn(entityManager);

9.2. 問題:NullPointerException at EntityTransaction.begin()

原因

ItemReaderが読み込み処理(read())を開始する際に、トランザクションを開始しようとentityManager.getTransaction().begin()を呼び出します。しかし、モックのentityManagergetTransaction()の振る舞いが定義されていないためnullを返し、結果としてNullPointerExceptionが発生します。

解決策

EntityTransaction自体もモック化し、entityManager.getTransaction()が呼ばれた際にそのモックを返すように振る舞いを定義します。

// 解決策コード
@Mock
private EntityTransaction entityTransaction;

// ... setUp()メソッド内
when(entityManager.getTransaction()).thenReturn(entityTransaction);

9.3. 問題:Mockito PotentialStubbingProblem (引数ミスマッチ)

原因

MockitoのStrict Stubbing機能が、スタブ設定(when(...))と実際のメソッド呼び出しの引数が一致しないことを検知したエラーです。実際には、ItemReaderが引数1つのcreateQuery(String)を呼び出しているのに対し、テスト側で引数2つのcreateQuery(String, Class)をモックしようとしていたために発生しました。

解決策

ItemReaderの実際の動きに合わせて、スタブ設定を引数1つのcreateQuery(anyString())に変更します。

// 解決策コード (setUpメソッド内)
when(entityManager.createQuery(anyString())).thenReturn(query);

9.4. 問題:NullPointerException at Query.setMaxResults()

原因

ItemReaderはページングを実現するためにquery.setFirstResult(..).setMaxResults(..)のようなメソッドチェーンを使用します。モックオブジェクトのメソッドは、振る舞いを定義しない限り戻り値がnullになるため、setFirstResult()nullを返し、続くsetMaxResults()の呼び出しでNullPointerExceptionが発生します。

解決策

メソッドチェーンが途切れないように、setFirstResult()setMaxResults()が呼ばれた際に自分自身(queryモック)を返すように振る舞いを定義します。

// 解決策コード (setUpメソッド内)
when(query.setFirstResult(anyInt())).thenReturn(query);
when(query.setMaxResults(anyInt())).thenReturn(query);

10. まとめ: ItemReaderテストで品質の高いバッチ処理を実現しよう

本記事では、Spring BatchのItemReaderの単体テストに焦点を当て、その重要性、Mockitoを用いたモック化の手法、そして具体的なテストコードの実装方法を解説しました。さらに、実装過程で発生しがちなエラーと、その実践的な解決策も共有しました。

  • ItemReaderの単体テストは、外部依存を排除し、ItemReader自身の読み込みロジックを高速かつ独立して検証するために不可欠です。
  • Mockitoを駆使することで、EntityManagerFactoryEntityManagerといった複雑な依存関係をシミュレートし、ItemReaderの挙動を正確にテストできます。
  • テストで発生するエラーは、テスト対象クラスの内部実装を理解する絶好の機会です。エラーメッセージを注意深く読み解き、モックの振る舞いを一つずつ正しく定義していくことが、テスト成功への鍵となります。

このアプローチにより、開発の早い段階でItemReaderのバグを発見し、修正コストを削減することができます。


11. (参考) JPAエンティティとLombok活用術

JPAエンティティでは、オブジェクトの生成と永続化のライフサイクルにおいて、コンストラクタが重要な役割を果たします。特に単体テストにおいては、テストデータの準備を容易にするために、様々なコンストラクタを使い分けることが推奨されます。


11.1. Lombokを使った実装例

以下が、今回のハンズオンで最終的に使用したProductエンティティのコードです。

package com.example.springbatchh2crud.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "products")
@Data
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Product {

    @Id
    private Long id;

    private String name;
    private BigDecimal price;
    private boolean invalid;
}

11.2. 各アノテーションの役割

  • @Data:
    • @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructorをまとめた便利なアノテーションです。
  • @NoArgsConstructor(access = AccessLevel.PROTECTED):
    • JPAがエンティティをインスタンス化するために必要な引数なしコンストラクタを生成します。アクセスレベルをPROTECTEDにすることで、アプリケーションコードからの意図しない呼び出しを防ぎます。
  • @AllArgsConstructor:
    • 全てのフィールドを引数に持つコンストラクタを生成します。@Builderと連携して動作します。
  • @Builder:
    • Builderパターンを自動生成します。Product.builder().name("...").price(...).build()のように、可読性が高く柔軟なオブジェクト生成が可能になり、テストデータの作成に非常に役立ちます。

11.3. Lombokを使う場合の留意点

Lombokは非常に便利ですが、特にJPAエンティティで利用する際にはいくつかの留意点があります。

  • @EqualsAndHashCodeの注意:
    • @Dataに含まれる@EqualsAndHashCodeは、デフォルトですべてのフィールドを使用します。
    • JPAの遅延読み込みやリレーションを持つエンティティの場合、意図しないパフォーマンス低下やStackOverflowErrorを引き起こす可能性があります。
    • その場合は@Dataを使わず、@Getter, @Setterなどを個別に使用し、@EqualsAndHashCodeの対象フィールドをexcludeonlyExplicitlyIncludedで明示的に指定することを検討してください。
  • チームでの合意:
    • Lombokはコードを簡潔にしますが、裏側で何が起きているかを理解しておくことが重要です。
    • チーム内でLombokの利用方針について合意形成しておくことが、コードの可読性と保守性を保つ上で役立ちます。

免責事項

本記事の内容は、執筆時点での情報に基づいており、その正確性、完全性、有用性を保証するものではありません。
記事に記載された情報やコードの利用によって生じたいかなる損害についても、筆者および公開元は一切の責任を負いません。
技術情報は常に変化するため、ご自身の責任において最新の情報を確認し、適用してください。


SNSでもご購読できます。

コメントを残す

*