Spring Batch Testing: JobLauncherTestUtils, Step-Scoped Beans, and Chunk Processing
Batch jobs process the most sensitive and voluminous data in many enterprise applications — billing runs, ETL pipelines, report generation, data migrations. A bug that skips a record or miscalculates a value can affect thousands of rows before anyone notices. Yet batch jobs are among the least-tested parts of most codebases, partly because developers aren't sure how to test them systematically.
Spring Batch's test support addresses this with a set of utilities that let you launch jobs and individual steps in isolation, test step-scoped beans without a running job, and assert on job execution outcomes with precision. This guide covers the full toolkit.
Setting Up @SpringBatchTest
The @SpringBatchTest annotation (introduced in Spring Batch 4.1) auto-configures the most important testing utilities:
JobLauncherTestUtils— launches jobs and individual steps in testsJobRepositoryTestUtils— creates and cleans upJobExecutioninstances in theJobRepository
Add the dependency:
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>A basic test class looks like this:
@SpringBatchTest
@SpringBootTest
class ReportGenerationJobTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired
private Job reportGenerationJob;
@AfterEach
void cleanUp() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void jobCompletesSuccessfully() throws Exception {
jobLauncherTestUtils.setJob(reportGenerationJob);
JobExecution execution = jobLauncherTestUtils.launchJob();
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(execution.getExitStatus().getExitCode()).isEqualTo("COMPLETED");
}
}The @AfterEach cleanup is important: Spring Batch prevents re-running a job with identical parameters if it already completed successfully. Cleaning up after each test ensures a fresh state.
Testing with Job Parameters
Job parameters affect job behavior and are often used to scope a batch run to a specific date range, tenant, or file path. Test the full range of parameter combinations:
@SpringBatchTest
@SpringBootTest
class InvoiceProcessingJobTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired
private Job invoiceProcessingJob;
@AfterEach
void cleanUp() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void processesInvoicesForGivenDateRange() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("startDate", "2024-01-01")
.addString("endDate", "2024-01-31")
.addLong("runId", System.currentTimeMillis()) // uniqueness parameter
.toJobParameters();
jobLauncherTestUtils.setJob(invoiceProcessingJob);
JobExecution execution = jobLauncherTestUtils.launchJob(params);
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
// Assert on step execution summary
StepExecution stepExecution = getStepExecution(execution, "processInvoicesStep");
assertThat(stepExecution.getReadCount()).isEqualTo(45);
assertThat(stepExecution.getWriteCount()).isEqualTo(45);
assertThat(stepExecution.getSkipCount()).isEqualTo(0);
}
@Test
void jobFailsWhenEndDateBeforeStartDate() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("startDate", "2024-01-31")
.addString("endDate", "2024-01-01")
.addLong("runId", System.currentTimeMillis())
.toJobParameters();
jobLauncherTestUtils.setJob(invoiceProcessingJob);
JobExecution execution = jobLauncherTestUtils.launchJob(params);
assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED);
assertThat(execution.getExitStatus().getExitDescription())
.contains("endDate must be after startDate");
}
private StepExecution getStepExecution(JobExecution execution, String stepName) {
return execution.getStepExecutions().stream()
.filter(se -> se.getStepName().equals(stepName))
.findFirst()
.orElseThrow(() -> new AssertionError("Step not found: " + stepName));
}
}Testing Individual Steps
Testing an entire job for every scenario is slow and can make failures hard to diagnose. JobLauncherTestUtils.launchStep() lets you run a single step in isolation:
@SpringBatchTest
@SpringBootTest
class ImportCustomerStepTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired
private CustomerRepository customerRepository;
@AfterEach
void cleanUp() {
jobRepositoryTestUtils.removeJobExecutions();
customerRepository.deleteAll();
}
@Test
void importCustomerStep_persistsAllValidRecords() throws Exception {
// Copy a test CSV file to the expected input location
Path testFile = Path.of("src/test/resources/test-customers.csv");
Files.copy(testFile, Path.of("/tmp/customers-import.csv"),
StandardCopyOption.REPLACE_EXISTING);
JobExecution execution = jobLauncherTestUtils.launchStep("importCustomerStep");
StepExecution stepExecution = getStepExecution(execution, "importCustomerStep");
assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(stepExecution.getReadCount()).isEqualTo(100);
assertThat(stepExecution.getWriteCount()).isEqualTo(98);
assertThat(stepExecution.getSkipCount()).isEqualTo(2); // 2 invalid records skipped
assertThat(customerRepository.count()).isEqualTo(98);
}
@Test
void importCustomerStep_populatesExecutionContext() throws Exception {
jobLauncherTestUtils.launchStep("importCustomerStep");
JobExecution jobExecution = jobLauncherTestUtils.getJobExecution();
ExecutionContext executionContext = jobExecution
.getStepExecutions().iterator().next().getExecutionContext();
assertThat(executionContext.containsKey("importedCount")).isTrue();
assertThat(executionContext.getLong("importedCount")).isEqualTo(98L);
}
}Testing Step-Scoped Beans with StepScopeTestUtils
@StepScope beans are created fresh for each step execution and can't be instantiated directly in a test — they require an active step execution scope. StepScopeTestUtils solves this by simulating a step execution context:
@SpringBatchTest
@SpringBootTest
class CustomerItemReaderTest {
@Autowired
private ItemReader<Customer> customerItemReader; // @StepScope bean
@Test
void readerReturnsAllCustomers() throws Exception {
StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(
new JobParametersBuilder()
.addString("filePath", "src/test/resources/test-customers.csv")
.toJobParameters()
);
StepScopeTestUtils.doInStepScope(stepExecution, () -> {
List<Customer> customers = new ArrayList<>();
Customer customer;
while ((customer = customerItemReader.read()) != null) {
customers.add(customer);
}
assertThat(customers).hasSize(5);
assertThat(customers.get(0).getEmail()).isEqualTo("alice@example.com");
assertThat(customers).extracting(Customer::getName)
.doesNotContainNull();
return null;
});
}
}Using @StepScope in Tests Directly
Spring Batch 4.x also supports activating step scope via test execution listeners. Annotate the test class and use MetaDataInstanceFactory to create mock step executions:
@SpringBatchTest
@SpringBootTest
class FlatFileItemReaderTest {
@Autowired
private FlatFileItemReader<Customer> customerReader;
public StepExecution getStepExecution() {
return MetaDataInstanceFactory.createStepExecution(
new JobParametersBuilder()
.addString("input.file", "classpath:test-customers.csv")
.toJobParameters()
);
}
@Test
void readerParsesHeaderCorrectly() throws Exception {
StepScopeTestUtils.doInStepScope(getStepExecution(), () -> {
customerReader.open(new ExecutionContext());
Customer first = customerReader.read();
assertThat(first).isNotNull();
assertThat(first.getId()).isEqualTo(1L);
assertThat(first.getName()).isEqualTo("Alice Smith");
assertThat(first.getEmail()).isEqualTo("alice@example.com");
customerReader.close();
return null;
});
}
}Testing ItemProcessor Logic
ItemProcessor implementations often contain your core business logic — transformations, validations, enrichments. Test them as plain unit tests first, then test them in the step context:
// Unit test — fast, no Spring context
class CustomerEnrichmentProcessorTest {
private final CustomerEnrichmentProcessor processor = new CustomerEnrichmentProcessor(
new MockGeolocationService()
);
@Test
void processor_enrichesCustomerWithCountryFromIp() throws Exception {
Customer input = new Customer("alice", "192.168.1.1");
EnrichedCustomer output = processor.process(input);
assertThat(output).isNotNull();
assertThat(output.getName()).isEqualTo("alice");
assertThat(output.getCountry()).isEqualTo("US");
}
@Test
void processor_returnsNullForBlacklistedEmails() throws Exception {
Customer blacklisted = new Customer("spammer", "spam@blacklisted.com");
EnrichedCustomer output = processor.process(blacklisted);
assertThat(output).isNull(); // filtered out
}
@Test
void processor_handlesNullIpAddressGracefully() throws Exception {
Customer noIp = new Customer("bob", null);
EnrichedCustomer output = processor.process(noIp);
assertThat(output).isNotNull();
assertThat(output.getCountry()).isEqualTo("UNKNOWN");
}
}Testing Chunk Processing End-to-End
For integration tests of chunk-oriented steps, create controlled test data and assert on exact read/write/skip counts:
@SpringBatchTest
@SpringBootTest
@Sql("/test-data/insert-raw-transactions.sql")
class TransactionProcessingStepTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired
private ProcessedTransactionRepository processedRepo;
@AfterEach
void cleanUp() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void chunkStep_processesAllValidTransactions() throws Exception {
// SQL file inserts 200 raw transactions: 180 valid, 20 with invalid amounts
JobExecution execution = jobLauncherTestUtils.launchStep("processTransactionsStep");
StepExecution step = execution.getStepExecutions().iterator().next();
assertThat(step.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(step.getReadCount()).isEqualTo(200);
assertThat(step.getProcessCount()).isEqualTo(180); // invalid ones filtered by processor
assertThat(step.getWriteCount()).isEqualTo(180);
assertThat(step.getSkipCount()).isEqualTo(0);
assertThat(step.getFilterCount()).isEqualTo(20); // processor returned null for 20
assertThat(processedRepo.count()).isEqualTo(180);
}
@Test
void chunkStep_skipsAndContinuesOnDataIntegrityViolation() throws Exception {
// Inject one duplicate transaction that will cause a constraint violation on write
JobExecution execution = jobLauncherTestUtils.launchStep("processTransactionsStep");
StepExecution step = execution.getStepExecutions().iterator().next();
// With skip policy configured, step should still complete
assertThat(step.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(step.getSkipCount()).isGreaterThan(0);
}
}Testing Listeners
Job and step listeners coordinate cross-cutting concerns like notifications, metrics, and auditing. Test them with mocks:
@SpringBatchTest
@SpringBootTest
class JobNotificationListenerTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@MockBean
private NotificationService notificationService;
@Test
void listener_sendsSuccessNotificationOnCompletion() throws Exception {
jobLauncherTestUtils.launchJob();
verify(notificationService, times(1))
.sendJobCompletionNotification(
argThat(notification ->
notification.getStatus().equals("COMPLETED") &&
notification.getJobName().equals("reportGenerationJob")
)
);
}
@Test
void listener_sendsAlertOnJobFailure() throws Exception {
// Configure job to fail (e.g., point reader at non-existent file)
JobParameters failingParams = new JobParametersBuilder()
.addString("inputFile", "/non/existent/file.csv")
.addLong("runId", System.currentTimeMillis())
.toJobParameters();
jobLauncherTestUtils.launchJob(failingParams);
verify(notificationService, times(1))
.sendJobFailureAlert(any(JobExecution.class));
}
}Asserting on BatchStatus vs ExitStatus
These two are distinct and both matter. BatchStatus is Spring Batch's internal state machine status. ExitStatus is what's reported to the scheduler and can be customized:
@Test
void jobWithSkips_hasCompletedBatchStatusButCustomExitCode() throws Exception {
JobExecution execution = jobLauncherTestUtils.launchJob();
// BatchStatus is COMPLETED — the job ran to the end
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
// ExitStatus has a custom code indicating partial completion
assertThat(execution.getExitStatus().getExitCode()).isEqualTo("COMPLETED_WITH_SKIPS");
// Check step-level exit status separately
StepExecution step = getStepExecution(execution, "importStep");
assertThat(step.getExitStatus().getExitCode()).isEqualTo("COMPLETED_WITH_SKIPS");
}Preventing Regressions in Batch Jobs
Batch jobs run on schedules, often at night, and failures may not be noticed for hours. A job that processed 10,000 records yesterday but only 9,800 today — with no error — is a silent data quality problem.
HelpMeTest lets you run your Spring Batch test suite as part of every deployment pipeline and on a recurring schedule, alerting your team the moment a step count, skip count, or exit status deviates from expected behavior. Continuous monitoring for batch jobs means you catch data processing regressions before your downstream systems — and your customers — do.
Pair thorough @SpringBatchTest coverage with automated monitoring, and your batch pipelines become a reliably verified part of your system rather than a black box that runs in the dark.