Testing Spring Batch Jobs: Unit Tests, Integration Tests, and @SpringBatchTest
Introduction
Spring Batch jobs are code — they need tests. Without tests you will catch errors in production. With tests you catch them in CI.
Spring Batch has a dedicated testing module that provides @SpringBatchTest, JobLauncherTestUtils, and JobRepositoryTestUtils. These tools let you:
- Run a complete job in a test and assert on its
BatchStatus - Launch individual steps and inspect
StepExecutioncounters - Test readers, processors, and writers in complete isolation
This article covers all three levels: unit, integration, and database integration with Testcontainers.
Test Dependencies
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers for real MySQL in tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Unit Testing an ItemProcessor
Processors are pure functions — test them with plain JUnit, no Spring context needed.
class OrderProcessingProcessorTest {
private OrderProcessingProcessor processor;
@BeforeEach
void setUp() {
// Stub the dependencies
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), eq(String.class), anyLong()))
.thenReturn("GOLD");
processor = new OrderProcessingProcessor(jdbc);
}
@Test
void processGoldCustomer_appliesTenPercentDiscount() throws Exception {
Order order = new Order();
order.setOrderId(1L);
order.setCustomerId(42L);
order.setAmount(new BigDecimal("100.00"));
order.setOrderDate(LocalDate.now());
order.setStatus("PENDING");
ProcessedOrder result = processor.process(order);
assertThat(result).isNotNull();
assertThat(result.getFinalAmount()).isEqualByComparingTo("90.00");
assertThat(result.getCustomerTier()).isEqualTo("GOLD");
}
@Test
void processInvalidOrder_returnsNull() throws Exception {
Order order = new Order();
order.setOrderId(2L);
order.setAmount(new BigDecimal("-1.00")); // invalid amount
order.setOrderDate(LocalDate.now());
order.setStatus("PENDING");
ProcessedOrder result = processor.process(order);
assertThat(result).isNull(); // filtered
}
}
Unit testing a FieldSetMapper
class OrderFieldSetMapperTest {
private final OrderFieldSetMapper mapper = new OrderFieldSetMapper();
@Test
void mapsValidLine() throws BindException {
DefaultFieldSet fs = new DefaultFieldSet(
new String[]{"101", "99.99", "2026-05-03", "COMPLETED"},
new String[]{"customerId", "amount", "orderDate", "status"});
Order order = mapper.mapFieldSet(fs);
assertThat(order.getCustomerId()).isEqualTo(101L);
assertThat(order.getAmount()).isEqualByComparingTo("99.99");
assertThat(order.getOrderDate()).isEqualTo(LocalDate.of(2026, 5, 3));
assertThat(order.getStatus()).isEqualTo("COMPLETED");
}
@Test
void invalidStatus_throwsBindException() {
DefaultFieldSet fs = new DefaultFieldSet(
new String[]{"101", "99.99", "2026-05-03", "UNKNOWN_STATUS"},
new String[]{"customerId", "amount", "orderDate", "status"});
assertThatThrownBy(() -> mapper.mapFieldSet(fs))
.isInstanceOf(BindException.class);
}
}
Integration Testing with @SpringBatchTest
@SpringBatchTest auto-wires JobLauncherTestUtils and JobRepositoryTestUtils into your test. Use H2 as an in-memory database for the Spring Batch metadata tables.
Test configuration
@TestConfiguration
public class BatchTestConfig {
@Bean
@Primary
public DataSource testDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:org/springframework/batch/core/schema-h2.sql")
.build();
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
Job integration test
@SpringBatchTest
@SpringBootTest
@Import(BatchTestConfig.class)
class ImportOrdersJobIntegrationTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@AfterEach
void cleanUp() {
// Remove all job executions between tests
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void jobCompletesSuccessfully() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("runDate", "2026-05-03", true)
.addString("inputFile", "classpath:test-data/orders.csv", false)
.toJobParameters();
JobExecution execution = jobLauncherTestUtils.launchJob(params);
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(execution.getExitStatus().getExitCode()).isEqualTo("COMPLETED");
}
@Test
void jobCountsMatchExpected() throws Exception {
JobExecution execution = jobLauncherTestUtils.launchJob(
defaultParams("2026-05-04", "classpath:test-data/orders-10-rows.csv"));
StepExecution stepExecution = execution.getStepExecutions().iterator().next();
assertThat(stepExecution.getReadCount()).isEqualTo(10);
assertThat(stepExecution.getWriteCount()).isEqualTo(10);
assertThat(stepExecution.getSkipCount()).isEqualTo(0);
}
@Test
void jobHandlesMalformedLines() throws Exception {
// CSV has 3 good + 2 bad lines
JobExecution execution = jobLauncherTestUtils.launchJob(
defaultParams("2026-05-05", "classpath:test-data/orders-with-errors.csv"));
StepExecution stepExecution = execution.getStepExecutions().iterator().next();
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(stepExecution.getReadCount()).isEqualTo(3);
assertThat(stepExecution.getSkipCount()).isEqualTo(2);
}
@Test
void jobFailsWhenSkipLimitExceeded() throws Exception {
// CSV has more bad lines than skipLimit allows
JobExecution execution = jobLauncherTestUtils.launchJob(
defaultParams("2026-05-06", "classpath:test-data/orders-all-bad.csv"));
assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED);
}
private JobParameters defaultParams(String runDate, String inputFile) {
return new JobParametersBuilder()
.addString("runDate", runDate, true)
.addString("inputFile", inputFile, false)
.toJobParameters();
}
}
Testing a single step
@Test
void importStepWritesExpectedRows() throws Exception {
// Launch just one step, not the full job
JobExecution execution = jobLauncherTestUtils.launchStep(
"importOrdersStep",
new JobParametersBuilder()
.addString("runDate", "2026-05-07", true)
.toJobParameters());
StepExecution stepExecution = execution.getStepExecutions().stream()
.filter(se -> se.getStepName().equals("importOrdersStep"))
.findFirst().orElseThrow();
assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(stepExecution.getWriteCount()).isGreaterThan(0);
}
Testing an ItemReader
@SpringBatchTest
@SpringBootTest
@Import(BatchTestConfig.class)
class OrderCsvReaderTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
@Qualifier("orderCsvReader")
private FlatFileItemReader<Order> reader;
@Test
void readsAllRowsFromCsv() throws Exception {
// Open reader with a mock ExecutionContext
ExecutionContext ctx = new ExecutionContext();
reader.open(ctx);
List<Order> orders = new ArrayList<>();
Order order;
while ((order = reader.read()) != null) {
orders.add(order);
}
reader.close();
assertThat(orders).hasSize(5);
assertThat(orders.get(0).getCustomerId()).isEqualTo(101L);
}
@Test
void readerSkipsHeaderAndComments() throws Exception {
// test-data/orders-with-comments.csv has 1 header + 2 comment lines + 3 data rows
ExecutionContext ctx = new ExecutionContext();
reader.open(ctx);
int count = 0;
while (reader.read() != null) count++;
reader.close();
assertThat(count).isEqualTo(3);
}
}
Testing an ItemWriter
@SpringBootTest
@Transactional // roll back after each test
class OrderJdbcWriterTest {
@Autowired
private JdbcBatchItemWriter<Order> orderWriter;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void writesOrdersToDatabase() throws Exception {
List<Order> orders = List.of(
order(1L, 101L, "99.99", "COMPLETED"),
order(2L, 102L, "49.99", "PENDING")
);
orderWriter.write(new Chunk<>(orders));
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders", Integer.class);
assertThat(count).isEqualTo(2);
}
@Test
void upsertDoesNotDuplicateOnRetry() throws Exception {
Order order = order(1L, 101L, "99.99", "COMPLETED");
orderWriter.write(new Chunk<>(List.of(order)));
// Simulate retry — write same order again
orderWriter.write(new Chunk<>(List.of(order)));
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders WHERE order_id = 1", Integer.class);
assertThat(count).isEqualTo(1); // only one row, not two
}
private Order order(Long id, Long customerId, String amount, String status) {
Order o = new Order();
o.setOrderId(id);
o.setCustomerId(customerId);
o.setAmount(new BigDecimal(amount));
o.setOrderDate(LocalDate.now());
o.setStatus(status);
return o;
}
}
Integration Testing Against Real MySQL with Testcontainers
H2 is fast but not MySQL. Constraint behaviour, JSON functions, and ON DUPLICATE KEY UPDATE all differ. Use Testcontainers for full fidelity.
@SpringBatchTest
@SpringBootTest
@Testcontainers
class ImportOrdersJobMysqlTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("batch_test")
.withUsername("test")
.withPassword("test")
.withInitScript("sql/schema.sql"); // creates orders table + batch tables
@DynamicPropertySource
static void mysqlProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name",
() -> "com.mysql.cj.jdbc.Driver");
registry.add("spring.batch.jdbc.initialize-schema", () -> "always");
}
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@Autowired
private JdbcTemplate jdbcTemplate;
@AfterEach
void cleanUp() {
jobRepositoryTestUtils.removeJobExecutions();
jdbcTemplate.execute("TRUNCATE TABLE orders");
}
@Test
void importJob_insertsRowsIntoMysql() throws Exception {
JobExecution execution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder()
.addString("runDate", "2026-05-03", true)
.addString("inputFile", "classpath:test-data/orders-5-rows.csv", false)
.toJobParameters());
assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders", Integer.class);
assertThat(count).isEqualTo(5);
}
@Test
void importJob_isIdempotent() throws Exception {
JobParameters firstRun = new JobParametersBuilder()
.addString("runDate", "2026-05-03", true)
.addString("inputFile", "classpath:test-data/orders-5-rows.csv", false)
.toJobParameters();
// Run twice with the same input — second run is a restart
jobLauncherTestUtils.launchJob(firstRun);
// Manually mark first execution as FAILED to allow restart
// (in practice the job would have crashed mid-run)
// then re-launch:
JobParameters secondRun = new JobParametersBuilder()
.addString("runDate", "2026-05-04", true) // new date = new instance
.addString("inputFile", "classpath:test-data/orders-5-rows.csv", false)
.toJobParameters();
jobLauncherTestUtils.launchJob(secondRun);
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM orders", Integer.class);
assertThat(count).isEqualTo(5); // still 5, not 10 — upsert worked
}
}
Test init SQL (src/test/resources/sql/schema.sql)
-- Spring Batch metadata (copied from schema-mysql.sql)
-- ...include full schema-mysql.sql content here...
-- Application tables
CREATE TABLE IF NOT EXISTS orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL,
amount DECIMAL(19,2) NOT NULL,
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Test Data Files
Maintain a small set of test CSV files in src/test/resources/test-data/:
test-data/
├── orders-5-rows.csv # 5 valid orders
├── orders-10-rows.csv # 10 valid orders
├── orders-with-errors.csv # 3 valid + 2 malformed lines
├── orders-all-bad.csv # all lines are malformed (exceeds skipLimit)
└── orders-with-comments.csv # has comment and header lines
Example (orders-with-errors.csv):
customerId,amount,orderDate,status
101,99.99,2026-05-01,COMPLETED
102,INVALID,2026-05-02,PENDING
103,199.99,2026-05-03,SHIPPED
104,,2026-05-04,PENDING
105,75.00,2026-05-05,COMPLETED
Key Takeaways
- Unit test processors and mappers with plain JUnit — no Spring context needed, fast feedback.
@SpringBatchTest+JobLauncherTestUtilsfor full job integration tests. Always calljobRepositoryTestUtils.removeJobExecutions()in@AfterEach.- Use
launchStep()to test a single step in isolation without running the full job. - H2 is fine for metadata schema testing. Use Testcontainers + real MySQL for database-specific SQL (upserts, JSON, constraints).
- Test the golden path (all good data), skip path (some bad data), and failure path (skip limit exceeded). Three test cases cover the critical scenarios.
What’s Next
Part 8 (Testing) is complete. Article 20 starts Part 9 — Scaling. You will learn multi-threaded steps and AsyncItemProcessor for concurrency within a single JVM.