테스트 전략 개요

Spring Batch 테스트는 레이어별로 나눠서 접근해야 한다. 전체를 통합 테스트로만 검증하면 느리고, 단위 테스트만으로는 Step 간 상호작용을 검증할 수 없다.

레이어도구속도용도
ItemProcessor 단위 테스트JUnit5 + Mockito빠름비즈니스 로직 검증
ItemReader 단위 테스트StepScopeTestUtils보통Reader 동작 검증
ItemWriter 단위 테스트EmbeddedDatabase보통쓰기 SQL 검증
Step 통합 테스트@SpringBatchTest느림Step 전체 흐름 검증
Job 통합 테스트@SpringBatchTest가장 느림Job 전체 시나리오 검증

의존성 설정

<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>
<!-- H2 인메모리 DB -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

ItemProcessor 단위 테스트

Spring Context 없이 순수 Java로 테스트한다. 빠르고 의존성이 없다.

class OrderProcessorTest {
 
    private OrderItemProcessor processor;
 
    @Mock
    private DiscountService discountService;
 
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        processor = new OrderItemProcessor(discountService);
    }
 
    @Test
    @DisplayName("일반 주문은 정상 처리되어야 한다")
    void processNormalOrder() throws Exception {
        // given
        Order order = Order.builder()
                .id(1L)
                .amount(new BigDecimal("10000"))
                .status("PENDING")
                .build();
 
        when(discountService.getDiscountRate(1L)).thenReturn(0.1);
 
        // when
        ProcessedOrder result = processor.process(order);
 
        // then
        assertThat(result).isNotNull();
        assertThat(result.getOrderId()).isEqualTo(1L);
        assertThat(result.getFinalAmount()).isEqualByComparingTo("9000");
        assertThat(result.getStatus()).isEqualTo("PROCESSED");
    }
 
    @Test
    @DisplayName("이미 처리된 주문은 null을 반환해야 한다 (필터링)")
    void filterAlreadyProcessedOrder() throws Exception {
        // given - 이미 처리된 주문
        Order order = Order.builder()
                .id(2L)
                .amount(new BigDecimal("5000"))
                .status("PROCESSED")  // 이미 처리됨
                .build();
 
        // when - null 반환 = Processor에서 필터링
        ProcessedOrder result = processor.process(order);
 
        // then
        assertThat(result).isNull();
        verify(discountService, never()).getDiscountRate(anyLong());
    }
 
    @Test
    @DisplayName("할인 서비스 호출 실패 시 예외가 전파되어야 한다")
    void propagateExceptionWhenDiscountServiceFails() {
        // given
        Order order = Order.builder().id(3L).amount(new BigDecimal("10000")).status("PENDING").build();
        when(discountService.getDiscountRate(3L))
                .thenThrow(new RuntimeException("할인 서비스 연결 실패"));
 
        // when & then
        assertThatThrownBy(() -> processor.process(order))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("할인 서비스 연결 실패");
    }
}

ItemReader 단위 테스트

@StepScope 빈은 Step Context 없이는 초기화되지 않는다. StepScopeTestUtils를 활용한다.

@SpringBatchTest
@SpringBootTest(classes = {BatchTestConfig.class, DateBasedJobConfig.class})
class OrderReaderTest {
 
    @Autowired
    private ApplicationContext applicationContext;
 
    @Autowired
    private DataSource dataSource;
 
    @Test
    @DisplayName("targetDate에 해당하는 주문만 읽어야 한다")
    void readOrdersByTargetDate() throws Exception {
        // given - 테스트 데이터 삽입
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        jdbcTemplate.update(
                "INSERT INTO orders (id, amount, status, created_at) VALUES (?, ?, ?, ?)",
                1L, new BigDecimal("10000"), "PENDING", "2026-03-27 10:00:00");
        jdbcTemplate.update(
                "INSERT INTO orders (id, amount, status, created_at) VALUES (?, ?, ?, ?)",
                2L, new BigDecimal("5000"), "PENDING", "2026-03-26 10:00:00");  // 다른 날짜
 
        // StepExecution Mock 설정
        StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution(
                new JobParametersBuilder()
                        .addString("targetDate", "2026-03-27")
                        .toJobParameters()
        );
 
        // when - @StepScope 컨텍스트 내에서 Reader 실행
        List<Order> orders = StepScopeTestUtils.doInStepScope(stepExecution, () -> {
            JdbcCursorItemReader<Order> reader =
                    applicationContext.getBean("orderReader", JdbcCursorItemReader.class);
            reader.open(stepExecution.getExecutionContext());
 
            List<Order> result = new ArrayList<>();
            Order order;
            while ((order = reader.read()) != null) {
                result.add(order);
            }
            reader.close();
            return result;
        });
 
        // then - 2026-03-27 날짜 주문만 읽혀야 함
        assertThat(orders).hasSize(1);
        assertThat(orders.get(0).getId()).isEqualTo(1L);
    }
}
 
// MockStepExecution 생성 헬퍼
class MockStepExecutionFactory {
 
    public static StepExecution create(String targetDate) {
        return MetaDataInstanceFactory.createStepExecution(
                new JobParametersBuilder()
                        .addString("targetDate", targetDate)
                        .addLong("timestamp", System.currentTimeMillis())
                        .toJobParameters()
        );
    }
 
    public static StepExecution createWithContext(
            String targetDate, Map<String, Object> contextValues) {
        StepExecution stepExecution = create(targetDate);
        contextValues.forEach((key, value) ->
                stepExecution.getExecutionContext().put(key, value));
        return stepExecution;
    }
}

ItemWriter 단위 테스트

class ProcessedOrderWriterTest {
 
    private EmbeddedDatabase embeddedDatabase;
    private JdbcBatchItemWriter<ProcessedOrder> writer;
 
    @BeforeEach
    void setUp() {
        embeddedDatabase = new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("schema/batch-schema.sql")   // 테스트용 DDL
                .addScript("schema/order-schema.sql")
                .build();
 
        writer = new JdbcBatchItemWriterBuilder<ProcessedOrder>()
                .dataSource(embeddedDatabase)
                .sql("INSERT INTO processed_orders (order_id, final_amount, processed_at) " +
                     "VALUES (:orderId, :finalAmount, :processedAt)")
                .beanMapped()
                .build();
 
        writer.afterPropertiesSet();
    }
 
    @AfterEach
    void tearDown() {
        embeddedDatabase.shutdown();
    }
 
    @Test
    @DisplayName("처리된 주문 목록이 DB에 저장되어야 한다")
    void writeProcessedOrders() throws Exception {
        // given
        List<ProcessedOrder> items = List.of(
                new ProcessedOrder(1L, new BigDecimal("9000"), LocalDateTime.now()),
                new ProcessedOrder(2L, new BigDecimal("4500"), LocalDateTime.now()),
                new ProcessedOrder(3L, new BigDecimal("18000"), LocalDateTime.now())
        );
 
        // Spring Batch 5.x: Chunk.of() 사용
        Chunk<ProcessedOrder> chunk = Chunk.of(
                items.toArray(new ProcessedOrder[0]));
 
        // when
        writer.write(chunk);
 
        // then
        JdbcTemplate jdbcTemplate = new JdbcTemplate(embeddedDatabase);
        int count = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM processed_orders", Integer.class);
        assertThat(count).isEqualTo(3);
 
        // 특정 레코드 검증
        BigDecimal amount = jdbcTemplate.queryForObject(
                "SELECT final_amount FROM processed_orders WHERE order_id = 1",
                BigDecimal.class);
        assertThat(amount).isEqualByComparingTo("9000");
    }
}

@SpringBatchTest 통합 테스트

설정

@SpringBatchTest           // JobLauncherTestUtils, JobRepositoryTestUtils 자동 등록
@SpringBootTest(classes = {
        BatchTestConfig.class,      // 테스트용 배치 설정
        DateBasedJobConfig.class    // 테스트할 Job 설정
})
@ActiveProfiles("test")
class DateBasedJobIntegrationTest {
 
    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;
 
    @Autowired
    private JobRepositoryTestUtils jobRepositoryTestUtils;
 
    @Autowired
    private JdbcTemplate jdbcTemplate;
 
    @BeforeEach
    void setUp() {
        // 테스트 간 실행 이력 격리 (이전 테스트의 메타데이터 삭제)
        jobRepositoryTestUtils.removeJobExecutions();
    }
 
    // =========== Job 전체 실행 테스트 ===========
 
    @Test
    @DisplayName("Job이 정상 완료되어야 한다")
    void jobShouldComplete() throws Exception {
        // given - 테스트 데이터 삽입
        insertTestOrders("2026-03-27", 5);
 
        JobParameters params = new JobParametersBuilder()
                .addString("targetDate", "2026-03-27")
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();
 
        // when - Job 전체 실행
        JobExecution jobExecution = jobLauncherTestUtils.launchJob(params);
 
        // then
        assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
        assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED");
    }
 
    @Test
    @DisplayName("처리된 주문이 DB에 저장되어야 한다")
    void processedOrdersShouldBeSaved() throws Exception {
        // given
        insertTestOrders("2026-03-27", 3);
 
        JobParameters params = new JobParametersBuilder()
                .addString("targetDate", "2026-03-27")
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();
 
        // when
        jobLauncherTestUtils.launchJob(params);
 
        // then
        int savedCount = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM processed_orders WHERE DATE(processed_at) = '2026-03-27'",
                Integer.class);
        assertThat(savedCount).isEqualTo(3);
    }
 
    // =========== Step 단독 실행 테스트 ===========
 
    @Test
    @DisplayName("processOrderStep의 readCount와 writeCount가 일치해야 한다")
    void stepReadWriteCountShouldMatch() throws Exception {
        // given
        insertTestOrders("2026-03-27", 10);
 
        JobParameters params = new JobParametersBuilder()
                .addString("targetDate", "2026-03-27")
                .toJobParameters();
 
        // when - Step 단독 실행
        JobExecution jobExecution = jobLauncherTestUtils.launchStep("processOrderStep", params);
 
        // then - StepExecution 검증
        StepExecution stepExecution = getStepExecution(jobExecution, "processOrderStep");
 
        assertThat(stepExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
        assertThat(stepExecution.getReadCount()).isEqualTo(10);
        assertThat(stepExecution.getWriteCount()).isEqualTo(10);
        assertThat(stepExecution.getSkipCount()).isEqualTo(0);
        assertThat(stepExecution.getFilterCount()).isEqualTo(0);
        assertThat(stepExecution.getRollbackCount()).isEqualTo(0);
    }
 
    private StepExecution getStepExecution(JobExecution jobExecution, String stepName) {
        return jobExecution.getStepExecutions()
                .stream()
                .filter(se -> se.getStepName().equals(stepName))
                .findFirst()
                .orElseThrow(() -> new AssertionError("Step not found: " + stepName));
    }
 
    private void insertTestOrders(String date, int count) {
        for (int i = 1; i <= count; i++) {
            jdbcTemplate.update(
                    "INSERT INTO orders (id, amount, status, created_at) VALUES (?, ?, 'PENDING', ?)",
                    (long) i,
                    new BigDecimal(i * 1000),
                    date + " 10:00:00"
            );
        }
    }
}

테스트 DB 설정

H2 인메모리 DB 설정

@TestConfiguration
public class BatchTestConfig {
 
    @Bean
    @Primary
    public DataSource testDataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("org/springframework/batch/core/schema-h2.sql")  // 배치 메타 스키마
                .addScript("test-schema.sql")  // 비즈니스 스키마
                .build();
    }
}
# src/test/resources/application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  batch:
    jdbc:
      initialize-schema: always
    job:
      enabled: false
  jpa:
    hibernate:
      ddl-auto: create-drop

Testcontainers + MySQL 실제 DB 테스트

Spring Boot 3.1+의 @ServiceConnection을 활용하면 설정이 단순해진다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBatchTest
@SpringBootTest(classes = {DateBasedJobConfig.class})
@Testcontainers
@ActiveProfiles("testcontainers")
class DateBasedJobContainerTest {
 
    // Spring Boot 3.1+ @ServiceConnection: 컨테이너 정보를 자동으로 DataSource에 주입
    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("batch_test")
            .withUsername("test")
            .withPassword("test")
            .withInitScript("init-schema.sql");
 
    @Autowired
    private JobLauncherTestUtils jobLauncherTestUtils;
 
    @Autowired
    private JobRepositoryTestUtils jobRepositoryTestUtils;
 
    @BeforeEach
    void setUp() {
        jobRepositoryTestUtils.removeJobExecutions();
    }
 
    @Test
    @DisplayName("실제 MySQL에서 Job이 정상 완료되어야 한다")
    void jobShouldCompleteWithRealMySQL() throws Exception {
        JobParameters params = new JobParametersBuilder()
                .addString("targetDate", "2026-03-27")
                .addLong("timestamp", System.currentTimeMillis())
                .toJobParameters();
 
        JobExecution execution = jobLauncherTestUtils.launchJob(params);
        assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
    }
}
 
// Spring Boot 3.1 이전: @DynamicPropertySource 방식
@SpringBatchTest
@SpringBootTest
@Testcontainers
class LegacyContainerTest {
 
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("batch_test");
 
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}

청크 테스트 패턴

패턴 1: DB 입력 → 배치 실행 → DB 출력 검증

@Test
@DisplayName("DB 입력 데이터가 처리되어 결과 테이블에 저장되어야 한다")
void dbInputToDbOutputTest() throws Exception {
    // given - 입력 데이터 준비
    jdbcTemplate.batchUpdate(
            "INSERT INTO orders (id, amount, status, created_at) VALUES (?, ?, 'PENDING', '2026-03-27 00:00:00')",
            List.of(
                    new Object[]{1L, new BigDecimal("10000")},
                    new Object[]{2L, new BigDecimal("20000")},
                    new Object[]{3L, new BigDecimal("30000")}
            )
    );
 
    JobParameters params = new JobParametersBuilder()
            .addString("targetDate", "2026-03-27")
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters();
 
    // when
    JobExecution execution = jobLauncherTestUtils.launchJob(params);
 
    // then - 배치 실행 결과 검증
    assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
 
    // DB 출력 검증
    List<Map<String, Object>> results = jdbcTemplate.queryForList(
            "SELECT order_id, final_amount FROM processed_orders ORDER BY order_id");
 
    assertThat(results).hasSize(3);
    assertThat(results.get(0).get("order_id")).isEqualTo(1L);
    assertThat((BigDecimal) results.get(0).get("final_amount"))
            .isEqualByComparingTo("9000");  // 10% 할인 적용 확인
}

패턴 2: 파일 입력 → 배치 실행 → 파일 출력 검증

@Test
@DisplayName("입력 파일을 처리하여 출력 파일을 생성해야 한다")
void fileInputToFileOutputTest(@TempDir Path tempDir) throws Exception {
    // given - 입력 파일 생성
    Path inputFile = tempDir.resolve("orders.csv");
    Files.writeString(inputFile,
            "id,amount,status\n" +
            "1,10000,PENDING\n" +
            "2,20000,PENDING\n"
    );
 
    Path outputFile = tempDir.resolve("processed_orders.csv");
 
    JobParameters params = new JobParametersBuilder()
            .addString("inputFile", inputFile.toString())
            .addString("outputFile", outputFile.toString())
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters();
 
    // when
    JobExecution execution = jobLauncherTestUtils.launchJob(params);
 
    // then
    assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
    assertThat(outputFile).exists();
 
    List<String> lines = Files.readAllLines(outputFile);
    assertThat(lines).hasSize(3);  // 헤더 1 + 데이터 2
    assertThat(lines.get(1)).contains("1");
}

테스트 격리: JobRepositoryTestUtils.removeJobExecutions()

@BeforeEach
void isolateTest() {
    // 이전 테스트에서 생성된 모든 JobExecution 삭제
    // 동일한 JobParameters로 재실행 가능하게 만듦
    jobRepositoryTestUtils.removeJobExecutions();
 
    // 비즈니스 데이터도 초기화
    jdbcTemplate.execute("DELETE FROM processed_orders");
    jdbcTemplate.execute("DELETE FROM orders");
}

Skip/Retry 테스트

@Test
@DisplayName("처리 중 예외 발생 시 해당 아이템을 Skip하고 계속 처리해야 한다")
void skipOnProcessingException() throws Exception {
    // given - 10건 중 3번, 7번 데이터는 처리 시 예외 발생하도록 설정
    insertTestOrders(10);
 
    // Mock Processor: id가 3 또는 7인 주문 처리 시 예외 발생
    when(mockProcessor.process(argThat(o -> o.getId() == 3L || o.getId() == 7L)))
            .thenThrow(new DataProcessingException("처리 불가 주문"));
    when(mockProcessor.process(argThat(o -> o.getId() != 3L && o.getId() != 7L)))
            .thenAnswer(inv -> {
                Order order = inv.getArgument(0);
                return new ProcessedOrder(order.getId(), order.getAmount());
            });
 
    JobParameters params = new JobParametersBuilder()
            .addString("targetDate", "2026-03-27")
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters();
 
    // when
    JobExecution jobExecution = jobLauncherTestUtils.launchStep("processOrderStep", params);
 
    // then
    StepExecution stepExecution = getStepExecution(jobExecution, "processOrderStep");
 
    assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
    assertThat(stepExecution.getReadCount()).isEqualTo(10);
    assertThat(stepExecution.getWriteCount()).isEqualTo(8);   // 10 - 2 skip
    assertThat(stepExecution.getSkipCount()).isEqualTo(2);    // 3번, 7번 skip
}
 
@Test
@DisplayName("skipLimit 초과 시 Step이 FAILED가 되어야 한다")
void stepShouldFailWhenSkipLimitExceeded() throws Exception {
    // given - skipLimit=2로 설정된 Step에서 3건 이상 예외 발생
    insertTestOrders(5);
    when(mockProcessor.process(any())).thenThrow(new DataProcessingException("항상 실패"));
 
    JobParameters params = new JobParametersBuilder()
            .addString("targetDate", "2026-03-27")
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters();
 
    // when
    JobExecution jobExecution = jobLauncherTestUtils.launchStep("processOrderStep", params);
 
    // then - skipLimit 초과로 Step FAILED
    assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.FAILED);
}

실무 팁

테스트 속도 최적화

// @SpringBatchTest는 Spring Context를 로드하므로 첫 실행이 느림
// 동일한 Context를 재사용하도록 @DirtiesContext를 최소화
 
// 나쁜 패턴: 각 테스트마다 Context 재생성
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)  // 지양
 
// 좋은 패턴: JobRepositoryTestUtils로 데이터만 초기화
@BeforeEach
void setUp() {
    jobRepositoryTestUtils.removeJobExecutions();  // Context 재생성 없이 격리
    jdbcTemplate.execute("TRUNCATE TABLE orders");
}

테스트 커버리지 체크리스트

ItemProcessor:
  [ ] 정상 케이스 (변환 결과 검증)
  [ ] null 반환 케이스 (필터링)
  [ ] 예외 케이스 (기대하는 예외 타입)
  [ ] 경계값 케이스 (빈 문자열, 0, 최대값)

Step 통합:
  [ ] 정상 완료 (COMPLETED 상태)
  [ ] readCount == writeCount (필터 없을 때)
  [ ] skip 동작 검증
  [ ] skipLimit 초과 시 FAILED
  [ ] 재시작 동작 (FAILED → restart → COMPLETED)

Job 통합:
  [ ] 동일 파라미터 재실행 방지
  [ ] 다른 날짜 파라미터로 재실행 허용
  [ ] Step 실패 시 Job FAILED
  [ ] 조건부 Flow (상태에 따른 다음 Step 분기)