Spring Batch 입문 40편 — Testing · @SpringBatchTest · End-to-End

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 40편. Batch job 의 단위·통합 테스트. @SpringBatchTest 한 줄로 주입되는 JobOperatorTestUtils · JobRepositoryTestUtils, end-to-end 테스트 패턴 (startJob · startStep), StepScope 컴포넌트 테스트 (StepScopeTestExecutionListener · StepScopeTestUtils), MetaDataInstanceFactory 로 도메인 객체 mock, JUnit 4 제거 (v6) 까지 정리한 학습 노트. Part 9 마무리.

📚 Spring Batch 입문에서 운영까지 · 40편 — Testing · @SpringBatchTest · End-to-End

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 40편이에요. 39편 까지 Repeat · Retry 의 building block 을 봤다면, 이번 40편은 그 모든 것을 검증 하는 자리 — Testing. Part 9 (Repeat · Retry · Testing) 마무리.

Batch Testing 이 어려운 이유

일반 단위 테스트와 다른 부담이 세 가지 있어요. 우선 전체 ApplicationContext(스프링이 띄우는 전체 빈 컨테이너)가 필요한데, 여기에는 Job, JobRepository(배치 메타 저장소), DataSource, TransactionManager(트랜잭션 관리자) 같은 복잡한 인프라 가 줄줄이 따라붙어요. 둘째는 상태 의존성 — JobRepository 가 들고 있는 JobInstance(같은 Job + 같은 parameter 묶음) 와 JobExecution(한 번의 실행 기록) 이력이 테스트 결과를 좌우합니다. 셋째는 @StepScope · @JobScope Bean — Step/Job 실행 중에만 살아 있는 빈이라 일반 단위 테스트로는 주입 불가.

→ Spring Batch 의 spring-batch-test 모듈이 이 세 부담 해결.

@SpringBatchTest — 한 줄 마법

@SpringBatchTest
@SpringJUnitConfig(SkipSampleConfiguration.class)
public class SkipSampleFunctionalTests {
    // ...
}

@SpringBatchTest 한 줄 = test context 에 Batch test util 들 자동 주입:

  • JobOperatorTestUtils (또는 JobLauncherTestUtils — 옛 이름)
  • JobRepositoryTestUtils
  • StepScopeTestExecutionListener + JobScopeTestExecutionListener (4.1+ 자동 등록)

@SpringJUnitConfig = 일반 Spring Test 의 ApplicationContext loading.

Single Job 자동 매핑

If the test context contains a single Job bean definition, this bean will be autowired in JobOperatorTestUtils. — 공식 reference

context 에 Job bean 이 하나뿐 이면 자동 주입. 여러 개setJob() 으로 명시.

Spring Batch 6 — JUnit 4 제거

As of Spring Batch 6.0, JUnit 4 is no longer supported. Migration to JUnit Jupiter is recommended. — 공식 reference

JUnit 5 (Jupiter) 만 지원. 마이그레이션 시 기존 JUnit 4 test 검토 필수.

End-to-End Test — 전체 Job 실행

@SpringBatchTest
@SpringJUnitConfig(SkipSampleConfiguration.class)
public class SkipSampleFunctionalTests {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Test
    public void testJob(@Autowired Job job) throws Exception {
        // 1. 테스트 데이터 세팅
        this.jobOperatorTestUtils.setJob(job);
        this.jdbcTemplate.update("DELETE FROM customer");
        for (int i = 1; i <= 10; i++) {
            this.jdbcTemplate.update(
                "INSERT INTO customer VALUES (?, 0, ?, 100000)",
                i, "customer" + i);
        }

        // 2. Job 실행
        JobExecution jobExecution = jobOperatorTestUtils.startJob();

        // 3. 결과 검증
        assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
    }
}

흐름은 세 단계예요. 먼저 데이터 세팅 — JdbcTemplate(스프링의 JDBC 헬퍼) 으로 테스트 DB 에 known data 를 넣고, 그다음 Job 실행startJob() (parameter 필요시 startJob(params)) 을 호출하고, 마지막에 결과 검증JobExecution.getExitStatus() · getStepExecutions() · 실제 결과 데이터를 확인합니다.

JobOperatorTestUtils API

메서드 동작
startJob() 기본 parameter 로 Job 실행
startJob(JobParameters) 명시 parameter
startStep(stepName) 특정 Step 만 실행
setJob(Job) 대상 Job 변경
getJobLauncher() 내부 launcher 직접 접근

Individual Step Test

@Test
public void testLoadStep() throws Exception {
    JobExecution jobExecution = jobOperatorTestUtils.startStep("loadFileStep");

    StepExecution stepExecution = jobExecution.getStepExecutions().iterator().next();
    assertEquals("COMPLETED", stepExecution.getExitStatus().getExitCode());
    assertEquals(10, stepExecution.getReadCount());
}

복잡 Job 의 특정 Step 만 검증하는 방식 — targeted 테스트 이면서 실행도 빨라요. StepExecution(한 Step 의 실행 기록) 단위로 read/write/skip 카운트까지 곧장 뜯어볼 수 있습니다.

JobExecution 의 검증 가능 정보

JobExecution exec = jobOperatorTestUtils.startJob();

// Status
assertEquals(BatchStatus.COMPLETED, exec.getStatus());
assertEquals("COMPLETED", exec.getExitStatus().getExitCode());

// Step 별 통계
for (StepExecution step : exec.getStepExecutions()) {
    System.out.println(step.getStepName() + ": "
        + "read=" + step.getReadCount()
        + " write=" + step.getWriteCount()
        + " skip=" + step.getSkipCount());
}

// JobParameters
JobParameters params = exec.getJobParameters();
assertEquals("input.csv", params.getString("input.file"));

// ExecutionContext
ExecutionContext ctx = exec.getExecutionContext();
assertEquals(100L, ctx.getLong("totalCount"));

// 실패 예외
List<Throwable> failures = exec.getAllFailureExceptions();

BatchStatus(Job·Step 의 진행 상태값) 와 ExitStatus(종료 코드) 로 성공/실패를 가르고, JobParameters(Job 실행 입력값 묶음) 와 ExecutionContext(Step/Job 동안 살아 있는 key-value 저장소) 까지 한 객체에서 다 꺼낼 수 있다는 게 핵심입니다.

JobRepositoryTestUtils

@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;

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

JobRepository 의 metadata 를 정리하는 도구예요. 이전 테스트가 남긴 JobInstance/JobExecution 잔존이 unique parameter constraint 문제를 일으킬 때 깨끗이 비우려고 씁니다.

@StepScope Bean Test — 가장 어려운 자리

문제 설정부터 보면:

@Bean
@StepScope
public ItemReader<String> reader(
        @Value("#{stepExecutionContext['input.data']}") String data) {
    return new ListItemReader<>(Arrays.asList(data.split(",")));
}

이 ItemReader(배치 입력 한 건씩 읽는 컴포넌트) 를 일반 단위 테스트로 주입 하려고 하면 → IllegalStateException: No Scope registered. 이유는 @StepScope Bean 이 Step 실행 중 에만 instance 가 생성되기 때문이에요. 테스트가 Step context 없이 접근하면 당연히 불가.

StepScopeTestExecutionListener — 해법

@SpringBatchTest                        // 4.1+ 자동 등록
@SpringJUnitConfig
public class StepScopeTest {

    @Autowired
    private ItemReader<String> reader;

    public StepExecution getStepExecution() {       // ★ factory method
        StepExecution execution = MetaDataInstanceFactory.createStepExecution();
        execution.getExecutionContext().putString("input.data", "foo,bar,spam");
        return execution;
    }

    @Test
    public void testReader() {
        assertNotNull(reader.read());     // "foo"
        assertNotNull(reader.read());     // "bar"
        assertNotNull(reader.read());     // "spam"
        assertNull(reader.read());
    }
}

작동 순서를 풀어보면, @SpringBatchTestStepScopeTestExecutionListener(Step scope 를 흉내 내주는 JUnit 리스너) 를 자동 등록하고, 이 listener 가 test 클래스의 getStepExecution() factory method 를 탐지합니다. 각 test method 가 실행되기 에 factory method 가 불려 StepExecution context 가 활성화 되고, test 안에서 @StepScope Bean 은 그 StepExecution 안 에 들어간 것처럼 작동해요. test method 가 끝나면 context 도 해제됩니다.

Factory method 의 signature

public StepExecution getStepExecution() { ... }

규칙은 단순해요 — 반환 타입이 StepExecution (auto-detect) 이어야 하고, parameter 는 없어야 하며, method 이름은 자유 (signature 만 맞으면 OK).

factory method 없으면 = default StepExecution (빈 context).

StepScopeTestUtils — 명시적 제어

listener 가 test method scope 를 자동 처리. 더 세밀한 제어 가 필요하면:

@Test
public void countItems() throws Exception {
    StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();
    stepExecution.getExecutionContext().putString("input.data", "foo,bar,spam");

    int count = StepScopeTestUtils.doInStepScope(stepExecution, () -> {
        int c = 0;
        while (reader.read() != null) c++;
        return c;
    });

    assertEquals(3, count);
}

doInStepScope(stepExecution, Callable) = 명시적 step scope 안에서 callable 실행.

Listener vs Utils 비교

항목 Listener Utils
scope 활성 시점 test method 전체 callable 안만
여러 scope 한 test X ✓ (여러 번 호출)
침습성 낮음
사용 case 단일 step scope 검증 복수 scope · 부분 검증

MetaDataInstanceFactory — 도메인 mock

문제는 StepExecution 인스턴스를 만드는 일이에요:

StepExecution execution = new StepExecution(
    "NoProcessingStep",
    new JobExecution(new JobInstance(1L, new JobParameters(), "NoProcessingJob"))
);

JobInstance → JobExecution → StepExecution 으로 이어지는 의존 체인 때문에 매 테스트마다 verbose 한 코드가 반복됩니다.

MetaDataInstanceFactory 의 단축

StepExecution execution = MetaDataInstanceFactory.createStepExecution();

MetaDataInstanceFactory(배치 도메인 객체 생성 헬퍼) 한 줄이면 기본 JobInstance · JobExecution · StepExecution 이 전부 자동으로 만들어져요. 테스트 코드가 훨씬 깔끔 해집니다.

자주 쓰는 메서드

메서드 반환
createJobInstance() 기본 JobInstance
createJobInstance(jobName, instanceId) named
createJobExecution() 기본 JobExecution
createJobExecution(jobName, instanceId, executionId) named
createStepExecution() 기본 StepExecution
createStepExecution(stepName) named

Listener Test 예제

public class NoWorkFoundStepExecutionListener implements StepExecutionListener {
    @Override
    public ExitStatus afterStep(StepExecution stepExecution) {
        if (stepExecution.getReadCount() == 0) {
            return ExitStatus.FAILED;
        }
        return null;
    }
}

@Test
public void testAfterStep() {
    NoWorkFoundStepExecutionListener listener = new NoWorkFoundStepExecutionListener();

    StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();
    stepExecution.setExitStatus(ExitStatus.COMPLETED);
    stepExecution.setReadCount(0);

    ExitStatus exitStatus = listener.afterStep(stepExecution);
    assertEquals(ExitStatus.FAILED.getExitCode(), exitStatus.getExitCode());
}

도메인 객체를 한 줄로 mock 하니 listener 검증도 깔끔해져요.

Test 환경 권장 설정

H2 in-memory DB

# application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL
    driver-class-name: org.h2.Driver
  batch:
    jdbc:
      initialize-schema: always

테스트 시 H2 in-memory 를 쓰되, 실제 DB schema 와 호환되는 mode 로 띄우는 게 안전합니다.

Test profile 분리

@SpringBatchTest
@SpringJUnitConfig(BatchConfig.class)
@ActiveProfiles("test")
public class JobTest {
    // ...
}

application-test.yml + @ActiveProfiles("test") 조합으로 테스트 전용 설정을 격리합니다.

@BeforeEach 정리

@BeforeEach
void setUp() {
    jdbcTemplate.update("DELETE FROM customer");
    jobRepositoryTestUtils.removeJobExecutions();
}

테스트 간 격리 보장 — DB 데이터와 JobRepository metadata 를 모두 초기화해 둡니다.

통합 테스트 — 다단계 Job

@SpringBatchTest
@SpringJUnitConfig(ETLJobConfig.class)
class ETLJobIntegrationTests {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void testFullETL() throws Exception {
        // 1. 입력 데이터
        jdbcTemplate.batchUpdate(
            "INSERT INTO source_table VALUES (?, ?, ?)",
            generateTestData(1000));

        // 2. Job 실행
        JobParameters params = new JobParametersBuilder()
            .addString("input.file", "test.csv")
            .addLong("run.id", System.currentTimeMillis())
            .toJobParameters();

        JobExecution execution = jobOperatorTestUtils.startJob(params);

        // 3. 전체 Job 상태
        assertEquals(BatchStatus.COMPLETED, execution.getStatus());

        // 4. 각 Step 통계
        Map<String, StepExecution> steps = execution.getStepExecutions().stream()
            .collect(Collectors.toMap(StepExecution::getStepName, Function.identity()));

        assertEquals(1000, steps.get("readStep").getReadCount());
        assertEquals(1000, steps.get("writeStep").getWriteCount());

        // 5. 최종 결과 데이터
        Long count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM target_table", Long.class);
        assertEquals(1000L, count);
    }
}

JobParametersBuilder(JobParameters 를 만드는 빌더) 로 unique run.id 까지 박아 넣어 end-to-end + 다단계까지 한 번에 검증.

ItemReader 단위 테스트 패턴

@SpringBatchTest
@SpringJUnitConfig(ReaderTestConfig.class)
class CustomerReaderTest {

    @Autowired
    private FlatFileItemReader<Customer> reader;

    public StepExecution getStepExecution() {
        StepExecution execution = MetaDataInstanceFactory.createStepExecution();
        execution.getExecutionContext().putString("input.file",
            "src/test/resources/customers.csv");
        return execution;
    }

    @Test
    void readsAllCustomers() throws Exception {
        reader.open(new ExecutionContext());

        List<Customer> customers = new ArrayList<>();
        Customer c;
        while ((c = reader.read()) != null) {
            customers.add(c);
        }
        reader.close();

        assertEquals(10, customers.size());
        assertEquals("Alice", customers.get(0).getName());
    }
}

@StepScope Reader 의 단위 테스트 — listener 가 step scope 를 활성해 주니 open/close 만 직접 챙기면 됩니다.

자주 만나는 사고

사고 1: IllegalStateException: No Scope registered

원인은 @StepScope Bean 을 일반 단위 테스트에서 그대로 주입하려 한 데 있어요. 해법은 @SpringBatchTest 를 붙이고 getStepExecution() factory method 를 같이 두는 것.

사고 2: 같은 JobParameters 로 재실행 시 실패

같은 parameter 묶음은 곧 같은 JobInstance 라서 JobInstance already exists 가 떨어집니다. 해법은 addLong("run.id", System.currentTimeMillis()) 처럼 unique parameter 를 하나 끼워 넣거나, jobRepositoryTestUtils.removeJobExecutions() 로 metadata 를 비우는 것.

사고 3: Test DB schema 미생성

Batch metadata 테이블 (BATCH_JOB_INSTANCE 등) 이 안 만들어진 경우예요. spring.batch.jdbc.initialize-schema=always 를 켜거나 test config 에 H2 init script 를 걸어 줍니다.

사고 4: Multi-thread Step test 가 deterministic X

thread 순서가 비결정이라 그렇습니다. 단위 테스트에서는 SyncTaskExecutor 로 override 해 순차 실행으로 묶어 두는 게 안전.

사고 5: Listener factory method 인식 안 됨

메서드 signature 가 StepExecution 반환 이 아닐 때 흔합니다. return type 을 정확히 StepExecution 으로 맞추면 해결.

사고 6: JUnit 4 잔존

Spring Batch 6 로 올라간 뒤 기존 JUnit 4 test 가 동작하지 않는 경우예요. JUnit Jupiter (5) 로 마이그레이션이 정답.

사고 7: JobScope Bean test

@JobScope Bean 도 Job context 가 필요합니다. JobScopeTestExecutionListener 를 쓰면 되는데 (4.1+ 자동 등록), 이번엔 getJobExecution() factory method 를 둡니다.

운영 권장 패턴

Pattern 1: 표준 end-to-end

@SpringBatchTest
@SpringJUnitConfig({BatchConfig.class, TestConfig.class})
@ActiveProfiles("test")
class JobIntegrationTest {

    @Autowired private JobOperatorTestUtils utils;
    @Autowired private JdbcTemplate jdbc;

    @BeforeEach
    void clean() {
        jdbc.update("DELETE FROM ...");
    }

    @Test
    void happyPath() throws Exception {
        setupData();
        JobExecution exec = utils.startJob(uniqueParams());
        assertEquals(BatchStatus.COMPLETED, exec.getStatus());
        verifyResult();
    }

    private JobParameters uniqueParams() {
        return new JobParametersBuilder()
            .addLong("run.id", System.currentTimeMillis())
            .toJobParameters();
    }
}

Pattern 2: Reader 단위 test

@SpringBatchTest
@SpringJUnitConfig(ReaderConfig.class)
class ReaderTest {

    @Autowired private ItemReader<Customer> reader;

    public StepExecution getStepExecution() {
        StepExecution exec = MetaDataInstanceFactory.createStepExecution();
        exec.getExecutionContext().putString("input.file", "test.csv");
        return exec;
    }

    @Test
    void readsAll() throws Exception {
        reader.open(new ExecutionContext());
        // ...
    }
}

Pattern 3: Listener 단위 test

class SkipListenerTest {

    private MySkipListener listener = new MySkipListener();

    @Test
    void recordsSkippedItems() {
        StepExecution exec = MetaDataInstanceFactory.createStepExecution();
        // ...
        listener.onSkipInRead(new IOException("test"));
        assertEquals(1, listener.getReadSkips());
    }
}

ApplicationContext 없이 순수 단위 테스트MetaDataInstanceFactory 만 활용해도 충분합니다.

Pattern 4: Step skip 동작 검증

@Test
void skipsInvalidRecords() throws Exception {
    insertInvalidData(5);   // 5건 invalid + 95건 valid
    insertValidData(95);

    JobExecution exec = utils.startJob(uniqueParams());

    StepExecution step = exec.getStepExecutions().iterator().next();
    assertEquals(BatchStatus.COMPLETED, step.getStatus());
    assertEquals(95, step.getWriteCount());
    assertEquals(5, step.getSkipCount());
}

14편 skip 로직의 실제 동작 검증.

Pattern 5: Restart 테스트

@Test
void resumesFromLastPosition() throws Exception {
    setupDataThatWillFailMidway();

    JobParameters params = uniqueParams();
    JobExecution first = utils.startJob(params);
    assertEquals(BatchStatus.FAILED, first.getStatus());

    // 실패 원인 제거
    fixUnderlyingIssue();

    // 같은 parameter 로 재시작
    JobExecution second = utils.startJob(params);
    assertEquals(BatchStatus.COMPLETED, second.getStatus());

    // 첫 실행이 처리한 부분은 skip 됐는지 검증
    assertTrue(second.getStepExecutions().iterator().next().getReadCount() <
               first.getStepExecutions().iterator().next().getReadCount());
}

13편 Step Restart 의 검증.

시험 직전 한 번 더 — Testing 함정 압축 노트

  • spring-batch-test 모듈 = 테스트 util
  • @SpringBatchTest = JobOperatorTestUtils · JobRepositoryTestUtils · StepScope/JobScope listener 자동 주입
  • @SpringJUnitConfig = ApplicationContext loading
  • JUnit 4 제거 (Spring Batch 6) = Jupiter (5) 만 지원
  • single Job bean = 자동 autowired, multiple = setJob()
  • JobOperatorTestUtilsstartJob() · startJob(params) · startStep(name) · setJob()
  • JobRepositoryTestUtils.removeJobExecutions() = metadata 정리
  • end-to-end 3 단계 = 데이터 세팅 → Job 실행 → 결과 검증
  • JobExecution 검증 = status · ExitStatus · StepExecutions · JobParameters · ExecutionContext · failureExceptions
  • 같은 parameter 재실행 = JobInstance already exists → unique param (run.id timestamp) 또는 metadata 정리
  • @StepScope Bean = 일반 단위 테스트 주입 불가 (IllegalStateException: No Scope registered)
  • StepScopeTestExecutionListener = test method scope step context 활성
  • factory method = getStepExecution() → StepExecution (signature 매칭)
  • factory method 없으면 = default StepExecution
  • @SpringBatchTest 4.1+ = listener 자동 등록
  • StepScopeTestUtils.doInStepScope = 명시적 callable scope
  • Listener vs Utils — test 전체 vs callable 내부
  • JobScopeTestExecutionListener = @JobScope Bean test
  • factory method = getJobExecution() → JobExecution
  • MetaDataInstanceFactory = JobInstance · JobExecution · StepExecution 한 줄 생성
  • 도메인 객체 mock 의 verbosity 해결
  • 사용 — listener / processor / reader 단위 테스트
  • 함정 — IllegalStateException No Scope (StepScope 미설정)
  • 함정 — JobInstance already exists (unique param)
  • 함정 — schema 미생성 (initialize-schema=always)
  • 함정 — multi-thread non-deterministic (SyncTaskExecutor override)
  • 함정 — factory method signature mismatch
  • 함정 — JUnit 4 잔존 (v6 migration)
  • 함정 — JobScope test (별도 listener)
  • 패턴 — 표준 end-to-end (setup → run → verify)
  • 패턴 — Reader 단위 test (factory method + 직접 open/close)
  • 패턴 — Listener pure unit test (ApplicationContext 없이 MetaDataInstanceFactory)
  • 패턴 — Skip 동작 검증 (writeCount + skipCount)
  • 패턴 — Restart 검증 (같은 parameter, 첫 실패 + 두 번째 성공)
  • Part 9 마무리 — 다음 글부터 Part 10 (Patterns · Integration · Observability)

공식 문서: Unit Testing 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!