1. 引言

Spring Batch 和普通的 Spring 应用不同,它的测试有其特殊性——主要源于作业(Job)通常是异步执行的,这种非即时反馈的机制让验证逻辑变得更复杂。

本文将系统性地介绍如何对 Spring Batch 作业进行测试,涵盖从端到端集成测试到单个组件的单元验证。内容适合已有 Spring Batch 使用经验的开发者,避免啰嗦基础概念,直奔实战要点。

2. 依赖配置

我们使用 spring-boot-starter-batch 快速搭建环境,以下是测试所需的核心依赖:

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

✅ 关键说明:

  • spring-boot-starter-test 提供了通用测试支持(如 JUnit、Mockito)
  • spring-batch-test 是重点,它提供了 JobLauncherTestUtilsAssertFile 等专用工具类,能大幅简化 Batch 测试流程

⚠️ 踩坑提示:如果漏掉 spring-batch-test,你会发现很多测试类(比如 StepScopeTestUtils)根本找不到,别问我是怎么知道的。

3. 定义 Spring Batch 作业

我们以一个处理图书数据的简单批处理任务为例:读取 CSV 文件,拆分为 Book 和 BookDetail 两部分输出。

3.1 作业步骤定义

整个 Job 包含两个 Step,分别处理不同的输出目标:

@Bean
public Step step1(
  ItemReader<BookRecord> csvItemReader, ItemWriter<Book> jsonItemWriter) throws IOException {
    return stepBuilderFactory
      .get("step1")
      .<BookRecord, Book> chunk(3)
      .reader(csvItemReader)
      .processor(bookItemProcessor())
      .writer(jsonItemWriter)
      .build();
}

@Bean
public Step step2(
  ItemReader<BookRecord> csvItemReader, ItemWriter<BookDetails> listItemWriter) {
    return stepBuilderFactory
      .get("step2")
      .<BookRecord, BookDetails> chunk(3)
      .reader(csvItemReader)
      .processor(bookDetailsItemProcessor())
      .writer(listItemWriter)
      .build();
}

📌 要点:

  • 使用 .chunk(3) 表示每 3 条记录提交一次事务
  • step1 输出为 JSON 文件,step2 输出到内存列表(用于后续验证)

3.2 输入读取器与输出写入器

CSV 输入读取器(FlatFileItemReader)

private static final String[] TOKENS = { 
  "bookname", "bookauthor", "bookformat", "isbn", "publishyear" };

@Bean
@StepScope
public FlatFileItemReader<BookRecord> csvItemReader(
  @Value("#{jobParameters['file.input']}") String input) {
    FlatFileItemReaderBuilder<BookRecord> builder = new FlatFileItemReaderBuilder<>();
    FieldSetMapper<BookRecord> bookRecordFieldSetMapper = new BookRecordFieldSetMapper();
    return builder
      .name("bookRecordItemReader")
      .resource(new FileSystemResource(input))
      .delimited()
      .names(TOKENS)
      .fieldSetMapper(bookRecordFieldSetMapper)
      .build();
}

⚠️ 关键设计:

  • 使用 @StepScope 注解,使该 Bean 的生命周期与 StepExecution 绑定
  • 支持通过 jobParameters['file.input'] 动态注入输入文件路径,极大提升测试灵活性

JSON 输出写入器(JsonFileItemWriter)

@Bean
@StepScope
public JsonFileItemWriter<Book> jsonItemWriter(
  @Value("#{jobParameters['file.output']}") String output) throws IOException {
    JsonFileItemWriterBuilder<Book> builder = new JsonFileItemWriterBuilder<>();
    JacksonJsonObjectMarshaller<Book> marshaller = new JacksonJsonObjectMarshaller<>();
    return builder
      .name("bookItemWriter")
      .jsonObjectMarshaller(marshaller)
      .resource(new FileSystemResource(output))
      .build();
}

📌 说明:

  • 同样使用 @StepScope,便于测试时替换输出路径
  • 第二个 Step 使用 ListItemWriter,直接将数据写入内存 List,方便断言验证

3.3 自定义 JobLauncher

为避免 Spring Boot 自动启动 Job,我们在 application.properties 中关闭默认行为:

spring.batch.job.enabled=false

然后通过自定义 CommandLineRunner 手动触发 Job:

@SpringBootApplication
public class SpringBatchApplication implements CommandLineRunner {

    @Autowired
    private JobLauncher jobLauncher;

    @Autowired
    private Job transformBooksRecordsJob;

    @Value("${file.input}")
    private String input;

    @Value("${file.output}")
    private String output;

    @Override
    public void run(String... args) throws Exception {
        JobParametersBuilder paramsBuilder = new JobParametersBuilder();
        paramsBuilder.addString("file.input", input);
        paramsBuilder.addString("file.output", output);
        jobLauncher.run(transformBooksRecordsJob, paramsBuilder.toJobParameters());
   }

   public static void main(String[] args) {
       SpringApplication.run(SpringBatchApplication.class, args);
   }
}

✅ 优势:完全掌控 Job 启动时机和参数传递,测试时可自由构造 JobParameters。

4. Spring Batch 测试实践

spring-batch-test 提供了强大的测试支持,合理使用能事半功倍。

4.1 测试基类配置

@RunWith(SpringRunner.class)
@SpringBatchTest
@EnableAutoConfiguration
@ContextConfiguration(classes = { SpringBatchConfiguration.class })
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, 
  DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
public class SpringBatchIntegrationTest {

    private static final String TEST_INPUT = "src/test/resources/input.csv";
    private static final String TEST_OUTPUT = "target/test-outputs/output.json";
    private static final String EXPECTED_OUTPUT = "src/test/resources/expected.json";

    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;
  
    @Autowired
    private JobRepositoryTestUtils jobRepositoryTestUtils;
  
    @After
    public void cleanUp() {
        jobRepositoryTestUtils.removeJobExecutions();
    }

    private JobParameters defaultJobParameters() {
        JobParametersBuilder paramsBuilder = new JobParametersBuilder();
        paramsBuilder.addString("file.input", TEST_INPUT);
        paramsBuilder.addString("file.output", TEST_OUTPUT);
        return paramsBuilder.toJobParameters();
   }
}

📌 核心组件说明:

组件 作用
@SpringBatchTest 自动注入测试工具类,简化配置
JobLauncherTestUtils 用于启动 Job 或 Step
JobRepositoryTestUtils 清理 JobExecution 记录,避免测试污染
@DirtiesContext 多测试类运行时防止 JobRepository 冲突

⚠️ 踩坑提示:如果不调用 removeJobExecutions(),多个测试可能因 JobInstance 重复而失败。

4.2 端到端 Job 测试

验证整个 Job 是否正常执行并生成预期输出:

@Test
public void givenReferenceOutput_whenJobExecuted_thenSuccess() throws Exception {
    // given
    FileSystemResource expectedResult = new FileSystemResource(EXPECTED_OUTPUT);
    FileSystemResource actualResult = new FileSystemResource(TEST_OUTPUT);

    // when
    JobExecution jobExecution = jobLauncherTestUtils.launchJob(defaultJobParameters());
    JobInstance actualJobInstance = jobExecution.getJobInstance();
    ExitStatus actualJobExitStatus = jobExecution.getExitStatus();
  
    // then
    assertThat(actualJobInstance.getJobName(), is("transformBooksRecords"));
    assertThat(actualJobExitStatus.getExitCode(), is("COMPLETED"));
    AssertFile.assertFileEquals(expectedResult, actualResult);
}

AssertFile.assertFileEquals()spring-batch-test 提供的实用方法,用于精确比对文件内容,简单粗暴有效。

4.3 单个 Step 测试

对于复杂 Job,没必要每次都跑完整流程。可以只测试特定 Step:

@Test
public void givenReferenceOutput_whenStep1Executed_thenSuccess() throws Exception {
    // given
    FileSystemResource expectedResult = new FileSystemResource(EXPECTED_OUTPUT);
    FileSystemResource actualResult = new FileSystemResource(TEST_OUTPUT);

    // when
    JobExecution jobExecution = jobLauncherTestUtils.launchStep("step1", defaultJobParameters()); 
    Collection<StepExecution> actualStepExecutions = jobExecution.getStepExecutions();
    ExitStatus actualJobExitStatus = jobExecution.getExitStatus();

    // then
    assertThat(actualStepExecutions.size(), is(1));
    assertThat(actualJobExitStatus.getExitCode(), is("COMPLETED"));
    AssertFile.assertFileEquals(expectedResult, actualResult);
}

@Test
public void whenStep2Executed_thenSuccess() {
    // when
    JobExecution jobExecution = jobLauncherTestUtils.launchStep("step2", defaultJobParameters());
    Collection<StepExecution> actualStepExecutions = jobExecution.getStepExecutions();
    ExitStatus actualExitStatus = jobExecution.getExitStatus();

    // then
    assertThat(actualStepExecutions.size(), is(1));
    assertThat(actualExitStatus.getExitCode(), is("COMPLETED"));
    actualStepExecutions.forEach(stepExecution -> {
        assertThat(stepExecution.getWriteCount(), is(8));
    });
}

📌 差异点:

  • 使用 launchStep("stepName") 直接运行指定 Step
  • step1 测试文件输出,step2 测试写入数量(因输出到内存 List)

4.4 Step 作用域组件测试

@StepScope 的 Bean 无法直接 Autowire,需借助 StepScopeTestUtils 模拟执行环境:

测试 FlatFileItemReader

@Autowired
private FlatFileItemReader<BookRecord> itemReader;

@Test
public void givenMockedStep_whenReaderCalled_thenSuccess() throws Exception {
    // given
    StepExecution stepExecution = MetaDataInstanceFactory
      .createStepExecution(defaultJobParameters());

    // when
    StepScopeTestUtils.doInStepScope(stepExecution, () -> {
        BookRecord bookRecord;
        itemReader.open(stepExecution.getExecutionContext());
        while ((bookRecord = itemReader.read()) != null) {

            // then
            assertThat(bookRecord.getBookName(), is("Foundation"));
            assertThat(bookRecord.getBookAuthor(), is("Asimov I."));
            assertThat(bookRecord.getBookISBN(), is("ISBN 12839"));
            assertThat(bookRecord.getBookFormat(), is("hardcover"));
            assertThat(bookRecord.getPublishingYear(), is("2018"));
        }
        itemReader.close();
        return null;
    });
}

测试 JsonFileItemWriter

@Test
public void givenMockedStep_whenWriterCalled_thenSuccess() throws Exception {
    // given
    FileSystemResource expectedResult = new FileSystemResource(EXPECTED_OUTPUT_ONE);
    FileSystemResource actualResult = new FileSystemResource(TEST_OUTPUT);
    Book demoBook = new Book();
    demoBook.setAuthor("Grisham J.");
    demoBook.setName("The Firm");
    StepExecution stepExecution = MetaDataInstanceFactory
      .createStepExecution(defaultJobParameters());

    // when
    StepScopeTestUtils.doInStepScope(stepExecution, () -> {
        jsonItemWriter.open(stepExecution.getExecutionContext());
        jsonItemWriter.write(Arrays.asList(demoBook));
        jsonItemWriter.close();
        return null;
    });

    // then
    AssertFile.assertFileEquals(expectedResult, actualResult);
}

⚠️ 注意事项:

  • 必须手动调用 open()close() 管理资源
  • MetaDataInstanceFactory 用于创建模拟的 StepExecution 上下文

5. 总结

Spring Batch 的测试策略应分层进行:

端到端测试:验证整体流程是否符合预期,适合回归测试
Step 级测试:定位问题更快,适合复杂 Job 的模块化验证
组件级测试:使用 StepScopeTestUtils 模拟上下文,精准测试 @StepScope Bean

合理利用 spring-batch-test 提供的工具类,可以避免大量样板代码,让测试更简洁可靠。

完整示例代码已托管至 GitHub:https://github.com/your-repo/spring-batch-testing-demo


原始标题:Testing a Spring Batch Job