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
是重点,它提供了JobLauncherTestUtils
、AssertFile
等专用工具类,能大幅简化 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