Spring Batch Testing: JobLauncherTestUtils, Step-Scoped Beans, and Chunk Processing

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 tests
  • JobRepositoryTestUtils — creates and cleans up JobExecution instances in the JobRepository

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.

Read more