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 입문에서 운영까지 시리즈 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— 옛 이름)JobRepositoryTestUtilsStepScopeTestExecutionListener+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());
}
}
작동 순서를 풀어보면, @SpringBatchTest 가 StepScopeTestExecutionListener(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() JobOperatorTestUtils—startJob()·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 정리 @StepScopeBean = 일반 단위 테스트 주입 불가 (IllegalStateException: No Scope registered)StepScopeTestExecutionListener= test method scope step context 활성- factory method =
getStepExecution() → StepExecution(signature 매칭) - factory method 없으면 = default StepExecution
@SpringBatchTest4.1+ = listener 자동 등록StepScopeTestUtils.doInStepScope= 명시적 callable scope- Listener vs Utils — test 전체 vs callable 내부
JobScopeTestExecutionListener=@JobScopeBean 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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 35편 — ItemProcessor · 변환 · 필터 · 검증
- 36편 — Reusing Services · ItemReaderAdapter · Process Indicator
- 37편 — Scaling · Parallel 6가지 전략 종합
- 38편 — Repeat · RepeatTemplate · CompletionPolicy
- 39편 — Retry · Spring Framework 7 Core Retry (v6 변경)
다음 글: