Testing batch jobs

This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

Batch jobs are testable at three levels: a component in isolation, a single step, and the whole job. The spring-batch-test module supplies the fixtures for all three.

JUnit 4 support was removed in Spring Batch 6.0 — tests use JUnit Jupiter (JUnit 5) only. The old JobLauncherTestUtils name has likewise given way to JobOperatorTestUtils, matching the unified JobOperator (Infrastructure configuration).

<dependency>
    <groupId>org.springframework.batch</groupId>
    <artifactId>spring-batch-test</artifactId>
    <scope>test</scope>
</dependency>

@SpringBatchTest

@SpringBatchTest registers the test utilities as beans and adds the scope test listeners, so a test class can inject them directly. Combine it with @SpringJUnitConfig (plain Spring) or @SpringBootTest (Boot):

@SpringBatchTest
@SpringJUnitConfig({ BatchTestConfiguration.class, EndOfDayJobConfiguration.class })
class EndOfDayJobTests {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    @Autowired
    private JobRepositoryTestUtils jobRepositoryTestUtils;

    @AfterEach
    void clearMetadata() {
        jobRepositoryTestUtils.removeJobExecutions();   // a clean repository per test
    }
}

A test configuration that uses the resourceless infrastructure needs no database at all, which keeps unit tests fast; an in-memory H2 (or Testcontainers) is used when the metadata itself is under test.

JobOperatorTestUtils

Running a whole job

@Test
void loadsEveryTradeAndCompletes() throws Exception {
    JobParameters parameters = new JobParametersBuilder()
            .addString("input.file", "src/test/resources/trades.csv")
            .addLocalDate("run.date", LocalDate.of(2026, 1, 31))
            .toJobParameters();

    JobExecution execution = jobOperatorTestUtils.startJob(parameters);

    assertEquals(BatchStatus.COMPLETED, execution.getStatus());
    assertEquals(ExitStatus.COMPLETED.getExitCode(), execution.getExitStatus().getExitCode());

    StepExecution loadStep = execution.getStepExecutions().stream()
            .filter(step -> step.getStepName().equals("loadTradesStep"))
            .findFirst()
            .orElseThrow();

    assertEquals(1_000, loadStep.getReadCount());
    assertEquals(998, loadStep.getWriteCount());
    assertEquals(2, loadStep.getFilterCount());
    assertEquals(0, loadStep.getSkipCount());
}

Asserting on the counters is what makes a batch test meaningful — "it completed" says nothing about whether it processed anything (Steps, executions & the ExecutionContext).

Running a single step

startStep(name) runs one step in isolation, which keeps a test focused and fast:

@Test
void rejectsRowsWithANegativePrice() {
    JobExecution execution = jobOperatorTestUtils.startStep("loadTradesStep");

    StepExecution stepExecution = execution.getStepExecutions().iterator().next();
    assertEquals(BatchStatus.COMPLETED, stepExecution.getStatus());
    assertEquals(3, stepExecution.getProcessSkipCount());
}

startStep(name, jobParameters, executionContext) supplies parameters and a seeded ExecutionContext — but note that this third argument seeds the job execution context (the parameter is named jobExecutionContext, and the entries are copied in a beforeJob listener), not the step’s. A reader bound to #{stepExecutionContext['minId']} therefore sees nothing:

@Test
void workerStepReadsTheSliceFromTheJobContext() {
    ExecutionContext context = new ExecutionContext();
    context.putLong("minId", 1L);
    context.putLong("maxId", 100L);

    JobExecution execution = jobOperatorTestUtils.startStep(
            "workerStep", new JobParameters(), context);

    assertEquals(100, execution.getStepExecutions().iterator().next().getReadCount());
}

This works only if the worker binds \{jobExecutionContext['minId']}. To test a partition worker that binds \{stepExecutionContext['minId']} — the usual case — seed a StepExecution instead and drive the reader inside step scope with StepScopeTestUtils.doInStepScope(…​), shown under Testing step-scoped and job-scoped beans below.

JobRepositoryTestUtils

Tests that run the same job repeatedly hit the "instance already completed" rule (Jobs, instances & parameters). JobRepositoryTestUtils clears the metadata between tests, and can also create executions for tests that exercise restart:

@BeforeEach
void reset() {
    jobRepositoryTestUtils.removeJobExecutions();
}

@Test
void resumesAfterAFailure() throws Exception {
    JobExecution seeded = jobRepositoryTestUtils
            .createJobExecutions("endOfDayJob", new String[] { "loadTradesStep" }, 1)
            .get(0);
    // ... arrange a failed first execution, then restart and assert the completed steps were skipped
}

MetaDataInstanceFactory

For a unit test of a listener, a partitioner or a tasklet, a real job run is overkill. MetaDataInstanceFactory builds domain objects without a repository:

@Test
void listenerFlagsAStepThatSkippedItems() {
    StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();
    stepExecution.setReadCount(100);
    stepExecution.setWriteCount(95);
    stepExecution.setProcessSkipCount(5);

    ExitStatus exitStatus = new StepAuditListener().after(stepExecution);

    assertEquals("COMPLETED WITH SKIPS", exitStatus.getExitCode());
}

createJobExecution(), createJobInstance() and createStepExecution(jobParameters, executionContext) cover the other shapes.

Testing step-scoped and job-scoped beans

A @StepScope bean cannot be instantiated outside a running step — the SpEL expressions in its @Value arguments have nothing to bind to. @SpringBatchTest registers StepScopeTestExecutionListener, which looks for a getStepExecution() method on the test class and uses it as the ambient step execution:

@SpringBatchTest
@SpringJUnitConfig(ReaderTestConfiguration.class)
class TradeReaderTests {

    @Autowired
    private FlatFileItemReader<TradeCsv> tradeReader;

    /** Supplies the StepExecution that step-scoped beans bind to. */
    StepExecution getStepExecution() {
        JobParameters parameters = new JobParametersBuilder()
                .addString("input.file", "src/test/resources/trades.csv")
                .toJobParameters();
        return MetaDataInstanceFactory.createStepExecution(parameters);
    }

    @Test
    void readsEveryRow() throws Exception {
        tradeReader.open(new ExecutionContext());
        int count = 0;
        while (tradeReader.read() != null) {
            count++;
        }
        tradeReader.close();
        assertEquals(1_000, count);
    }
}

StepScopeTestUtils.doInStepScope(…​) does the same for one block, without the listener:

@Test
void readsTheSliceItWasGiven() throws Exception {
    StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();
    stepExecution.getExecutionContext().putLong("minId", 1L);
    stepExecution.getExecutionContext().putLong("maxId", 50L);

    int count = StepScopeTestUtils.doInStepScope(stepExecution, () -> {
        partitionedReader.open(stepExecution.getExecutionContext());
        int read = 0;
        while (partitionedReader.read() != null) {
            read++;
        }
        partitionedReader.close();
        return read;
    });

    assertEquals(50, count);
}

JobScopeTestExecutionListener and JobScopeTestUtils are the @JobScope equivalents.

Validating output, and mocking

An end-to-end test should assert on what the job produced, not only on how it ended:

@Test
void writesTheExpectedOutputFile() throws Exception {
    JobExecution execution = jobOperatorTestUtils.startJob(parameters);
    assertEquals(BatchStatus.COMPLETED, execution.getStatus());

    List<String> lines = Files.readAllLines(Path.of("target/out/trades.csv"));
    assertEquals(999, lines.size());                       // 998 rows plus a header
    assertEquals("externalId,isin,quantity,price,notional", lines.get(0));
    assertTrue(lines.get(1).startsWith("T-0001,"));
}
@Test
void skipsTradesThePricingServiceRejects() throws Exception {
    // register the general stub FIRST: a later broader stubbing would
    // override the specific one and the exception would never be thrown
    when(pricingClient.price(anyString()))
            .thenReturn(new BigDecimal("12.34"));
    when(pricingClient.price("XX0000000000"))
            .thenThrow(new UnknownInstrumentException("not priced"));

    JobExecution execution = jobOperatorTestUtils.startStep("priceTradesStep");

    StepExecution stepExecution = execution.getStepExecutions().iterator().next();
    assertEquals(1, stepExecution.getProcessSkipCount());
    verify(pricingClient, times(1_000)).price(anyString());
}

A pragmatic split:

  • unit-test processors, `FieldSetMapper`s, `Partitioner`s and listeners as plain objects — no Spring context needed;

  • step-test readers and writers with startStep or doInStepScope, against a small fixture file or an in-memory database;

  • job-test the flow — that steps run in the right order, that a failure routes where it should, that a restart resumes — with startJob, sparingly, because these are the slow ones.

Mocking a remote dependency is right; mocking the database usually is not, since half of what a batch step does is SQL. Testcontainers against the real engine is the better trade — see Unit & Integration Testing.

Further reading

For the full detail behind this page: