見出し画像

【技術系】Spring Batch 5.1を動かそうとする

対象となる方:Spring Batch5.1を動かそうとしてうまくいっていない

 数年前「公式が最も情報量が多くて信頼できる」との触れ込みで、検索しても、検索しても公式の記事の丸写ししか出てこないことがありました。そういう事象に出くわすたびに「公式で、正確な表現をしようとしてかしこまったり、読む人はわかるものだと思って端折ってわかりにくくなっている、だから追加で検索しているんだよ(@_@)」って思っていました。
というわけで、自分が記事を書く場合は対象者を絞る代わりに、少々表現がライン越えしてしまっても、という観点で踏み込んだ表現を心がけたいと思います。
 バッチの解説といえばまず「チャンクモデル」と「タスクレットモデル」があって…となりますが、そのあたりは公式または他サイトで確認しているものとしてすっ飛ばします。

ここで解説したソースは現在作成中の政治資金収支報告書調査側ソフトウェアのバッチ起動試行のBranchに存在します。

起動方法

 さて、Spring Batch 5.1における最大のキモを提示します。公式にはいろいろ書いてありますが、「Web コンテナー内からのジョブの実行」にあるこの実装がコマンドラインを使って起動しない場合の最終、かつ決定版になります。

@Controller//(2)
public class JobLauncherController {

    @Autowired//(3)
    JobLauncher jobLauncher;

   
    @Qualifier(Configure.JOB_NAME)//←公式にはないが必要なので追加した
    @Autowired //(4)
    Job job;

    @RequestMapping("/jobLauncher.html")
    public void handle() throws Exception{
        jobLauncher.run(job, new JobParameters());//(5)
    }
}

※上記ソースは公式の掲載サンプルに下記説明との対応番号を振った

  1. Spring Bootが起動状態である

  2. 起動するクラスはSpring Bootの管理下にある(@Component,@Controller,@Serviceなどがついている)

  3. @autowiredされたJobLauncherがある

  4. (@Qualifierかつ)@autowiredで取得できる組み立て済のJobがある

  5. 実行条件となるJobParametersを与えている

さえそろえば、動きます。動かすベースとなるクラスがConlloerでなくてServiceでも動きます。Scheduleランチャでも動きます。boot起動時にすべてのBactchの初回起動を設定するapplication.propertiesにspring.batch.job.enabled=falseとしても動きます。動かないときに、「ほかの起動の実装方法を試したらうまくいくのでは…」「動いていないのは起動方法に問題がある可能性があって、他のウェブページでチラ見した(Ver4の)起動方法なら動くのでは」「まだ私の見知らぬ設定を行っていないから動かない」と考えるだけ、ググりにいくだけ基本ムダです。それ、たぶん起動失敗の原因は、タスクの定義方法だったり、メタテーブルの作り方だったりします。(※起動失敗原因がJobParametersの設定が不十分、@autowiredつけ忘れとかで起動部分に問題がある可能性はありますが、それは実装方法そのものの問題ではありません。)繰り返しになりますが、Spring Batch5.1を採用し、Spring Bootが起動している環境であれば、この起動方法がシンプルかつベストになります。

JobとStepの定義(Configuration)

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

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.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.PersonDto;
import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.ReportDto;

/**
 * Csv読み取りし書き込みするバッチ
 */
@Configuration
public class ReadCsvBatchConfiguration {

    /** 機能名 */
    private static final String FUNCTION_NAME = "readCsv";

    /** Step(接尾語) */
    private static final String STEP = "Step";
    /** Job(接尾語) */
    private static final String JOB = "Job";

    /** Job名 */
    public static final String JOB_NAME = FUNCTION_NAME + JOB;

    /** Step名 */
    private static final String STEP_NAME = FUNCTION_NAME + STEP;

    /** チャンクサイズ */
    private static final int CHUNK_SIZE = 3;

    /** プロセッサ */
    @Autowired
    private ReadCsvItemProcessor readCsvItemProcessor;

    /** リーダ */
    @Autowired
    private ReadCsvPersonItemReader readCsvPersonItemReader;
    
    /** ライタ */
    @Autowired
    private ReadCsvReportItemWriter readCsvReportItemWriter;

    /**
     * Jobを返却する
     *
     * @param jobRepository ジョブレポジトリ
     * @param step このConfigureで設定したステップ
     * @return ジョブ
     */
    @Bean(JOB_NAME)
    protected Job job(final JobRepository jobRepository,@Qualifier(STEP_NAME) final Step step) {

        return new JobBuilder(JOB_NAME, jobRepository).incrementer(new RunIdIncrementer()).flow(step).end().build();
    }

    /**
     * Stepを返却する
     *
     * @param jobRepository ジョブレポジトリ
     * @param transactionManager トランザクションマネージャ
     * @return step
     */
    @Bean(STEP_NAME)
    protected Step step(final JobRepository jobRepository, final PlatformTransactionManager transactionManager) {
        
        return new StepBuilder(STEP_NAME, jobRepository).<PersonDto, ReportDto>chunk(CHUNK_SIZE, transactionManager)
                .reader(readCsvPersonItemReader).processor(readCsvItemProcessor).writer(readCsvReportItemWriter).build();
    }
}

これは自作のBatchのConfigrationなのですが、5.0以前と明確に異なる、ちゃんと起動するかどうかを明確に隔てている点があります。

  1. StepやJobを定義するメソッドで@Beanを使うときは名前を指定すること

  2. 定義したクラス/インスタンスを呼ぶときは@Qualifierを使い名前を明示すること

 複数ジョブが存在する場合、@Qualifierを使い、使用するBeanの名前を明示しないと(独自のクラス型を設定していない限り)100%落ちます。つい最近まで型がJobインターフェイスであろうとも、引数名がアプリケーション内で1個しかなかったら、引数名の規則で依存性注入してくれていましたが、しなくなったようです。(これはSpringフレームワーク6の傾向であるというような公式記事がありました。)
 また、@Qualifierを設定している実装サンプルは、Web上でそれなりの数があるようですが、引数に当てているサンプルはかなり少なくなりますので、この機会に提示しておきます。『われわれ』は@autowiredを使うときはクラスのフィールドだ、と思い込んでいますが、引数に使っても所期の効果が得られます。どこかで1件だけ実装サンプルを見ました(ってもう一度見かけた!って確認したら公式だったよ)。結論:@autowired,@Qualifierのアノテーションはメソッド、フィールド、引数、たぶんいずれの場所でも使えます。

バッチ起動引数(とタイムスケジュール起動)

 バッチの実行条件が必要な時はJobParameterに設定して、それを受け取ります。まずは引数を与える側(かつタイムスケジュール実装例)

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecutionException;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

//TODO 普段は毎回定期間隔で実行されると煩わしいので@EnableSchedulingと@Componentを無効化する
/**
 * バッチをタイムスケージュールで自動起動するランチャ
 */
@EnableScheduling
@Component
public class ReadCsvBatchLauncher {

    /** 起動をつかさどるランチャー */
    @Autowired
    private JobLauncher jobLauncher;

    /** 起動をするJob */
    @Qualifier(ReadCsvBatchConfiguration.JOB_NAME)
    @Autowired
    private Job readCsvJob;

    /** JobParameter */
    private JobParameters jobParameters = new JobParameters();

    /**
     * 時間間隔と必要な起動引数を設定し、所定時刻で起動する
     *
     * @throws JobExecutionException ジョブ設定に誤りがある場合の例外
     */
    @Scheduled(cron = "0/10 * * * * *")
    public void launchJob() throws JobExecutionException {

        jobParameters = new JobParametersBuilder(readCsvJob.getJobParametersIncrementer().getNext(this.jobParameters)) // NOPMD
                .addString("readFileName", "c:/temp/person.csv").addString("writeFileName", "c:/temp/report.csv")
                .toJobParameters();
        
        jobLauncher.run(readCsvJob, jobParameters);
    }

}

そして受け取る側(Reader。Writerも原理は同じ)

@Component
public class ReadCsvPersonItemReader  extends FlatFileItemReader<PersonDto>{

    /**
     * コンストラクタ
     *
     * @param lineMapper 行解析Mapper
     */
    public ReadCsvPersonItemReader(final PersonLineMapper lineMapper) {
        super();
        super.setLineMapper(lineMapper);
    }

    /**
     * 実行条件(読み取りファイル)を取得する
     *
     * @param stepExecution Step実行
     */
    @BeforeStep
    public void beforeStep(final StepExecution stepExecution){
        Path path = Paths.get(stepExecution.getJobParameters().getString("readFileName"));
        super.setResource(new FileSystemResource(path.toFile()));
    }
    

引数を与える側は、Mapに値を入れるのと同様、JobParametersに呼び出すためのキー(ワード)と合わせて変数を与えていきます。受け取る場合はStepExecutionに格納されたJobParametersからキー(ワード)を使って取り出します。たまたまItemReaderとItemWriterには@BeforeStepなる、タイミング的にうってつけそうのメソッドがあるので、それを利用するといいそうですBy公式(それにしてもパスの直書きはよくない…)。
 上記タイムスケジュールランチャは10秒おきに起動します。書法については他サイトを参照していただければと思います。このタイムスケジュール機能はバッチ機能とは何のかかわりもなく、単に時間通り指定された作業をするだけです。本当にバッチをスケジュール実行したい場合は、別途スケジュールツールを用意してくださいと公式がおっしゃっています。

この実装にgetJobParametersIncrementer().getNext(this.jobParameters)なる『変な』ものがついていますが、バッチ起動実行変数が同一の場合でも実行できるようにするための措置です。詳しくは下記「発生しがちな例外まとめ」2の項を参照してください。
ファイルを扱うReaderにはファイルの形式からDtoに変換するMapper、WriterにはDtoから出力形式に変更するAggregatorが必要となります(設定していないとboot起動途中で落ちます。生成してSpring Bootが管理を始める段階ではそろっていないといけないようなので、コンストラクタの引数ないしは内部でnewという選択肢になりがち)。詳しくは下記「発生しがちな例外まとめ」3の項を参照してください。

Junitによるテスト

まずはJob定義のテストです。

package mitei.mitei.investigate.report.balance.politician.batch.trial.write_csv;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * WriteCsvBatchConfiguration単体テスト
 */
@SpringBatchTest
@SpringJUnitConfig(WriteCsvBatchConfiguration.class)
class WriteCsvBatchConfigurationTest {

    /** テストユーティリティ */
    @MockBean
    private JobLauncherTestUtils jobLauncherTestUtils;

    /** レポジトリのMock */
    @MockBean
    private JobRepository jobRepository;

    /** トランザクションマネージャのMock */
    @MockBean
    private PlatformTransactionManager transactionManager;

    /** ジョブランチャのMock */
    @MockBean
    private JobLauncher jobLauncher;


    @Test
    void testJob(final @Autowired Job job) {
        assertThat(job.getName()).isEqualTo(WriteCsvBatchConfiguration.JOB_NAME);
    }

    @Test
    void testStep(final @Autowired Step step) {
        assertThat(step.getName()).isEqualTo(WriteCsvBatchConfiguration.STEP_NAME);
    }

    @Test
    void testTasklet(final @Autowired Tasklet tasklet) {
        assertThat(tasklet.getClass()).isEqualTo(WriteCsvSimpleTasklet.class);
    }

}

そして実行のテスト

package mitei.mitei.investigate.report.balance.politician.batch.trial.write_csv;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import mitei.mitei.investigate.report.balance.politician.BackApplication;

/**
 * WriteCsvBatchConfiguration単体テスト(実行が成功する)
 */
@SpringJUnitConfig(WriteCsvBatchConfiguration.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@SpringBatchTest
@ContextConfiguration(classes = BackApplication.class)//全体起動
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
class WriteCsvBatchConfigurationSuccessExecuteTest {
    
    /** テストユーティリティ */
    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;
    
    @Test
    void test(final @Qualifier(WriteCsvBatchConfiguration.JOB_NAME) @Autowired Job job)throws Exception {
        //JOB名だけを検証していた場合は他のクラスを起動していなかったので、Job名を指定する必要がなかったが、
        //今回全体起動したため、readCsvが同じくBoot管理下におかれているため、@QualifierでJob名を厳密に指定する必要が出た
        
        jobLauncherTestUtils.setJob(job);
        JobExecution jobExecution = jobLauncherTestUtils.launchJob();

        assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
    }

}

え、(たまたまこのJobは超単純ですが)こんないろんな作業をしているのに、テスト内容って取得したステップの名前を確認するだけ?終了ステータスを確認するだけ?と思いがちですが、基本、定義をしているクラスについては、定義ができているかを確認する、そういうもの(みたい)です。関係する全部のクラスで100%テストを書いて、その中に常識的にテストすべきと思われる「××というデータを入れたら○○という結果が返ってきた」というテストが必ずどこか(というかそういうロジックを書いたクラスに対応するテスト)に含まれているからです。どこにも含まれていなければ、どこかに書かなければならなくなりますが。
 今回の記事中最大の謎が公式のユニットテストのバッチジョブのエンドツーエンドテストの記載です。2つのアノテーションを追加してあり、テストクラスが完結し、これで動作する風なのですが、この記述だけ、この要素を満たしただけでは動かないような気がします。

package mitei.mitei.investigate.report.balance.politician.batch.trial.write_csv;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * WriteCsvBatchConfiguration実行テスト(失敗する)
 */
@SpringBatchTest
@SpringJUnitConfig(WriteCsvBatchConfiguration.class)
class WriteCsvBatchConfigurationExecuteTestFailure {

    /** テストユーティリティ */
    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    /** レポジトリのMock */
    @MockBean
    private JobRepository jobRepository;

    /** トランザクションマネージャのMock */
    @MockBean
    private PlatformTransactionManager transactionManager;

    /** ジョブランチャのMock */
    @MockBean
    private JobLauncher jobLauncher;

    @Test
    void test(final @Autowired Job job) throws Exception {

        jobRepository.createJobExecution(WriteCsvBatchConfiguration.JOB_NAME, new JobParameters());
        jobLauncherTestUtils.setJobLauncher(jobLauncher);
        jobLauncherTestUtils.setJobRepository(jobRepository);
        
        jobLauncherTestUtils.setJob(job);

        JobExecution jobExecution = jobLauncherTestUtils.launchJob();

        //実行できなくてnullの結果しか返らなくてテストが落ちる。普通に考えるとConfigureで
        //引数に取っているPlatformTransactionManagerなどがMockだから…
        assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
    }

}

※このソースはテスト失敗例

作成中にデータベースを一切触っていないJobを定義したのにDataSourceがないよ(←バッチ管理スキーマ用ですね)と言われて、「stept定義で引数に取っている(依存性注入を要求している)PlatformTransactionManagerをどうやって埋めるのか?いろいろ工夫を凝らしているより、先にBoot全体起動するテストパターンのほうが、もういろいろ悩まなくていいよ…」とハタと思ってしまったので、成功する実行テストはその方法で実装しています。
というわけで、この公式サンプルの記述は完結していて2アノテーションだけで動くか、そうでないか、というこの謎の結論は出ていません。このパターンで、この条件を満たしたなら、例えば、役割としてはMockなんだけど、本番Spring Bootのクラス管理と矛盾しないクラスを置くと、動きますよというかたは挙手いただきますと幸いです。あるいはすべての関連インスタンスがnewされているジョブ、みたいな場合は動きそうな気がしますが、ビジネスロジックとその組み立て(定義)だけを実装すればバッチとして機能するという、Spring Batchでおすすめされている実装方法と異なる気がします…

 つぎはReader、Write、Processorです。
まずはProcessorですが、読み取り用クラスから書き込み用クラスの変換なのでSpring Batchはおろか、Spring Bootすら無関係です。

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.PersonDto;
import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.ReportDto;

/**
 * ReadCsvItemProcessor単体テスト
 */
class ReadCsvItemProcessorTest {
    // CHECKSTYLE:OFF

    @Test
    void testProcess()throws Exception {

        //プロセッサのテストはBoot機能と一切関係なし!
        ReadCsvItemProcessor readCsvItemProcessor = new ReadCsvItemProcessor();
        
        PersonDto personDto = new PersonDto();
        
        personDto.setFirstName("first");
        personDto.setLastName("last");
        personDto.setExamResult(80);
        personDto.setYearOfJoining(3);
        personDto.setExamResult(100);
        
        ReportDto reportDto = readCsvItemProcessor.process(personDto);
        
        //本当は分岐の数だけテストが必要ですがサンプルなので…
        assertThat(reportDto.getFirstName()).isEqualTo(personDto.getFirstName());
        assertThat(reportDto.getLastName()).isEqualTo(personDto.getLastName());
        assertThat(reportDto.getGradeName()).isEqualTo("グレードA");
    }

}

WriterのテストにはItemReader,ItemWriterのテストで必要とされる@SpringBatchTest,@SpringJUnitConfigアノテーションをつけてはいるのですが…なくても動きます。Spring Batchテスト特有のMetaDataInstanceFactoryというクラスは使っているものの、このテストで登場する皆さんは全員インスタンスのNew、またはStaticで呼んでいて、Spring Bootの機能に依存していないので。

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.test.MetaDataInstanceFactory;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.ReportDto;
import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.ReportLineAggregator;

/**
 * ReadCsvReportItemWriter単体テスト
 */
@SpringBatchTest
@SpringJUnitConfig
class ReadCsvReportItemWriterTest {
    // CHECKSTYLE:OFF

    /** Jobパラメータ */
    private final String paramPath = "c:/temp/report_test.csv";

    public StepExecution getStepExecution() {

        JobParameters jobParameters = new JobParametersBuilder() // NOPMD
                .addString("writeFileName", paramPath).toJobParameters();

        //起動引数付きのStepExecutionを作成
        StepExecution execution = MetaDataInstanceFactory.createStepExecution(jobParameters);

        return execution;
    }

    @Test
    void testBeforeStep() throws Exception {

        /* ジョブパラメータで設定したファイルが事前に存在するなら、テストのために消すという行為を行っているので要注意!、 */
        Path path = Paths.get(paramPath);
        if (Files.exists(path)) {
            Files.delete(path);
        }

        ReadCsvReportItemWriter readCsvReportItemWriter = new ReadCsvReportItemWriter(new ReportLineAggregator());

        // これから作ろうとするファイルは存在しません
        assertFalse(Files.exists(path));

        StepExecution execution = this.getStepExecution();

        readCsvReportItemWriter.beforeStep(execution);

        ReportDto reportDto = new ReportDto();
        reportDto.setFirstName("first");
        reportDto.setLastName("last");
        reportDto.setGradeName("グレードA");
        Chunk<ReportDto> chunk = new Chunk<ReportDto>(reportDto);

        readCsvReportItemWriter.open(execution.getExecutionContext());
        readCsvReportItemWriter.write(chunk);
        readCsvReportItemWriter.close();

        // write終了後、ファイルが生成されました=writerにジョブパラメータが渡ったことを意味する。・・・・かなり論理展開が苦しい
        assertTrue(Files.exists(path));

    }

    @Test
    void testDoWrite() throws Exception {

        ReadCsvReportItemWriter readCsvReportItemWriter = new ReadCsvReportItemWriter(new ReportLineAggregator());

        //投入するデータを作成
        ReportDto reportDto = new ReportDto();
        reportDto.setFirstName("first");
        reportDto.setLastName("last");
        reportDto.setGradeName("グレードA");
        Chunk<ReportDto> chunk = new Chunk<ReportDto>(reportDto);

        String actual = readCsvReportItemWriter.doWrite(chunk);

        assertThat(actual).isEqualTo("\"first\",\"last\",\"グレードA\"" + ReadCsvReportItemWriter.DEFAULT_LINE_SEPARATOR);
    }

}

シンプルなパターンのTaskletのテスト

package mitei.mitei.investigate.report.balance.politician.batch.trial.write_csv;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.scope.context.StepContext;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.batch.test.MetaDataInstanceFactory;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import mitei.mitei.investigate.report.balance.politician.util.GetTestResourceUtil;

/**
 * WriteCsvSimpleTasklet単体テスト
 */
@SpringBatchTest
@SpringJUnitConfig(WriteCsvSimpleTasklet.class)
class WriteCsvSimpleTaskletTest {

    /** テスト対象 */
    @Autowired
    private WriteCsvSimpleTasklet writeCsvSimpleTasklet;
    
    @Test
    void test()throws Exception {

        StepExecution execution = MetaDataInstanceFactory.createStepExecution();
        StepContribution contribution = execution.createStepContribution();
        ChunkContext chunk = new ChunkContext(new StepContext(execution));
        
        RepeatStatus status = writeCsvSimpleTasklet.execute(contribution, chunk);
        
        assertEquals("FINISHED", status.name());
        
        //想定されるファイルの中身が同一
        Path pathExpect = GetTestResourceUtil.practice("/batch/trial/tasklet_test.csv");
        
        String actual = Files.readString(Paths.get(WriteCsvSimpleTasklet.PATH_WRTITE));
        String expect = Files.readString(pathExpect);
        assertThat(actual).isEqualTo(expect);
    }

}

ChunkContext,StepContributionともこの実装で使用していないので、nullでも全く問題ありません(Taskletでごりごり実装していて、使う方がいれば参考に程度)。公式には2つのファイルの比較は便利なクラスを用意してあげているよ、と書いてありますが、すでに非推奨で5.2で削除予定、Assert Jを使ってね、とのこと(もう公式記載の比較クラスは使わないほうが良いかも)。

次はReader(どっちでもいいけど全体起動パターン)

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.test.MetaDataInstanceFactory;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import mitei.mitei.investigate.report.balance.politician.BackApplication;
import mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv.dto.PersonDto;
import mitei.mitei.investigate.report.balance.politician.util.GetTestResourceUtil;

/**
 * ReadCsvPersonItemReader単体テスト
 */
@SpringJUnitConfig
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@SpringBatchTest
@ContextConfiguration(classes = BackApplication.class)//全体起動
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
class ReadCsvPersonItemReaderTest {
    // CHECKSTYLE:OFF
    
    /** テスト対象 */
    @Autowired
    private ReadCsvPersonItemReader readCsvPersonItemReader;

    public StepExecution getStepExecution()throws Exception {

        String paramPath =  GetTestResourceUtil.practice("batch/trial/person.csv").toString();
        
        JobParameters jobParameters = new JobParametersBuilder() // NOPMD
                .addString("readFileName", paramPath).toJobParameters();
        //起動引数付きのStepExecutionを作成
        StepExecution execution = MetaDataInstanceFactory.createStepExecution(jobParameters);
        return execution;
    }

    @Test
    void testRead()throws Exception {
    
        StepExecution execution = getStepExecution();
        readCsvPersonItemReader.beforeStep(execution);
        
        readCsvPersonItemReader.open(execution.getExecutionContext());
        readCsvPersonItemReader.read(); // 1行目をテストしてもいいけどあえて空打ち
        PersonDto personDto = readCsvPersonItemReader.read();
        
        assertThat(personDto.getFirstName()).isEqualTo("first");
        assertThat(personDto.getLastName()).isEqualTo("last");
        assertThat(personDto.getExamResult()).isEqualTo(48);
        assertThat(personDto.getYearOfJoining()).isEqualTo(1);
        assertThat(personDto.getTrainingAmount()).isEqualTo(100);
        
        readCsvPersonItemReader.close();
        
    }    
}

タイムスケジュールのテスト。これはSpring BootのGitのソースにバシバシ確認して作ったかつ、現在時刻にテスト結果が振り回されないテストになっています(ので、ちょっと自慢げ、でもタイムスケジュール起動自体がもうおすすめされていない)。

package mitei.mitei.investigate.report.balance.politician.batch.trial.read_csv;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

import org.junit.jupiter.api.Test;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.config.CronTask;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskHolder;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.scheduling.support.SimpleTriggerContext;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import mitei.mitei.investigate.report.balance.politician.BackApplication;

/**
 * ReadCsvBatchLauncher単体テスト
 */
@SpringJUnitConfig
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@SpringBatchTest
@ContextConfiguration(classes = { BackApplication.class, // 全体起動
        ReadCsvBatchLauncher.class // テスト対象)
})
@DirtiesContext(classMode = ClassMode.BEFORE_CLASS)
class ReadCsvBatchLauncherTest {
    // CHECKSTYLE:OFF
    
    /** テスト対象 */
    @Autowired
    private ReadCsvBatchLauncher readCsvBatchLauncher;
    
    /** spring boot タスク保持クラス */
    @Autowired
    private ScheduledTaskHolder scheduledTaskHolder;
    // ↑取得するのはScheduledAnnotationBeanPostProcessorでも可    
    
    @Test
    void testLaunchJob()throws Exception {

        // テスト対象のクラス.メソッド名
        final String taskName = "ReadCsvBatchLauncher.launchJob";

        int checkCount = 0;
        ScheduledTask scheduledTask = null;
        for (ScheduledTask task : scheduledTaskHolder.getScheduledTasks()) {
            if (task.toString().endsWith(taskName)) {
                scheduledTask = task;
                checkCount++;
            }
        }

        // スケージュールを指定したjobLauncharは一つに絞れているはずです(念のため)。
        assertEquals(1, checkCount, "合致しているLaucherは1件であること");
        assertNotNull(scheduledTask, "確実に代入されていること");

        if (scheduledTask.getTask() instanceof CronTask) {
            CronTask cronTask = (CronTask) scheduledTask.getTask(); // NOPMD

            // テストだと現在時間でなく過去(未来)日付で基準日付はOK
            LocalDateTime dateTime = LocalDateTime.of(2022, 2, 22, 12, 34, 18);

            // SpringBoot登録側
            Instant instant = dateTime.toInstant(ZoneOffset.UTC);
            Instant instantRegist = cronTask.getTrigger() // NOPMD
                    .nextExecution(new SimpleTriggerContext(instant, instant, instant));

            // コードから力ずくで取得して予測時間を計算
            String expression = readCsvBatchLauncher.getClass().getMethod("launchJob")
                    .getAnnotation(Scheduled.class).cron();
            Instant instantExpect = CronExpression.parse(expression).next(dateTime).toInstant(ZoneOffset.UTC);

            // 記載したコードの通りbatch側スケジュールに登録されている
            assertEquals(instantExpect, instantRegist, "ソースからのスケジュール値が実行環境に違いなく登録されている");

            // 起動したい時間通りに計算されている
            OffsetDateTime offsetDateTime = instantRegist.atOffset(ZoneOffset.UTC); // NOPMD

            assertEquals(2022, offsetDateTime.getYear(), "起動時間の確認(年)");
            assertEquals(2, offsetDateTime.getMonthValue(), "起動時間の確認(月)");
            assertEquals(22, offsetDateTime.getDayOfMonth(), "起動時間の確認(日)");
        
        }
    }

}


発生しがちな例外まとめ

豊富なStack経験(笑)をもとに例外集を作ってみました。

1.xxxxxConfiguration required a single bean, but (数字) were found:
→上記の@Qualifier指定忘れです
2. A job instance already exists and is complete for identifying parameters=xx
→バッチ実行条件が以前の実行条件と重なっています。出力ファイル名を変えるなどして重ならないようにしてください。どうしても変更できない場合はタイムスケジュールで紹介したJobParametersのようにgetJobParametersIncrementer()を使用するとIdが1づつ増えて重複しているとみなされなくなり、実行できるようになります。bootを停止すると実行Idは初期化されます。完全に新規のIdが割り当てられるまで、いままで実行した回数分だけ失敗試行を繰り返しますが、bootが落ちはしないので、できるようになるまで試行を繰り返して待つことになります。開発中でバッチ管理スキーマを好き放題いじれるなら、下記4に記載した管理テーブル初期化(trancate)するのがおすすめです。

        jobParameters = new JobParametersBuilder(readCsvJob.getJobParametersIncrementer().getNext(this.jobParameters)) // NOPMD
                .addString("readFileName", "c:/temp/person.csv").addString("writeFileName", "c:/temp/report3.csv")
                .toJobParameters();

3.LineMapper is requiredまたはAggregator is required
→ItemReaderまたはItemWriterに変換用クラスLineMapperまたはAggregator をセットしていない場合発生します。インスタンス作成が作成されSpring Bootが管理する時点で揃って(セットされて)いる必要があるので、実装方法に工夫が(コンストラクタの引数とするなど)必要となります。

4.org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0
→バッチのメタスキーマのうち、Seqテーブルに初期値が入っていないという不備があって、Seq番号が特定できず、1回目のバッチ実行の記録がうまくいっていません。公式にあるように、これはMySQLなどで、シーケンスが対応しておらず、Seqテーブルを別途作る必要があった環境で発生します。(必要な場合はすべてのバックアップを取り、すべてのテーブルをtruncaeteしたのち)下記SQLでSeqテーブルに初期値を設定します。

INSERT INTO BATCH_STEP_EXECUTION_SEQ values(0);
INSERT INTO BATCH_JOB_EXECUTION_SEQ values(0);
INSERT INTO BATCH_JOB_SEQ values(0);

あ、truncateは下記SQLで。

#MySQLの場合外部キーは生成または削除の2択のようなので、全体をでのチェックを一時停止のほうが妥当みたい
SET foreign_key_checks = 0; 
TRUNCATE TABLE BATCH_JOB_INSTANCE;
TRUNCATE TABLE BATCH_JOB_EXECUTION;
TRUNCATE TABLE BATCH_JOB_EXECUTION_PARAMS;
TRUNCATE TABLE BATCH_JOB_EXECUTION_CONTEXT;
TRUNCATE TABLE BATCH_STEP_EXECUTION ;
TRUNCATE TABLE BATCH_STEP_EXECUTION ;
TRUNCATE TABLE BATCH_STEP_EXECUTION_CONTEXT ;

TRUNCATE TABLE BATCH_STEP_EXECUTION_SEQ ;
TRUNCATE TABLE BATCH_JOB_EXECUTION_SEQ ;
TRUNCATE TABLE BATCH_JOB_SEQ ;

SET foreign_key_checks = 1;

順序が逆ですが、この両方のSQLを走らせると、Spring Batchのバッチ管理スキーマを初期化する処理を行うことになります。 Incorrect result…でググるとトランザクションが…とかいう記事が出ますが、ここでは全く関係ありません。@Transactionalなどと書こうものなら「Batchでトランザクション使っているんだから2重にトランザクションしないで!」と怒られます。恐ろしいことにはこの原因による例外にはバリエーションがあり、
Cannot find any job execution for job instance: JobInstance: id=(数字), version=0, Job=
←何度かトライしてデータベースの書き込みがすべて完了していなくて中断していることによる不整合
があります。

5.テスト時にWARNING: A Java agent has been loaded dynamically (bytebuddy\byte-buddy-agent\1.14.16\byte-buddy-agent-1.14.16.jar)と赤字で表示される。動いているけどなんか気持ち悪い
→起動引数に「-XX:+EnableDynamicAgentLoading」を追加してくださいとのこと

場所はテストのクラスの上で右クリック、「Run AS」-「Run Configuration」-「Arguments」-「VM Arguments」。

Eclipse Run Configure

ここだとテストすべてに1回1回付与しないといけないが、Eclipseの設定xxをなんとかすると実行環境全体に付与できる予感がひしひしと(苦笑)


いいなと思ったら応援しよう!