batch 기능은 테스트가 어려웠다. 차장님께선 select 까지만 테스트해보면 된다고 하셨지만
실제 삭제 로직까지 테스트하기 위해 Junit 5 로 테스트 하는 것을 제안드렸다.

처음에는 테스트 코드에서 데이터를 생성한 뒤 테스트가 끝나면 롤백되므로 기존 DB에는 영향이 없을 것이라 생각했다.
하지만 이번 Batch는 1년 이상 지난 데이터를 실제로 삭제하는 기능을 검증해야 했고, 테스트 대상 조건과 운영 데이터가 겹칠 경우 실제 데이터가 삭제될 위험이 있었다.
또한 Spring Batch는 Step 단위로 별도의 트랜잭션을 사용하여 Commit이 이루어지므로, 일반적인 @Transactional 테스트처럼 항상 롤백되는 구조도 아니었다.
따라서 운영 데이터와 완전히 분리된 환경에서 안전하게 테스트하기 위해 H2 In-Memory Database를 사용하여 테스트 데이터를 생성하고 삭제하는 방식으로 Integration Test를 작성하였다.
먼저 테스트코드를 실행할 수 있는 뼈대를 만들어야했다.
Spring Batch 테스트를 위해 가장 먼저 필요한 것은 JobLauncherTestUtils였다. 테스트에서는 실제 Batch Job을 실행해야 하므로 다음과 같이 Bean을 등록하였다.
또한 1개의 JobConfiguration에 2개의 job 이 있는 구조이므로, 테스트 할 job 을 지정하는 로직이 추가로 필요했다. 테스트용 JobLauncherTestUtils 에서 사용할 Job 을 @Qualifier로 명시적으로 지정해주었다.
이러한 특이사항 아래 JobLauncherTestUtils 의존성 주입을 위해 여러가지 방법을 사용해보았다 Config.java 에서 생성자 주입, @Autowired 주입 등 여러가지 방법을 해보았지만 spring 2.5 + Junit 5 에서 Test 클래스에서 아래 방법으로 @BeforeEach 과정에서 생성자 주입을 해야 실행되었다.
class NotificationJobConfigurationTest {
...
@Autowired
@Qualifier("notificationHistoryDeleteJob")
private Job notificationHistoryDeleteJob;
private JobLauncherTestUtils jobLauncherTestUtils;
@BeforeEach
void setUp() {
jobLauncherTestUtils = new JobLauncherTestUtils();
jobLauncherTestUtils.setJobLauncher(jobLauncher);
jobLauncherTestUtils.setJobRepository(jobRepository);
jobLauncherTestUtils.setJob(notificationHistoryDeleteJob);
...
< 실패한 방법 >
TestBatchConfig.java 를 만들어 해당 클래스 파일 안에 JobLauncherTestUtils 를 넣어주고 실행할 job 을 지정해주는 로직도 사용해보았지만, 실행하면 job 을 인식하지 못했다.
@TestConfiguration
@EnableBatchProcessing
@EnableAutoConfiguration
public class TestBatchConfig {
@Bean
public JobLauncherTestUtils jobLauncherTestUtils(
JobLauncher jobLauncher,
JobRepository jobRepository,
@Qualifier("notificationHistoryDeleteJob") Job job
) {
JobLauncherTestUtils utils = new JobLauncherTestUtils();
utils.setJobLauncher(jobLauncher);
utils.setJobRepository(jobRepository);
utils.setJob(job);
return utils;
}
}
위의 내용으로 테스트코드 실행 환경을 마치고나서, 처음 실행하자마자 다음과 같은 오류가 발생했다.
No bean named 'entityManagerFactory'
원인은 @SpringBootTest(classes=...)를 사용하여 필요한 Bean만 올리고 있었기 때문이었다.
필요한 Configuration만 올리다 보니 JPA Auto Configuration까지 제외되어 EntityManagerFactory가 생성되지 않았다.
해결은 @EnableAutoConfiguration을 추가하는 것이었다.
@TestConfiguration
@EnableBatchProcessing
@EnableAutoConfiguration
이후 H2와 EntityManagerFactory가 정상적으로 생성되었다.
의존성 주입 과정에서 많은 어려움이 있었다.
해당 테스트는 batch 로 메일 전송 이력 데이터만 삭제하는 로직이므로, 특정 entity,repository 단만 의존성을 주입해 사용했다. 하지만 테스트 데이터를 생성하는 과정에서 오류가 발생했다.
AuthUser.getAuthUserDetails() NullPointerException
Entity에는 createdId 를 자동으로 넣는 Generator 가 있었고 Generator 내부에서는 SecurityContextHolder 에서 로그인 사용자를 가져오고 있었다.
따라서 @BeforeEach 과정에 SecurityContextHolder에 임의의 사용자 정보를 넣어주었다.
@BeforeEach
void setUp() {
AuthUserDetails authUserDetails = new AuthUserDetails();
AuthUserDto authUserDto = AuthUserDto.builder()
.id(1L).build();
authUserDetails.setAuthUserDto(authUserDto);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
authUserDetails,
null,
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
따라서 다음과 같은 테스트코드가 완성되었다.
@ActiveProfiles("junit")
@ExtendWith(SpringExtension.class)
@SpringBootTest(
classes = JobConfiguration.class,
TestBatchConfig.class,
demo2Reader.class,
demo2Writer.class,
cRepository.class,
cBackendRepositoryImpl.class // 구현체 지정해서
}
)
@EntityScan(basePackageClasses = {
c.class,
b.class,
a.class
})
@EnableJpaRepositories(basePackageClasses = {
cRepository.class,
bRepository.class,
aRepository.class,
})
class JobConfigurationTest {
@Autowired
private CRepository cRepository;
@Autowired
private ARepository aRepository;
@Autowired
private BRepository bRepository;
@MockBean
private JobResultListener jobResultListener;
@MockBean
private StepResultListener stepResultListener;
@MockBean
private Demo1Tasklet demo1Tasklet;
@Autowired
private JobLauncher jobLauncher;
@Autowired
private JobRepository jobRepository;
@Autowired
@Qualifier("notificationHistoryDeleteJob")
private Job notificationHistoryDeleteJob;
private JobLauncherTestUtils jobLauncherTestUtils;
@BeforeEach
void setUp() {
jobLauncherTestUtils = new JobLauncherTestUtils();
jobLauncherTestUtils.setJobLauncher(jobLauncher);
jobLauncherTestUtils.setJobRepository(jobRepository);
jobLauncherTestUtils.setJob(notificationHistoryDeleteJob);
AuthUserDetails authUserDetails = new AuthUserDetails();
AuthUserDto authUserDto = AuthUserDto.builder()
.type(CommonTypes.SiteCode.USER.getCode())
.id(1L)
.languageTypeCode(AuthUser.LANG_TY_CD).build();
authUserDetails.setAuthUserDto(authUserDto);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
authUserDetails,
null,
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@AfterEach
public void tearDown() {
aRepository.deleteAllInBatch();
bRepository.deleteAllInBatch();
cRepository.deleteAllInBatch();
}
@Test
public void delete_whenDataCountExceedsChunkSize() throws Exception {
// given
int insertCount = 5004;
long beforeCount = cRepository.count();
saveNotificationHistoryData(insertCount);
assertThat(cRepository.count()).isEqualTo(beforeCount + insertCount);
JobParameters jobParameters = createJobParameters();
// when
JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
// then
assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(cRepository.count()).isEqualTo(beforeCount);
StepExecution stepExecution = getStepExecution(jobExecution);
assertThat(stepExecution.getReadCount()).isEqualTo(2);
assertThat(stepExecution.getWriteCount()).isEqualTo(2);
}
@Test
public void delete_whenDataCountIsLessThanChunkSize() throws Exception {
// given
int insertCount = 4;
long beforeCount = cRepository.count();
saveNotificationHistoryData(insertCount);
assertThat(cRepository.count()).isEqualTo(beforeCount + insertCount);
JobParameters jobParameters = createJobParameters();
// when
JobExecution jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
// then
assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
assertThat(cRepository.count()).isEqualTo(beforeCount);
assertThat(cRepository.count()).isEqualTo(0);
StepExecution stepExecution = getStepExecution(jobExecution);
assertThat(stepExecution.getReadCount()).isEqualTo(1;
assertThat(stepExecution.getWriteCount()).isEqualTo(1;
}
private void saveNotificationHistoryData(int count) {
LocalDateTime beforeDate = LocalDateTime.now().minusYears(2);
for (int i = 0; i < count; i++) {
A a = new A();
a.setCreateDateTime(beforeDate);
aRepository.save(a);
B b = new B();
b.setCreateDateTime(beforeDate);
bRepository.save(b);
C c = new c();
c.setCreateDateTime(beforeDate);
cRepository.save(c);
b.setC(c);
a.setC(c);
aRepository.save(a);
bRepository.save(b);
}
}
private JobParameters createJobParameters() {
return new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters();
}
private StepExecution getStepExecution(JobExecution jobExecution) {
return jobExecution.getStepExecutions()
.iterator()
.next();
}
}
@TestConfiguration
@EnableBatchProcessing
@EnableAutoConfiguration
public class TestBatchConfig {
}