11. Unit Testing

다른 애플리케이션들과 마찬가지로, 배치 잡의 코드를 단위 테스트는 매우 중요하다. 스프링 코어 문서에는 스프링을 사용한 단위 및 통합 테스트 방법이 매우 자세히 설명되어 있으므로, 여기서 반복하지 않는다. 그러나, 이 장에서 다루는 배치 잡을 “종단 간(end to end)” 테스트 방법에 대해 생각해보는 것은 중요하다. spring-batch-test 프로젝트에는 이러한 종단 간(end to end) 테스트 접근 방식을 용이하게 만드는 클래스가 포함되어 있다.

11.1. Creating a Unit Test Class

단위 테스트에서 배치 잡을 실행하려면, 프레임워크가 잡이 포함된 애플리케이션컨텍스트(ApplicationContext)를 로드해야 한다. 이 동작을 트리거하는 데 두 개의 어트리뷰트가 사용된다:

  • @SpringJUnitConfig는 클래스가 스프링의 JUnit 기능을 사용해야 함을 나타낸다.
  • @SpringBatchTest는 테스트 컨텍스트에 스프링 배치 테스트 유틸리티(예: 잡런처테스트유틸(JobLauncherTestUtils)잡리포지터리테스트유틸(JobRepositoryTestUtils))를 주입한다.

테스트 컨텍스트에 단일 잡 빈이 포함된 경우 이 빈은 잡런처테스트유틸(JobLauncherTestUtils)에서 오토와이어드(autowired) 된다. 그렇지 않으면, 테스트 중인 잡을 잡런처테스트유틸(JobLauncherTestUtils)에서 수동으로 설정해야 한다.

다음 자바 예제는 어노테이션 사용을 보여준다: 자바 구성

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

다음 XML 예제는 어노테이션 사용을 보여준다: XML 구성

    @SpringBatchTest
    @SpringJUnitConfig(locations = { "/simple-job-launcher-context.xml", "/jobs/skipSampleJob.xml" })
    public class SkipSampleFunctionalTests { ... }

11.2. End-To-End Testing of Batch Jobs

“종단간(End To End)” 테스트는 배치 잡의 전체 실행을 처음부터 끝까지 테스트하는 것으로 정의할 수 있다. 이를 통해 테스트 조건을 설정하고 잡을 실행하며 최종 결과를 확인하는 테스트가 가능하다.

데이터베이스에서 읽고 플랫 파일에 쓰는 배치 잡의 예를 생각해보자. 테스트 방법은 테스트 데이터로 데이터베이스를 설정하는 것부터 시작된다. CUSTOMER 테이블을 비운 다음 10개의 새 레코드를 삽입한다. 그런 다음 테스트에서는 launchJob() 메서드를 사용하여 잡을 시작한다. launchJob() 메서드는 잡런처테스트유틸(JobLauncherTestUtils) 클래스에서 제공된다. 잡런처테스트유틸(JobLauncherTestUtils) 클래스는 테스트에서 특정 파라미터를 제공할 수 있는 launchJob(JobParameters) 메서드도 제공한다. launchJob() 메서드는 잡익스큐션(JobExecution) 객체를 반환한다. 이는 잡 익스큐션에 대한 특정 정보를 확인하는 데 유용하다. 다음 경우 테스트에서는 잡이 COMPLETED 상태로 종료되었는지 확인한다.

다음은 XML 구성의 JUnit 5를 사용한 예를 보여준다: XML 구성

    @SpringBatchTest
    @SpringJUnitConfig(locations = { "/simple-job-launcher-context.xml", "/jobs/skipSampleJob.xml" })
    public class SkipSampleFunctionalTests {
        @Autowired
        private JobLauncherTestUtils jobLauncherTestUtils;
        private JdbcTemplate jdbcTemplate;

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

        @Test
        public void testJob(@Autowired Job job) throws Exception {
            this.jobLauncherTestUtils.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);
            }
            JobExecution jobExecution = jobLauncherTestUtils.launchJob();

            Assert.assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
        }
    }

다음은 자바 구성의 JUnit 5를 사용한 예를 보여준다: 자바 구성

    @SpringBatchTest
    @SpringJUnitConfig(SkipSampleConfiguration.class)
    public class SkipSampleFunctionalTests {
    
        @Autowired
        private JobLauncherTestUtils jobLauncherTestUtils;
        private JdbcTemplate jdbcTemplate;
     
        @Autowired
        public void setDataSource(DataSource dataSource) {
            this.jdbcTemplate = new JdbcTemplate(dataSource);
        }

        @Test
        public void testJob(@Autowired Job job) throws Exception {
            this.jobLauncherTestUtils.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);
            }
            JobExecution jobExecution = jobLauncherTestUtils.launchJob();
            Assert.assertEquals("COMPLETED", jobExecution.getExitStatus().getExitCode());
        }
  }

11.3. Testing Individual Steps

복잡한 배치 잡의 경우, 종단간(End To End) 테스트를 관리하기 어려울 수 있다. 이러한 경우 각 스텝을 자체적으로 테스트하는 것이 더 유용할 수 있다. 잡런처테스트유틸(JobLauncherTestUtils) 클래스에는 스텝명을 가져와 해당 특정 스텝만 실행하는 launchStep이라는 메서드가 있다. 이 접근 방식을 사용하면 테스트에서 해당 스텝에 대해서만 데이터를 설정하고 결과를 직접 검증할 수 있어 보다 타겟화된 테스트가 가능해집니다. 다음 예에서는 launchStep 메서드를 사용하여 이름별 스텝을 로드하는 방법을 보여준다:

    JobExecution jobExecution = jobLauncherTestUtils.launchStep("loadFileStep");

11.4. Testing Step-Scoped Components

런타임 시 스텝에 구성된 컴포넌트는 스텝 스코프(step scope) 및 레이트 바인딩(late binding)을 사용하여 스텝 또는 잡 익스큐션에서 컨텍스트를 주입하는 경우가 많다. 스텝 익스큐션에 있는 것처럼 컨텍스트를 설정할 수 있는 방법이 없으면, 독립 실행형 컴포넌트로 테스트하기 까다롭다.

이것이 스프링 배치의 두 컴포넌트인 스텝스코프데스트익스큐션리스너(StepScopeTestExecutionListener)스텝스코프테스트유틸(StepScopeTestUtils)의 목표이다. 리스너는 클래스 레벨에 선언되며, 해당 잡은 다음 예제와 같이 각 테스트 메서드에 대한 스텝 익스큐션 컨텍스트를 생성하는 것이다:

    @SpringJUnitConfig
    @TestExecutionListeners( { DependencyInjectionTestExecutionListener.class, StepScopeTestExecutionListener.class })
    public class StepScopeTestExecutionListenerIntegrationTests {
        // 이 컴포넌트는 스텝 스코프로 정의되므로 다음과 같이 사용하지 않으면 주입할 수 없다.
        // 스텝이 활성화 됐다.
        @Autowired
        private ItemReader<String> reader;

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

        @Test
        public void testReader() {
            // 리더(reader)가 초기화되고 입력 데이터에 바인딩된다.
            assertNotNull(reader.read());
        } 
    }

두 개의 테스트익스큐션리스너(TestExecutionListener)가 있다. 하나는 리더(reader)를 주입하기 위해 구성된, 애플리케이션 컨텍스트에서 의존성 주입(dependency injection)을 처리하는 일반 스프링 테스트 프레임워크이다. 다른 하나는 스프링 배치 스텝스코프테스트익스큐션리스너(StepScopeTestExecutionListener)이다. 이는 스텝익스큐션(StepExecution)에 대한 테스트에서 팩토리 메서드를 찾고 이를 테스트 메서드의 컨텍스트로 사용하여 마치 해당 익스큐션이 런타임에 스텝에서 활성화된 것처럼 작동한다. 팩토리 메소드는 시그니처(signature)로 감지된다(스텝익스큐션(StepExecution)을 반환해야 함). 팩토리 메소드가 제공되지 않으면 기본 스텝익스큐션(StepExecution)이 생성된다.

v4.1부터 테스트 클래스에 @SpringBatchTest 어노테이션이 달린 경우 스텝스코프테스트익스큐션리스너(StepScopeTestExecutionListener)잡스코프테스트익스큐션리스너(JobScopeTestExecutionListener)를 테스트 익스큐션 리스너로 가져온다. 앞의 테스트는 다음과 같이 구성할 수 있다:

    @SpringBatchTest
    @SpringJUnitConfig
    public class StepScopeTestExecutionListenerIntegrationTests {
        // 이 컴포넌트는 스텝 스코프로 정의되므로 다음과 같이 사용하지 않으면 주입할 수 없다.
        // 스텝이 활성화 됐다.
        @Autowired
        private ItemReader<String> reader;

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

        @Test
        public void testReader() {
            // 리더(reader)가 초기화되고 입력 데이터에 바인딩된다.
            assertNotNull(reader.read());
        } 
    }

스텝 스코프의 지속 시간이 테스트 메서드 실행 시간이 되도록 하려는 경우 리스너 접근 방식이 편리하다. 보다 유연하지만 침투적인 접근 방식을 위해, 스텝스코프테스트유틸(StepScopeTestUtils)를 사용할 수 있다. 다음 예에서는 위의 예에 표시된 리더(reader)에서 사용 가능한 아이템 수를 계산한다:

    int count = StepScopeTestUtils.doInStepScope(stepExecution,
        new Callable<Integer>() { 
            public Integer call() throws Exception {
                int count = 0;
                while (reader.read() != null) {
                    count++;
                }
                return count;
            }
        });

11.5. Validating Output Files

배치 잡이 데이터베이스에 작성할 때, 데이터베이스를 쿼리하여 출력이 예상한 대로인지 쉽게 확인할 수 있다. 그러나 배치 잡이 파일에 쓰는 경우, 출력을 확인하는 것도 마찬가지로 중요하다. 스프링 배치는 출력 파일 확인을 용이하게 하기 위해 어썰트파일(AssertFile)이라는 클래스를 제공한다. 어썰트파일이퀄(AssertFileEquals)라는 메소드는 두 개의 파일(File) 객체(또는 두 개의 리소스(Resource) 객체)를 가져와 두 파일의 내용이 동일하다는 것을 한 줄씩 확인한다. 따라서, 다음 예와 같이 예상되는 출력으로 파일을 생성하고 이를 실제 결과와 비교할 수 있다:

    private static final String EXPECTED_FILE = "src/main/resources/data/input.txt";
    private static final String OUTPUT_FILE = "target/test-outputs/output.txt";
    AssertFile.assertFileEquals(new FileSystemResource(EXPECTED_FILE), new FileSystemResource(OUTPUT_FILE));

11.6. Mocking Domain Objects

스프링 배치 컴포넌트에 대한 단위 및 통합 테스트를 작성하는 동안 발생하는 또 다른 문제는 도메인 객체를 목(mock)하는 것이다. 좋은 예시는 다음 코드 조각에서 볼 수 있듯이 스텝익스큐션리스너(StepExecutionListener)이다:

    public class NoWorkFoundStepExecutionListener extends StepExecutionListenerSupport {

        public ExitStatus afterStep(StepExecution stepExecution) {
            if (stepExecution.getReadCount() == 0) {
                return ExitStatus.FAILED;
            }
            return null;
        }
    }

프레임워크는 위의 리스너 예제를 제공하고 스텝익스큐션(StepExecution)에서 빈(empty) 읽기 횟수를 확인하여, 수행된 작업이 없음을 나타낸다. 이 예제는 매우 간단하지만, 스프링 배치 도메인 객체가 필요한 인터페이스를 구현하는 클래스를 단위 테스트할 때 발생할 수 있는 문제 타입을 설명하는 데 유용하다. 위 예제의 리스너에 대한 다음 단위 테스트를 생각해보자:

    private NoWorkFoundStepExecutionListener tested = new NoWorkFoundStepExecutionListener();
    
    @Test
    public void noWork() {
        StepExecution stepExecution = new StepExecution("NoProcessingStep",
                    new JobExecution(new JobInstance(1L, new JobParameters(),"NoProcessingJob")));
        stepExecution.setExitStatus(ExitStatus.COMPLETED);
        stepExecution.setReadCount(0);

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

스프링 배치 도메인 모델은 좋은 객체 지향 원칙을 따르기 때문에, 스텝익스큐션(StepExecution)에는 유효한 스텝익스큐션(StepExecution)을 생성하기 위해, 잡인스턴스(JobInstance)잡파라미터(JobParameters)가 필요한 잡익스큐션(JobExecution)이 필요하다. 이는 솔리드 도메인 모델(solid domain model)에서는 좋지만, 단위 테스트를 위한 스텁 객체(stub objects) 생성을 장황하게 만든다. 이 문제를 해결하기 위해 스프링 배치 테스트 모듈에는 도메인 객체 생성을 위한 팩토리인 메타데이터인스턴스팩토리(MetaDataInstanceFactory)가 있다. 이 팩토리가 주어지면 다음 예제와 같이 단위 테스트를 더 간결하게 할 수 있다:

    private NoWorkFoundStepExecutionListener tested = new NoWorkFoundStepExecutionListener();
    @Test
    public void testAfterStep() {
        StepExecution stepExecution = MetaDataInstanceFactory.createStepExecution();
        stepExecution.setExitStatus(ExitStatus.COMPLETED);
        stepExecution.setReadCount(0);

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

간단한 스텝익스큐션(StepExecution)을 생성하기 위한 이전 방법은 팩토리 내에서 사용할 수 있는 편리한 방법 중 하나일 뿐이다. 자바독(Javadoc)에서 전체 메소드 목록을 찾아볼 수 있다.