SpringBatchにおける結合テスト

2020-08-01T09:14:57

SpringBatchにおける結合テスト

やること

前の記事でWireMockを使った結合テストに関しては紹介した。なので、今回はいろんなテストライブラリをシンプルに組み合わせたテストのサンプルを作ってみたので書き留めておく。

リポジトリは下記にある。
https://github.com/angelica-keiskei/java-batch-sample

主役はSpringBatchである。このBatchはDBにあるタスクテーブルを逐次とってきては、それを黙々と実行するたぐいのもので、必ず何らかのAPIを呼ぶことを想定している。

今回サンプルとして用意したものはなにかのキャンセルAPIであり、バッチは最終的にキャンセルAPIを実行してその結果でDBを更新する。

テストしづらいもの

これらの処理は、単純に単体テストライブラリだけで検証しても要件を完全にカバーすることは難しい。

  • Batchの挙動は単体テストライブラリで単純に検証することができない
    • spring-batch-testがある。しかも使い方はシンプル
  • DBのRead/WriteはMockで表現するには限度がある
    • dbunit がある。今回は汎用的なCSVによるデータ入力を紹介するが、コードベースでも色々かけるのでぜひ調べてみてほしい。
  • 実際にはいろんなAPIを呼ぶかもしれないが、テスト対象でもないのに動かすためだけにMockにするのは煩わしい。

サンプルのアプリケーションについて

かんたんに今回のサンプルに関して図示を行う。

SpringBatchについて

SpringBatchの要点は、その処理フローである。

  • readerが処理するデータを取得する
  • processorがreaderの取得したデータをもとに、DBに書き込むためのデータを作成する
  • writerはprocessorの作ったデータをDBに書き込む

上記が簡略化したSpringBatchの動作であり、思想になっている。

今回のサンプルアプリケーションでは、DBにタスクが登録されていることが前提になっている。
つまり、別のアプリケーションがタスクをDBに”CREATED”のステータスで書き込むと、Cronなどで実行されるであろうこのBatchのReaderがそれを拾ってくれるというわけだ。

そして、Processorは取得したタスクの情報をもとに、APIを叩きに行く。そのレスポンスが成功、失敗、エラー(APIを叩くための情報が足りないとか)によってタスクの状態を変更する。

すなわち、成功すればCOMPLETE、失敗すればRUNNING、エラーとなればFAULTEDとなる。
ちなみに、タスク実行回数をカウントしており、20回に達するとRUNNINGのまま再実行されなくなる。

まあ実に古典的なバッチ処理のサンプルである。

結合テストの要点

今回のテーマは結合テスト…性格には内部結合テストの自動化であるので、それに関して説明する。サンプルアプリケーション唯一のテストである下記の理解を目標にする。

https://github.com/angelica-keiskei/java-batch-sample/blob/master/src/test/java/chalkboard/me/recovery_batch/integration/RecoveryTaskForBenefitTests.java

package chalkboard.me.recovery_batch.integration;

import chalkboard.me.recovery_batch.config.WireMockInitializer;
import chalkboard.me.recovery_batch.domain.read.recoverytask.RecoveryStatus;
import com.github.tomakehurst.wiremock.WireMockServer;
import javax.sql.DataSource;
import org.dbunit.DataSourceDatabaseTester;
import org.dbunit.operation.DatabaseOperation;
import org.dbunit.util.fileloader.CsvDataFileLoader;
import org.junit.Assert;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.ResourceUtils;

import static org.junit.Assert.fail;

@ActiveProfiles("test")
@SpringBootTest
@SpringBatchTest
@ExtendWith(SpringExtension.class)
@ContextConfiguration(initializers = {WireMockInitializer.class})
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@DisplayName("リカバリバッチ 統合テスト")
public class RecoveryTaskForBenefitTests {
    @Autowired
    private WireMockServer wireMockServer;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private DataSourceDatabaseTester dataSourceDatabaseTester; // dbunit
    private CsvDataFileLoader csvDataFileLoader;

    private final String testDataPath = "classpath:chalkboard/me/recovery_batch/integration/benefit/";

    @BeforeEach
    public void setup() {
        dataSourceDatabaseTester = new DataSourceDatabaseTester(dataSource);
        dataSourceDatabaseTester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
        csvDataFileLoader = new CsvDataFileLoader();
        try{
            dataSourceDatabaseTester.setDataSet(csvDataFileLoader.loadDataSet(ResourceUtils.getURL(testDataPath)));
            dataSourceDatabaseTester.onSetup();
        } catch (Exception e) {
            fail();
        }
    }

    @AfterEach
    public void afterEach() {
        this.wireMockServer.resetAll();
    }

    @Test
    public void 正常系_リカバリを実行する() {
        int beforeCountCreated = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.CREATED), Integer.class);
        int beforeCountRunning = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.RUNNING), Integer.class);
        JobExecution jobExecution = jobLauncherTestUtils.launchStep("recoveryStep");

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

        int afterCountCreated = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.CREATED), Integer.class);
        int afterCountCompleted = jdbcTemplate.queryForObject(taskStatusCompletedCountSQL(), Integer.class);

        Assert.assertEquals(beforeCountCreated, 1); // (CREATED OR RUNNNING) AND COUNT19以下しか取得しない
        Assert.assertEquals(beforeCountRunning, 2);
        Assert.assertEquals(afterCountCreated, 0);  // batchが実行されたらCREATEDはなくなる
        Assert.assertEquals(afterCountCompleted, 4); // もとから完了していたもの + ↑の実行結果で増えたもの. COUNT20以上は実行されない.
    }


    private String taskStatusCountSQL(RecoveryStatus status) {
        return "SELECT COUNT(ID) FROM RECOVERY_TASKS WHERE COUNT < 20 AND STATUS = '"
                + status.name() + "';";
    }

    private String taskStatusCompletedCountSQL() {
        return "SELECT COUNT(ID) FROM RECOVERY_TASKS WHERE STATUS = 'COMPLETION'";
    }
}

ライブラリ

jUnit以外のテストライブラリについて触れておく

  • 38行目: Wiremock
    • 前回使用した、Mockサーバをかんたんに実行するためのライブラリ。予め用意したJsonを読み込ませて使用することで、 正常系のMockを省略できる。ここでは、APIの呼び出しを正常に動いたものとして省略させるために使用している。
      例外パターンは前回記事で触れているのでそちらを参照。
  • 49,50行目: dbUnit
    • データベースをテストするための単体テストライブラリ。ここでは、52行目で定義されるCSVをロードしてテスト時のDBとして反映させるために使用する。
      これを利用することで、テストケースごとに様々なDBの状態を再現することができるため、無闇矢鱈なリポジトリ層のMockを書かなくて良くなる。
    • 利用DBはオンメモリのH2である。本番環境と完全一致にはならないだろうから、そこは注意が必要
  • 44行目: SpringBatchTest
    • 流石に公式のテストツールだけあって、Batchのテストはとてもシンプルに実行できる。
    • launchStepを呼ぶだけで、該当タスクを実行してくれている。

アノテーション

@ActiveProfiles("test")
@SpringBootTest
@SpringBatchTest
@ExtendWith(SpringExtension.class)
@ContextConfiguration(initializers = {WireMockInitializer.class})
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@DisplayName("リカバリバッチ 統合テスト")

ポイントはSpringBootTestとSpringBatchTest両方使っているところだろう。今回はWebサーバも利用するのでBatchの要素だけでなくSpringBootの仕組みも併用している。ActiveProfilesはテスト用リソースを指定するために使っている。

ContextConfigurationはWiremockの初期化を行うために利用する。前回の記事が詳しい。

setup

    @BeforeEach
    public void setup() {
        dataSourceDatabaseTester = new DataSourceDatabaseTester(dataSource);
        dataSourceDatabaseTester.setSetUpOperation(DatabaseOperation.CLEAN_INSERT);
        csvDataFileLoader = new CsvDataFileLoader();
        try{
            dataSourceDatabaseTester.setDataSet(csvDataFileLoader.loadDataSet(ResourceUtils.getURL(testDataPath)));
            dataSourceDatabaseTester.onSetup();
        } catch (Exception e) {
            fail();
        }
    }

このテスト用に用意したCSVファイルを使ってDBの初期化を行っている。try-catchをいちいちしないといけない点は、spock(groovy)に対するデメリットだなという感じ。

Test

    @Test
    public void 正常系_リカバリを実行する() {
        int beforeCountCreated = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.CREATED), Integer.class);
        int beforeCountRunning = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.RUNNING), Integer.class);
        JobExecution jobExecution = jobLauncherTestUtils.launchStep("recoveryStep");
        Assert.assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
        int afterCountCreated = jdbcTemplate.queryForObject(taskStatusCountSQL(RecoveryStatus.CREATED), Integer.class);
        int afterCountCompleted = jdbcTemplate.queryForObject(taskStatusCompletedCountSQL(), Integer.class);
        Assert.assertEquals(beforeCountCreated, 1); // (CREATED OR RUNNNING) AND COUNT19以下しか取得しない
        Assert.assertEquals(beforeCountRunning, 2);
        Assert.assertEquals(afterCountCreated, 0);  // batchが実行されたらCREATEDはなくなる
        Assert.assertEquals(afterCountCompleted, 4); // もとから完了していたもの + ↑の実行結果で増えたもの. COUNT20以上は実行されない.
    }

jdbcTemplateをつかえばそのタイミングでSQLを実行できる。タスク実行前後でSQLを発行し、状態を確認することでDBの変化に関して単体テストで検証することが可能になる。