Develop/spring-batch

[Kotlin] Spring-Batch (JPA 적용) Junit5를 이용한 Test Code 작성

에디개발자 2021. 7. 29. 07:00
반응형

나를 닮았다고 한다...

이번 글에서는 JPA를 적용한 Spring Batch Job을 테스트하는 글을 작성하겠습니다. 

모든 소스는 Github에 올려두었습니다. 

 

 

들어가기 전에

테스트 환경에서의 데이터베이스는 H2, Test Docker Container, Local 환경에 Database 띄우기 등 다양한 방법으로 테스트할 수 있습니다. 하지만 이번 글에서는 실제 DB에 붙었을 경우 테스트 데이터를 생성 삭제하기 위해서는 어떻게 하는지에 대해 작성했습니다.


Job

먼저 JOB을 살펴보겠습니다. 

@Configuration
class SimpleJobConfiguration(
    val jobBuilderFactory: JobBuilderFactory,
    val stepBuilderFactory: StepBuilderFactory,
    val entityManagerFactory: EntityManagerFactory,
    val personRepository: PersonRepository,
    val simpleStepListener: SimpleStepListener,
    val simpleProcessor: SimpleProcessor,
    val simpleWriter: SimpleWriter
) {

    @Bean("${BatchItem.SIMPLE}_JOB")
    fun simpleJob() =
        jobBuilderFactory.get("${BatchItem.SIMPLE}_JOB")
            .start(simpleStep(null))
            .build()

    @Bean("${BatchItem.SIMPLE}_STEP")
    @JobScope
    fun simpleStep(@Value("#{jobParameters[chunkSize]}") chunkSize: Int?) =
        stepBuilderFactory.get("${BatchItem.SIMPLE}_STEP")
            .chunk<Person, Person>(chunkSize!!)
            .reader(reader(null))
            .processor(simpleProcessor)
            .writer(simpleWriter)
            .listener(simpleStepListener)
            .build()

    @Bean("${BatchItem.SIMPLE}_READER")
    @StepScope
    fun reader(@Value("#{jobParameters[pageSize]}") pageSize: Int?): QuerydslPagingItemReader<Person> {
        val reader = QuerydslPagingItemReader(entityManagerFactory) { personRepository.findAllInBatch() }
        reader.pageSize = pageSize!!
        return reader
    }

}

 

이 코드에 대해서는 몇 가지 포인트만 집고 넘어가겠습니다.

  • JobScope, StepScope를 사용하여 JobParameters를 받음
  • QuerydslPagingItemReader로 Querydsl 기반인 Reader를 사용
  • Reader, Processor, Writer 방식 사용

Test Code

Test Code 환경 구성

먼저 Spring Batch에 필요한 Annotation에 대해서 살펴보겠습니다.

@SpringBootTest(  // 1)
    classes = [
        SimpleJobConfiguration::class,  // 2)
        SimpleStepListener::class,      // 2)
        SimpleProcessor::class,         // 2)
        SimpleWriter::class,            // 2)
        P6spyLogMessageFormatConfiguration::class,   // 2)
        TestBatchConfig::class          // 3)
    ]
)
internal class SimpleJobConfigurationTest: BatchTestSupport() {  // 4)

}

 

1) 먼저 @SpringBootTest로 Test에 필요한 의존성을 주입하였습니다.

( SpringBatch 공식문서에는 @ContextConfiguration 가이드되어 있으나 관련 Bean 주입을 하나하나 등록해야하는 불편함이 있어 @SpringBooTest를 선택하였습니다. )

 

2) Test Target이 되는 Job, Step, Listener를 선언합니다. 

( P6spyLogMessageFormatConfiguration 클래스는 Query를 이쁘게 찍어주는 클래스입니다. 관련 설정은 여기를 참조해주세요 ) 

 

3) Batch Test에 필요한 Annotation을 모아놓은 클래스입니다.

@Configuration
@EnableAutoConfiguration    // 3-1)
@EnableBatchProcessing      // 3-2)
@EntityScan("me.practice.kotlinbatch.common.domain.entity")          // 3-3)
@EnableJpaRepositories("me.practice.kotlinbatch.common.repository")  // 3-3)
@Import(QuerydslConfiguration::class)    // 3-4)
class TestBatchConfig {
}

3-1) spring.factories 에 등록된 설정들을 조건에 따라 Bean에 등록하는 Annotation

3-2) 배치 환경을 구성하는 Annotation

3-3) JPA 기반 Batch 이므로 Entity, Repository의 경로를 선언합니다.

3-4) Querydsl-JPA Configuration 클래스 파일을 등록합니다.

@Configuration
class QuerydslConfiguration(@PersistenceContext val entityManager: EntityManager) {

    @Bean
    fun jpaQueryFactory() : JPAQueryFactory = JPAQueryFactory(entityManager)
}

 

 

4) Batch Test를 편리하게 도와주는 클래스입니다.

@SpringBatchTest
open class BatchTestSupport {

    @Autowired
    protected lateinit var jobLauncherTestUtils: JobLauncherTestUtils

    @Autowired
    protected lateinit var entityManagerFactory: EntityManagerFactory

    protected val entityManager: EntityManager by lazy { entityManagerFactory.createEntityManager() }

    /**
     * 유일한 값을 포함하는 Job Parameter Builder를 얻어온다.
     * @return JobParametersBuilder
     */
    protected fun getUniqueParameterBuilder() = jobLauncherTestUtils.uniqueJobParametersBuilder

    /**
     * Job 실행
     * @return JobExecution
     */
    protected fun launchJob() = jobLauncherTestUtils.launchJob()

    /**
     * Job 실행 ( Parameter 포함 )
     * @param jobParameters JobParameters
     * @return JobExecution
     */
    protected fun launchJob(jobParameters: JobParameters) = jobLauncherTestUtils.launchJob(jobParameters)

    /**
     * Entity Save
     * @param entity Any
     * @return Any
     */
    protected fun save(entity: Any): Any {
        entityManager.persist(entity)
        return entity
    }

    /**
     * Entity Save All
     * @param entities List<Any>
     * @return List<Any>
     */
    protected fun saveAll(entities: List<Any>): List<Any> {
        entities.forEach { entity ->
            entityManager.persist(entity)
        }
        return entities
    }

    /**
     * Entity Delete
     * @param entity Any
     * @return Any
     */
    protected fun delete(entity: Any): Any {
        entityManager.remove(entity)
        return entity
    }

    /**
     * Entity Delete All
     * @param entities List<Any>
     * @return List<Any>
     */
    protected fun deleteAll(entities: List<Any>): List<Any> {
        entities.forEach { entity ->
            entityManager.remove(entity)
        }
        return entities
    }
}

 

여기서 포인트는 JpaRepository를 사용하지 않고 EntityManager에서 직접 persist, remove를 사용한다는 점 입니다.

Spring-Batch는 자체적으로 Transactional을 관리하고 있습니다. 그런데 테스트 코드에서 @Transactional을 선언하고 사용하면 아래와 같은 에러가 발생합니다.

Existing transaction detected in JobRepository. Please fix this and try again (e.g. remove @Transactional annotations from client).
java.lang.IllegalStateException: Existing transaction detected in JobRepository. Please fix this and try again (e.g. remove @Transactional annotations from client).

해석하자면 JobRepository에서 이미 Transaction이 존재합니다. @Transactional을 제거하십시오.

 

결론적으로 @Transactional을 사용하지 않고 Job 시작과 후에 Transaction을 시작하고 commit 하기 위해 EntityManager를 이용하였습니다.

 

테스트 코드 데이터 생성

배치 Job에 필요한 테스트 데이터를 생성하고 삭제처리하는 코드를 살펴보겠습니다.

lateinit var people: List<Person>

@BeforeEach
fun before() {
    people = (0..20).map {
        Person(name = "사람$it", address = "주소$it")
    }

    val transaction = entityManager.transaction
    transaction.begin()
    saveAll(people)    // 영속성 맺음
    transaction.commit()
}

@AfterEach
fun after() {
    val transaction = entityManager.transaction
    transaction.begin()
    deleteAll(people)  // 영속성이 맺어져 있어 삭제 가능
    transaction.commit()
    entityManager.clear()  // 영속성 Finish
}

BatchTestSupport 클래스의 매서드를 이용하여 데이터를 저장하고 삭제합니다.

 

개인적으로 deleteAll() 메서드 사용을 권장하지 않습니다. 이유는 테스트환경을 잘 만들어서 실 DB에 문제가 없도록 설계했겠지만 개발자의 실수로 실 DB에 붙어 모든 데이터를 삭제할 수 있수도 있기 때문입니다. 

 

결론은 EntityManager의 remove() 메서드를 사용하여 지정된 Entity의 데이터만 삭제하도록 처리하였습니다. remove() 메서드를 사용하려면 영속성이 맺어져 있어야하므로 @AfterEach 블록에서 EntityManager를 clear 하였습니다. 

 

테스트 코드 작성

Batch Job을 테스트하는 코드를 살펴보겠습니다.

@Test
fun `test job run test`() {
    // Given
    val jobParameters = getUniqueParameterBuilder()  // 아래 코드 참조
        .addLong("chunkSize", 5L)
        .addLong("pageSize", 20L)
        .toJobParameters()

    // When
    val jobExecution = launchJob(jobParameters)  // 아래 코드 참조

    // Then
    Assertions.assertThat(jobExecution.status).isEqualTo(BatchStatus.COMPLETED)

}

Job에서 Parameters를 받도록 설정했기 때문에 Parameter를 설정하고 Job을 Launch합니다. 

 

/**
 * 유일한 값을 포함하는 Job Parameter Builder를 얻어온다.
 * @return JobParametersBuilder
 */
protected fun getUniqueParameterBuilder() = jobLauncherTestUtils.uniqueJobParametersBuilder

/**
 * Job 실행 ( Parameter 포함 )
 * @param jobParameters JobParameters
 * @return JobExecution
 */
protected fun launchJob(jobParameters: JobParameters) = jobLauncherTestUtils.launchJob(jobParameters)

 

전체 테스트 코드

전체 코드를 살펴보겠습니다.

@SpringBootTest(
    classes = [
        SimpleJobConfiguration::class,
        SimpleStepListener::class,
        SimpleProcessor::class,
        SimpleWriter::class,
        P6spyLogMessageFormatConfiguration::class,
        TestBatchConfig::class
    ]
)
internal class SimpleJobConfigurationTest: BatchTestSupport() {

    lateinit var people: List<Person>

    @BeforeEach
    fun before() {
        people = (0..20).map {
            Person(name = "사람$it", address = "주소$it")
        }

        val transaction = entityManager.transaction
        transaction.begin()
        saveAll(people)
        transaction.commit()
    }

    @AfterEach
    fun after() {
        val transaction = entityManager.transaction
        transaction.begin()
        deleteAll(people)
        transaction.commit()
        entityManager.clear()
    }

    @Test
    fun `test job run test`() {
        // Given
        val jobParameters = getUniqueParameterBuilder()
            .addLong("chunkSize", 5L)
            .addLong("pageSize", 20L)
            .toJobParameters()

        // When
        val jobExecution = launchJob(jobParameters)

        // Then
        Assertions.assertThat(jobExecution.status).isEqualTo(BatchStatus.COMPLETED)

    }

}
반응형