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
startStepordoInStepScope, 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.