서론

앞으로 팀내 스프링 배치를 적극 사용하기 위해, 미리 테스트 환경 구축하는 작업을 진행했던 내용에 대해서 공유해보려고 합니다.

 

작업 하기 전 스프링 배치 테스트는 어떤식으로 하는게 좋을지 검색을 해봤습니다.

이쉽게도 스프링 배치는 아티클이 별로 없기 때문에 테스트에 대해 집중적으로 다루고 있는 글도 드물었습니다.

 

AS-IS

우선 기존에 제가 테스틀 작성했던 방법입니다.(과거 배치 테스트 블로그 글에서 소개했던 방법을 그대로 사용했었습니다)

@SpringBatchTest
@SpringBootTest(classes = {GetProductDtoJobConfig.class, TestBatchConfig.class})
class GetProductResponseJobConfigTest {

    @Autowired
    JobLauncherTestUtils jobLauncherTestUtils;
    
    @Test
    void foo() throws Exception {
       // doSome ...
       
        jobLauncherTestUtils.launchJob(jobParameters);
    }
}

하지만 위 방식에는 다음과 같은 문제점들이 있었습니다.

  • 환경이 다르기 때문에 컨텍스트를 다시 띄운다.
  • 유지 보수가 어렵다 (의존성이 여러개 필요한 경우 코드가 지저분해지고 의존성 중 인터페이스 구현체가 바뀌면 테스트가 실패한다. (classes = ...)
  • 불필요한 익셉션 처리 (jobLauncherTestUtils.launchJob 에서 catched exception)

 

환경이 다르단 얘기를 좀 더 자세하게 설명해보겠습니다.

스프링에서 통합 테스트를 진행할때 기본적으로 컨텍스트를 캐시해서 재사용합니다.

하지만 환경 (activeProfiles, 빈 구성 등..)이 다를 경우 다음과 같이 캐시된 녀석을 재사용하지 못하고 컨텍스트를 새로 띄웁니다. (SpringApplication.run()이 매번 재실행된다고 생각하시면 됩니다.)

환경이 같은 경우 캐시된 컨텍스트를 사용한다.
스프링 애플리케이션이 재실행되는 모습

위 문제들은 테스트 코드가 추가 되면 추가될수록 상황이 악화되는 문제점이 있었기 이 방식을 사용할 수는 없었습니다.

이번에 배치를 고도화하면서 이런 단점을 없앨 수 있는 테스트 방법을 찾아야만했습니다.

 

CustomJobLauncherTestUtils 만들기

기존에 JobLauncherTestUtils를 사용했는데, 이 방식으로는 하나의 컨텍스트를 이용해서 테스트를 돌릴 수 없습니다.

JobLauncherTestUtils은 빈으로 등록할때 Job에 대한 의존성을 setter를 통해서 주입받는데, 여러개의 Job들이 빈으로 띄워져있을 경우 오류가 발생하기 때문입니다.

JobLauncherTestUtils 코드 중 일부 발췌
Job이 두개 이상 빈에 등록된 경우 오류가 발생한다.

해결하기 위해서는 Job에 대한 의존성을 자동으로 주입 받지 않고 순수 자바 코드를 통해서 주입해주면 됩니다.

 

다음은 CustomJobLauncherTestUtils입니다. 기본 코드는 모두 똑같고 의존성 주입 부분만 코드를 수정했습니다.

@Component
public class CustomJobLauncherTestUtils {

    private final SecureRandom secureRandom = new SecureRandom();

    protected final Log logger = LogFactory.getLog(getClass());

    private final JobLauncher jobLauncher;

    private final JobRepository jobRepository;

    private StepRunner stepRunner;

    private Job job;

    // (1)
    public CustomJobLauncherTestUtils(final JobLauncher jobLauncher, final JobRepository jobRepository) {
        this.jobLauncher = jobLauncher;
        this.jobRepository = jobRepository;
    }

    // (2)
    public void setJob(Job job) {
        this.job = job;
    }

    public JobRepository getJobRepository() {
        return jobRepository;
    }

    public Job getJob() {
        return job;
    }

    public JobLauncher getJobLauncher() {
        return jobLauncher;
    }

    public JobExecution launchJob() throws Exception {
        return this.launchJob(this.getUniqueJobParameters());
    }

    public JobExecution launchJob(JobParameters jobParameters) throws Exception {
        return getJobLauncher().run(this.job, jobParameters);
    }

    public JobParameters getUniqueJobParameters() {
        Map<String, JobParameter> parameters = new HashMap<>();
        parameters.put("random", new JobParameter(this.secureRandom.nextLong()));
        return new JobParameters(parameters);
    }
    public JobParametersBuilder getUniqueJobParametersBuilder() {
        return new JobParametersBuilder(this.getUniqueJobParameters());
    }

    protected StepRunner getStepRunner() {
        if (this.stepRunner == null) {
            this.stepRunner = new StepRunner(getJobLauncher(), getJobRepository());
        }
        return this.stepRunner;
    }

    public JobExecution launchStep(String stepName) {
        return this.launchStep(stepName, this.getUniqueJobParameters(), null);
    }

    public JobExecution launchStep(String stepName, ExecutionContext jobExecutionContext) {
        return this.launchStep(stepName, this.getUniqueJobParameters(), jobExecutionContext);
    }

    public JobExecution launchStep(String stepName, JobParameters jobParameters) {
        return this.launchStep(stepName, jobParameters, null);
    }

    public JobExecution launchStep(String stepName, JobParameters jobParameters, @Nullable ExecutionContext jobExecutionContext) {
        if (!(job instanceof StepLocator)) {
            throw new UnsupportedOperationException("Cannot locate step from a Job that is not a StepLocator: job="
                + job.getName() + " does not implement StepLocator");
        }
        StepLocator locator = (StepLocator) this.job;
        Step step = locator.getStep(stepName);
        if (step == null) {
            step = locator.getStep(this.job.getName() + "." + stepName);
        }
        if (step == null) {
            throw new IllegalStateException("No Step found with name: [" + stepName + "]");
        }
        return getStepRunner().launchStep(step, jobParameters, jobExecutionContext);
    }
}
  • (1):  setter를 통해 의존 관계 자동 주입을 받는것을 생성자 주입으로 변경함.
  • (2):  의존 관계 자동 주입을 없앰. 순수 자바 코드로 의존 관계를 주입할 것임.

 

통합 테스트 환경 셋업

 

공통으로 사용할 통합 테스트 환경입니다. 

@ActiveProfiles("test")
@SpringBootTest
@EnableBatchProcessing
public abstract class AbstractBatchIntegrationTest {

    @Autowired
    CustomJobLauncherTestUtils jobLauncherTestUtils;

    @Autowired
    DatabaseCleanup databaseCleanup;

    @Autowired
    ApplicationContext ac;

    JobExecution jobExecution;

    @AfterEach
    void tearDown() {
        databaseCleanup.execute(); // (1)
    }

    protected void launchJob(String jobName) { 
        final Job job = ac.getBean(jobName, Job.class); // (2)
        jobLauncherTestUtils.setJob(job);
        try {
            jobExecution = jobLauncherTestUtils.launchJob();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    protected void launchJob(String jobName, JobParameters jobParameters) {
        final Job job = ac.getBean(jobName, Job.class);
        jobLauncherTestUtils.setJob(job);
        try {
            jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    protected void checkSuccessJob() {
        assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
    }
}
  • (1): 컨텍스트를 공유해서 사용하려면 데이터 격리가 잘 되어야합니다. 해당 코드는 개발자마다 방식이 다르기 때문에 코드는 생략했습니다. (깃허브에서 확인 가능)
  • (2): 애플리케이션 컨텍스트에서 해당 jobName을 받으면 해당 이름으로 등록된 빈의 Job을 찾아서 실행합니다.
  • @SpringBatchTest 어노테이션은 사용하지 않습니다. 해당 어노테이션은 JobLauncherTestUtils을 빈으로 등록하기 때문에 에러가 발생합니다.

실제 테스트 코드는 다음과 같이 만들 수 있습니다. 이전에 비해 더 깔끔해졌고 스프링 컨텍스트가 재실행되지 않는다.

class JdbcBatchItemWriterJobConfigTest extends AbstractBatchIntegrationTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    OrderRepository orderRepository;

    @Test
    @DisplayName("상품 기반으로 주문을 생성하고 저장한다.")
    void 상품_기반_주문_생성() {
        for (int i = 0; i < 10; i++) {
            productRepository.save(new Product(10_000L, "kubernetes"));
        }

        launchJob(JdbcBatchItemWriterJobConfig.JOB_NAME);
        checkSuccessJob();

        final List<Order> orders = orderRepository.findAll();
        assertThat(orders.size()).isEqualTo(10);
        assertThat(orders.get(0).getAmount()).isEqualTo(10_000L);
        assertThat(orders.get(0).getAddress()).isEqualTo("kubernetes");
    }
}

 

전체 코드는 깃허브에서 확인 가능합니다.

 

GitHub - brick0123/spring-boot-in-action: 📚 spring pratice repository

📚 spring pratice repository. Contribute to brick0123/spring-boot-in-action development by creating an account on GitHub.

github.com

 

아직 배치 테스트가 적어서, 추가해야될 내용이 있을 수 있습니다.

사용하다가 보완할 점이 생기면 내용 추가하도록하겠습니다.

 

피드백이나 댓글 언제든 환영합니다.