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 StepExecution counters
  • 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 + JobLauncherTestUtils for full job integration tests. Always call jobRepositoryTestUtils.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.