배치 테스트를 위해서는 Job을 실행시켜야 합니다. 이것을 편리하게 도와주는 것이 launchJob()메서드입니다. JobLauncherTestUtils의 launchJob() 메서드를 한 번 감싸서 사용하는 용도로 특별하건 없습니다. 슈퍼 클래스에서 의존성을 주입받지 않으면 실제 테스트에 계속 DI를 받아 사용해야 하기 때문에 슈퍼 클래스에서 해당 기능을 제공합니다. JobParameters가 필요한 경우 JobParameters를 전달하면 됩니다. 그렇지 않은 경우에는 스프링 배치에서 자체적으로 유니크한 JobParameters을 생성하는 기본값을 지정했습니다.
위 코드처럼 SimpleJobListener로 Job 시작 이전, 이후로 log를 찍는 리스너를 추가하고 launchStep("csvWriterStep") Step을 실행시키면 리스너는 동작하지 않습니다. 반면 launchJob(csvWriterJob)은 잘 동작합니다.
launchJob(csvWriterJob) Log
launchStep(“csvWriterStep”) Log
리스너는 Job에 연결이 된다는 것이지 Step에 연결돼있는 것은 아니며, 위에서 언급했듯 launchStep()은 name=TestJob으로 Job을 실행시키기 때문에 해당 잡에는 리스너가 없어 당연한 결과입니다.
해당 코드는 단순하게 리스너로 로직을 것이지만 해당 Step에 필요한 리스너라면 예상했던 테스트 결과와 다르게 동작할 수 있습니다. 이러한 문제 때문에 저는 아래와 같은 방식으로 Step 테스트합니다.
@Test internalfun `csvWriterStep job을 직접 생성해서 테스트`() { //given ...
//when launchJob(job)
//then ... } }
위 코드는 실제 Job을 생성하고 해당 Job으로 launchJob()메서드를 통해서 Job을 실행시키고 있습니다.
해당 결과 리스너의 로그 및 Job name이 name=csvWriterStepForTestJob으로 테스트를 진행하는 것을 확인할 수 있습니다.
그 밖에도 Job을 직접 생성할 수 있으니 다양한 방법으로 테스트를 진행할 수 있습니다. 예를 들어 특정 step 몇 개를 연결해서 단위 테스트해볼 수 있으며, Flow와 같은 배치 Step의 순서에 대한 Flow를 직접 정의해서 테스트할 수 있습니다.
테스트 데이터 세팅은?
배치 애플리케이션 여러 여러 테이블의 데이터를 읽어 오고, 여러 테이블에 데이터를 저장하는 경우가 빈번합니다. 이런 경우 테스트를 작성하기 위한 given절에 해당하는 데이터 세팅이 많이 번거롭습니다.
JPA 기반 세팅
테스트를 진행할 때 given을 JPA 기반으로 작성하기 위해서는 Repositroy를 주입받아 save 해서 테스트하는 것이 일반적입니다. 위에서도 언급했지만 여러 테이블의 조회가 필요하니 그 필요한 테이블만큼 Repositroy를 주입 받아야 하는데 이것이 생각보다 귀찮고 코딩의 흐름을 방해합니다.
테스트 코드를 작성하다 ‘아 데이터가 필요하네…’ 하고 다시 테스트 클래스 상위로 올라가 필요한 Repositroy를 주입받고 다시 save를 진행하는 것은 코딩의 흐름을 많이 방해한다고 생각합니다.
위 테스트 코드는 Repositroy를 주입받아 테스트, BatchTestSupport를 기반으로 테스트하는 코드입니다. 해당 코드는 단순하지만 테스트에 필요한 Repositroy가 많아지면 BatchTestSupport 기반으로 테스트하는 것이 효율적입니다.
SQL 기반 세팅
프로젝트가 JPA 기반으로 진행하고 있다면 코드 냥이 많아 지나더라도 JPA 기반으로 테스트하는 것이 장기적으로 좋다고 생각한다. 하지만 JPA로 데이터 셋업이 번거롭고, 특정 시점으로 데이터를 Set Up 하기에는 어려운 부분이 있다. 그런 경우 유용한 방법이 @Sql을 기반으로 데이터를 세팅하는 것이다.
@Test @Sql("/csv-setup.sql") internalfun `csvWriterJob sql 테스트 진행`() { //given
//when launchJob(csvWriterJob)
//then thenBatchCompleted()
deleteAll(QPayment.payment) }
/test/resources/csv-setup.sql 해당 경로에 SQL 파일을 위치시키고 @Sql으로 해당 경로를 지정하면 해당 SQL 기반으로 데이터를 세팅합니다. @Sql는 테스트 메서드 단위로 실행되기 때문에 편리하지만 SQL의 단순 문자열 기반으로 관리되기 때문에 엔티티가 변경되는 경우 유지 보수가 어려운 부분들이 있습니다. 해당 엔티티가 변경될 일이 거의 없거나. 데이터의 특정 시점을 객체 기반으로 만들기 어려운 경우 사용하는 것을 권장합니다.
테스트 검증은?
테스트 검증 시 여러 테이블에 대한 변경 작업이 발생했을 경우 모든 변경에 대한 테스트를 작성해야 합니다. 우선 then에서 검증할 엔티티의 개수만큼 Repositroy를 주입받아야 합니다. 이는 위에서도 언급했듯이 코딩의 흐름에 악영향을 미칩니다. 무엇보다 테스트 검증을 위한 조회를 Repositroy를 의존하게 되면 오직 테스트 코드 검증을 위한 조회 로직이 필요하게 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13
@Test internalfun `csvWriterJob 테스트 코드만을 위한 코드`() { //given
//when launchJob(csvWriterJob)
//then thenBatchCompleted() paymentRepository.findByxxxxx() // 오직 테스트 코드엑서만 사용한다
deleteAll(QPayment.payment) }
위 테스트의 검증은 paymentRepository 기반으로 진행하고 있습니다. 그런데 검증을 위해 조회 코드가 없어 만들려고 하는데 이 코드는 오직 테스트 검증 시에만 사용하게 되는 코드입니다. 이렇게 테스트를 위해서 특정 메서드가 테스트 스코프가 아닌 영역에 있다는 거 자체가 좋지 않은 패턴, 설계라고 생각합니다.
이렇게 어려운 부분들을 해결하기 위해서 Query DSL 기반으로 조회를 진행할 수 있는 JPAQueryFactory를 BatchTestSupport에서 지원하고 있습니다.
@Test internalfun `csvReaderJob repository 기반 테스트`() { //given
//when launchJob(csvReaderJob)
//then thenBatchCompleted()
val payments = paymentRepository.findByOrderId(1L)
then(payments).hasSize(9)
deleteAll(QPayment.payment) }
@Test internalfun `csvReaderJob JPAQueryFactory 기반 테스트`() { //given
//when launchJob(csvReaderJob)
//then thenBatchCompleted()
val payments = query.selectFrom(QPayment.payment) .where(QPayment.payment.orderId.eq(1L)) .fetch()
then(payments).hasSize(9)
deleteAll(QPayment.payment) } }
repository 기반으로 테스트 진행할 할 때, 당연하지만 해당 Repositroy를 주입받아야 하며 검증 조회 메서드가 없는 경우 오직 테스트 코드에서 사용하기 위해서 메서드를 구현해야 합니다. 검증할 엔티티가 많아지면 그만큼 위 작업을 반복해야 합니다.
JPAQueryFactory 기반으로 테스트는 Query DSL 방식으로 검증을 진행하기 때문에 불필요한 DI, 조회 검증 메서드는 테스트에서 직접 구현해서 이런 문제를 해결할 수 있습니다.
테스트 검증 이후 데이터 제거는 ?
스프링 배치 애플리케이션은 @Transactional으로 시작할 수 없습니다. 그 결과 테스트 메서드가 끝난 이후에 자동으로 해당 데이터가 롤백 되지 않으며 데이터가 남아 있어 다른 테스트 코드에 영향을 주게 됩니다. 그러기 때문에 테스트가 끝난 이후에 데이터를 제거하는 작업을 해야 합니다. 반복적인 이야기이지만 Repositroy 기반으로 해당 작업을 하기 위해서는 의존성을 주입받아야 합니다. 이것들을 쉽게 처리할 수 있게 BatchTestSupport에서 deleteAll() 메서드를 지원합니다.
테스트 코드에서 트랜잭션을 물고 시작하면 위와 같은 트랜잭션 원자성이 하나로 묶이게 됩니다. 그렇다면 문제가 생겨서 롤백이 발생하면 어떻게 될까요? 하나의 원자성을 가지고 있기 때문에 Meta-Data Schema에 저장돼있는 데이터까지 모두 롤백 되게 됩니다. 해당 저장소에는 배치에 실패에 대한 기록도 남겨야 하기 때문에 이는 문제가 되기 때문에 이러한 경우 예외가 발생하는 것이라고 생각합니다.
그동안 배치 테스트를 작성하면서 느낀 점들
그동안 스프링 배치 테스트 코드 작성에 대한 나름의 고찰을 정리해 보았습니다. 배치 애플리케이션 테스트 코드도 다른 환경에서의 테스트 코드 작성 방식과 전체적인 방향성은 크게 다르지 않다고 생각합니다. 스프링 배치라는 프레임워크를 통해서 조금 더 쉽고 안전하게 테스트할 수 있는 방향성에 대해서 포스팅해보았습니다.
스프링 배치 프레임워크를 사용하기 때문에 테스트 코드 작성 시에도 제약사항을 받는 부분이 있었고 이를 원천적으로 해결하기에는 아직도 역량이 많이 부족하다는 생각이 들었습니다. 그래도 이러한 부분들에 대해서 정리가 저와 같은 고민을 했던 분들에게 조금이라도 도움이 되기를 기원하겠습니다. 긴 글 읽어주셔서 감사합니다.