개요

실무 프로젝트 시, 테스트코드를 작성하다가 다르게 알게된 부분도 있고, 보일러 플레이트 코드가 양산되는거 같아 정리겸 쓰는 글. 그리고 좀 더 개선된 코드로 가기위한 나름의 몸부림..

  • Include osiv in test context in test code

 

메타애노테이션

테스트 코드를 작성할 때, 스프링부트에서는 특정한 레이어 계층에 대해 테스트코드를 작성할 수 있도록 여러 애노테이션을 제공해주고 있다. 이런 애노테이션들을 각각 별도의 커스텀한 메타애노테이션을 만들어보았다. 

 

관련 코드

/**
 * 테스트 관련 메타 애노테이션에서 사용하기 위한 최상위 애노테이션
 */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ActiveProfiles("test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
annotation class TestEnvironment


/**
 * @Controller, @Service, @Repository 를 TestContext 에 띄어놓고 테스트하기 위한 메타애노테이션
 * - 각 테스트 컨텍스트마다 테스트 격리를 위한 @Transactional 을 붙여준다.
 * - TestRestTemplate 도 별도로 사용이 가능한다.
 *   - TestRestTemplate 은 별도의 서블릿 컨테이너 내에서 실행, 요청당 별도의 스레드가 만들어진다. (해당 테스트코드가 있을 시, 트랜잭션 롤백이 되지 않음)
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Transactional
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(value = [TestObjectMapperConfiguration::class])
annotation class IntegrationSupport


/**
 * @Controller 계층만 테스트하기 위한 메타애노테이션
 * - 나머지 레이어 계층 영역은 mocking 해주어야 함
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@WebMvcTest
@Import(value = [TestObjectMapperConfiguration::class])
annotation class WebLayerSupport


/**
 * @Repository 를 테스트하기 위한 메타애노테이션
 * - 자동롤백된다.
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@DataJpaTest
@Import(JpaAuditingBaseConfiguration::class)
annotation class RepositorySupport


/**
 * @Controller, @Service, @Repository 를 TestContext 에 띄어놓고 테스트하기 위한 메타애노테이션
 * - 디스패처 서블릿 단위까지 테스트가 가능하기 때문에 필터, 인터셉터까지 확인할 수 있다.
 * - 각 테스트 컨텍스트마다 테스트 격리를 위한 @Transactional 을 붙여준다.
 * - @AutoConfigureMockMvc 를 통해서 MockMvc 에 대한 의존성을 받는다.
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Transactional
@Import(value = [
    JpaAuditingBaseConfiguration::class,
    TestObjectMapperConfiguration::class
])
annotation class MockMvcSupport


/**
 * 단순 mock 객체만을 만들어서 테스트하기 위한 메타애노테이션
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Import(JpaAuditingBaseConfiguration::class)
annotation class SimpleMockSupport

@SpringBootTest 의 웹환경 속성

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 은 SpringBootTest 의 webEnvironment 의 디폴트 값이다. 해당 값이 MOCK 이면 서블릿 컨테이너 mocking 한 서블릿 컨테이너를 띄우겠단 의미이다. 그 상태에서 @AutoConfigureMockMvc 를 붙이게 되면 서블릿 컨테이너를 테스트할 수 있게 MockMvc 에 대한 의존성이 주어진다. 단순 @SpringBootTest 를 붙여서 테스트하는 것보단 거기다 @AutoConfigureMockMvc 를 붙여서 테스트 하는 것이 더 넓은 범위의 테스트가 가능해진다.

 

 

테스트 환경에서 @Transactional 롤백

각 TestContext 내 데이터가 쌓이면 별도의 롤백을 해주어야 하는데 @Transactional 을 붙이면 각 테스트 메소드가 끝날 시에 롤백 Rolled back transaction for test 문구와 함께 롤백처리 된다. 하지만 @SpringBootTest 에서 SpringBootTest.WebEnvironment.RandomPort 를 주게 되면 @Transactional 을 붙여도 별도의 스레드에서 디비 저장을 해서 롤백이 안된다고 한다!! 무조건 안되는게 아니라, 더 정확하게 말하면 TestRestTemplate 을 가지고 테스트 했을 때 롤백이 안되는 것이다.

 

 

TestRestTemplate

SpringBootTest.WebEnvironment.RandomPort 를 줄 때에 롤백이 안된다는 것보단, @SpringBootTest 내에 같이 포함되어 있는 TestRestTemplate 의존성을 가지고 테스트를 수행하는 경우에만 롤백이 안된다는 걸 확인할 수 있었다. 아래코드 내 Order(2) 의 테스트 결과가 Order(3) 에 영향을 끼치고 있다. TestRestTemplate 이 서블릿 컨테이너를 초기화해서 사용하고 요청당 스레드를 별도로 서블릿에서 만들어 처리하기 때문이다. (그래서 많은 글들에서 그렇다고 하지 않았을까 생각..)

package com.example.springboottestcodebasis.domain.member.api

import com.example.IntegrationSupport
import com.example.springboottestcodebasis.constant.Constant
import com.example.springboottestcodebasis.domain.member.model.Member
import com.example.springboottestcodebasis.domain.member.repository.MemberRepository
import com.fasterxml.jackson.databind.ObjectMapper
import io.kotest.assertions.asClue
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.postForEntity
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.http.HttpEntity
import org.springframework.http.HttpStatus
import org.springframework.util.LinkedMultiValueMap
import java.time.LocalDate


@IntegrationSupport
@DisplayName("memberController2 는")
class MemberControllerTest2(
    private val objectMapper: ObjectMapper,
    private val testRestTemplate: TestRestTemplate,
    private val memberController: MemberController,
    private val memberRepository: MemberRepository,

    @LocalServerPort
    private var port: Int,
) {

    @BeforeEach
    fun init() {
        println("current port : $port")
    }


    @Test
    @DisplayName("[1] Controller 클래스를 통해 멤버를 생성한다.")
    @Order(1)
    fun createTest() {

        // given
        val member = Member("세종대왕", 55)

        // when
        val savedMember = memberController.create(member).body!!

        // then
        savedMember.id shouldBe 1L
        memberRepository.findAll().first().asClue {
            it.name shouldBe "세종대왕"
            it.age shouldBe 55
            it.createdAt!!.toLocalDate() shouldBe LocalDate.now()
            it.modifiedAt!!.toLocalDate() shouldBe LocalDate.now()
        }
    }

    @Test
    @DisplayName("[2] TestRestTemplate 을 통해 멤버를 생성한다.")
    @Order(2)
    fun createTestRestTemplateTest() {

        // 아래와 같이 서블릿 컨테이너가 실행된다. (테스트 컨텍스트의 메인 스레드가 아닌 별도 스레드에서 동작)
        // 2021-11-25 21:59:38.158  INFO 63375 --- [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
        // 2021-11-25 21:59:38.158  INFO 63375 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
        // 2021-11-25 21:59:38.174  INFO 63375 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 16 ms

        // given
        val member = Member("강감찬", 30)

        // when
        val headers = LinkedMultiValueMap<String, String>().apply{
            this.add("Content-Type", "application/json")
            this.add(Constant.PermissionHeader.KEY, Constant.PermissionHeader.ADMIN.VALUE)
        }
        val request: HttpEntity<String> = HttpEntity(objectMapper.writeValueAsString(member), headers)
        val memberResponse = testRestTemplate
            .postForEntity<Member>("/members", request, Member::class)

        // then
        memberResponse.statusCode shouldBe HttpStatus.OK
        memberResponse.body!!.asClue {
            it.name shouldBe "강감찬"
            it.age shouldBe 30
        }
    }

    @Test
    @DisplayName("[3] TestRestTemplate 을 통해 멤버를 생성하지만, 헤더가 없는 문제로 권한 에러가 발생한다.")
    @Order(3)
    fun createTestRestTemplateThrowTest() {

        // given
        val memberRequest = Member("루피", 18)

        // when
        val exception = testRestTemplate
            .postForEntity<String>("/members", memberRequest)

        // then
        exception.statusCode shouldBe HttpStatus.INTERNAL_SERVER_ERROR
    }

    @Test
    @DisplayName("[100] 따로 데이터를 넣지 않았지만 " +
            "testRestTemplate 의 별도 스레드에서 데이터를 넣었기 때문에 롤백 동작이 안된다. " +
            "그래서 데이터는 존재한다.")
    @Order(100)
    fun findAllTest() {

        // given
        val members = memberRepository.findAll()

        // then
        members.isNotEmpty() shouldBe true
        members.size shouldBe 1
        members.first().asClue {
            it.name shouldBe "강감찬"
            it.age shouldBe 30
        }
    }
}

 

통합테스트에서 @Transactional 을 붙이게 된다면...

보통 실무 프로젝트에서는 open-in-session-view(=osiv) 를 꺼둔채 서비스가 된다. 다만 테스트코드 상에서는 @Transactional 로 TestContext 상에서 롤백전략을 가져가다 보니, 해당 트랜잭션 전파가 테스트코드 내 통합테스트의 컨트롤러 영역까지 범위에 속하게 된다. 그 범위에 속하다 보니 lazy exception 이 발생하는지 여부를 판단하기가 애매하다. 아래의 코드는 그 예시이다.

 

 

@IntegrationSupport 를 붙인 IntegrationSupportTestIncludeTx 클래스는 테스트 메소드가 종료되면 롤백이 적용된다. 트랜잭션이 적용이 되어서 컨트롤러까지 트랜잭션이 전파되어 lazyException 이 발생하지 않는다.  반면에 IntegrationSupportTestExcludeTx 클래스는 lazyException 이 적용되서 에러가 발생한다. 차이점은 무엇인가? IntegrationSupportTestExcludeTx 에는 @Transactional  전파를 하는지 여부 이다.

 

(+ 사전에 코드는 post : comment = 1 : N 관계로 잡아놓은 상태. OneToMany(fetch = FetchType.LAZY) 가 되어있다.)

@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Transactional
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(value = [TestObjectMapperConfiguration::class])
annotation class IntegrationSupport

@DisplayName("[IntegrationSupport] PostControllerTest 는 osiv 를 꺼둔 상태이다.")
class PostControllerTest {

    @Nested
    @DisplayName("트랜잭션 처리가 최상위에 있는 통합테스트를 수행한다.")
    @IntegrationSupport
    inner class IntegrationSupportTestIncludeTx(
        private val postController: PostController,
        private val commentController: CommentController
    ) {

        @Test
        @DisplayName("Post 조회 시, Comment 도 잘 조회된다.")
        fun test() {

            // given
            val postRequest = PostResources.CreateRequest(
                title = "게시글 1",
                content = "내용 1"
            )
            val postId = postController.create(postRequest).body!!

            val commentRequest = CommentResources.CreateRequest(
                content = "댓글 1"
            )
            commentController.create(postId, commentRequest)

            // when
            val response = postController.findOneById(postId).body!!

            // then
            response.asClue {
                it.id shouldBe postId
                it.title shouldBe "게시글 1"
                it.content shouldBe "내용 1"
                it.comments.size shouldBe 1
                it.comments.first().content shouldBe "댓글 1"
            }
        }
    }

    @Nested
    @DisplayName("트랜잭션 전파를 하지 않는 통합테스트를 수행한다.")
    @IntegrationSupport
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    inner class IntegrationSupportTestExcludeTx(
        private val postController: PostController,
        private val commentController: CommentController
    ) {

        @Test
        @DisplayName("Post 조회 시, Comment 에 대한 프록시 Lazy Exception 이 발생한다.")
        fun test() {

            // given
            val postRequest = PostResources.CreateRequest(
                title = "게시글 1",
                content = "내용 1"
            )
            val postId = postController.create(postRequest).body!!

            val commentRequest = CommentResources.CreateRequest(
                content = "댓글 1"
            )
            commentController.create(postId, commentRequest)

            // when
            val exception = assertThrows<LazyInitializationException> {
                postController.findOneById(postId)
            }

            // then
            exception.message shouldContain "could not initialize proxy - no Session"
        }
    }
}

테스트 결과

나는 @Transactional 을 붙여도 전파를 하지 않도록 설정해주었다. 이러면 osiv 가 false 로써 작동된다. lazy exception 이 발생한다.

근데 아예 @Transactional 을 제거하고 데이터도 테스트 메소드 수행마다 정리하고 싶다.

 

테스트 라이프 사이클

나는 아래의 라이프 사이클에 @AfterEach 부분에 별도의 익스텐션 클래스를 만들어 @AfterEach 부분을 호출하는 콜백 인터페이스 구현체를 별도로 만들었다. (설명이 복잡한데 코드로 바로 보자.)

https://howtodoinjava.com/junit5/junit-5-test-lifecycle/

 

아래가 내가 새롭게 만든 코드이다. BeforeEachCallback 과 AfterEachCallback 의 메소드를 새롭게 재정의하는 클래스를 만들었다. 그리고 이 클래스는 @ExtendWith(TruncateDbExtension::class) 로 확장해서 메타애노테이션에 붙일 수 있었다. 코드를 설명하자면 afterEach 메소드 내에서 ApplicationContext 내 entityManager 까지 추출한다. 추출된 엔티티 매니저로 테이블 명을 돌면서 truncate 수행해주었다. => 이렇게 했더니 굳이 @Transactional 을 붙이지 않더라도, 롤백전략을 적용하지 않더라도 테스트 컨텍스트에 남은 잔여데이터를 정리하고 테스트 코드간에 데이터 참조가 되는 걸 방지할 수 있었다. 후...

/**
 * https://www.baeldung.com/junit-5-extensions
 * https://stackoverflow.com/questions/34617152/how-to-re-create-database-before-each-test-in-spring
 */
class TruncateDbExtension: BeforeEachCallback, AfterEachCallback {

    companion object: KLogging()

    override fun beforeEach(context: ExtensionContext?) {
        logger.info { "" }
        logger.info { "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@" }
        logger.info { "@@@@@@@@@@@@ before each @@@@@@@@@@@@" }
        logger.info { "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@" }
        logger.info { "" }
    }

    override fun afterEach(context: ExtensionContext?) {
        val applicationContext = SpringExtension.getApplicationContext(context!!)
        val entityManagerFactory = applicationContext.getBean(EntityManagerFactory::class) as EntityManagerFactory
        val entityManager = entityManagerFactory.createEntityManager()

        try {
            with(entityManager) {
                this.transaction.begin()
                this.clear()
                this.createNativeQuery("SET FOREIGN_KEY_CHECKS=0;").executeUpdate()
                this.truncateAllTables()
                this.createNativeQuery("SET FOREIGN_KEY_CHECKS=1;").executeUpdate()
                this.transaction.commit()
            }
        } catch (exception: Exception) {
            logger.error { "truncateDbSupport Error : ${exception.message}" }
            entityManager.transaction.rollback()
        }

        logger.info { "" }
        logger.info { "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@" }
        logger.info { "@@@@@@@@@@@@ after each @@@@@@@@@@@@" }
        logger.info { "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@" }
        logger.info { "" }
    }
}

fun EntityManager.truncateAllTables() {
    val tableNames = this.metamodel.entities
        .filter { entity -> entity.persistenceType == Type.PersistenceType.ENTITY  }
        .map { entity -> entity.name.replace("entity_", "") }

    tableNames.forEach { tableName ->
        this.createNativeQuery("TRUNCATE TABLE $tableName").executeUpdate()
    }
}

/**
 * test context 내 공유된 데이터를 초기화한다. : 테스트 격리를 위함
 * BeforeEach 혹은 AfterEach 에서 truncate 가 동작될 수 있도록 한다.
 */
@ExtendWith(value = [TruncateDbExtension::class])
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class TruncateDbSupport(
    val truncateCycle: TruncateCycle = TruncateCycle.AFTER_TEST_METHOD
)

/**
 * @IntegrationSupport 에 @Transactional 이 적용되지 않은 상태
 */
@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@TruncateDbSupport(truncateCycle = TruncateCycle.BEFORE_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(value = [TestObjectMapperConfiguration::class])
annotation class IntegrationSupportWithTruncateDb

 

@TruncateDbSupport 를 통해서 @Transactional 을 안붙이고 데이터 초기화 + osiv 를 false 로 해놓고 테스트를 했다. 그럼 컨트롤러까지 전파되어 lazyException 이 발생이 된다. 내가 의도한대로 작동하고 있다. 이걸 한번의 메타애노테이션으로 감싸주었다. @IntegrationSupportWithTruncateDb 해당 애노테이션을 붙이고 통합테스트를 하는 코드에 여기저기 붙여서 쓸 수 있다.

 

추가적으로 truncate 자체가 사실 dml (데이터 조작어) 보다는 ddl (데이터 정의어) 느낌이 크다. drop -> create 로 동작되기 때문이다. 그래서 깔끔하게 auto_increment 로 설정된 PK 또한 1부터 초기화 될 것이다. 더불어 앞선 TestRestTemplate 을 통해 테스트 하면 별도의 요청 스레드에 생성된 데이터로 인해 TestContext 에서 롤백을 하여도 잔여 데이터가 남는 문제도 해결할 수 있었다.

 

@AutoConfigure MockMvc 로 테스트할 때도 @Transactional 을 붙이면 어떻게 될까?

결론은 이때도 osiv 설정을 해도 테스트는 제대로 동작하지 않는다.

코드를 보면, 마지막 DynamicTest 에서 원래는 실패가 떨어지는게 맞다. osiv 를 꺼두고 테스트하니깐. 하지만 @MockMvcSupport 에 @Transactional 이 같이 붙어있기 때문에 테스트 성공이 뜬다. @Transactional 을 제거하면 lazyException 이 뜨는 것을 확인할 수 있다.

@TestEnvironment
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Transactional
@Import(value = [
    JpaAuditingBaseConfiguration::class,
    TestObjectMapperConfiguration::class
])
annotation class MockMvcSupport

@DisplayName("[MockMvcSupport] PostController 는 osiv 를 꺼둔 상태이다.")
class PostControllerTest2 {

    @Nested
    @DisplayName("트랜잭션 처리가 최상위에 있는 mockMvc 테스르를 수행한다. : lazyException 발생")
    @MockMvcSupport
    inner class IntegrationSupportTestIncludeTx(
        private val mockMvc: MockMvc,
        private val objectMapper: ObjectMapper,
    ) {

        @TestFactory
        @DisplayName("Post 조회 시, Comment 에 대한 프록시 Lazy Exception 이 발생한다.")
        fun test(): List<DynamicTest> {

            var postId = 0L

            return listOf(
                DynamicTest.dynamicTest("게시글을 저장한다.") {

                    // given
                    val postRequest = PostResources.CreateRequest(
                        title = "게시글 1",
                        content = "내용 1"
                    )
                    val jsonString = objectMapper.writeValueAsString(postRequest)

                    // when
                    val result = mockMvc.perform(MockMvcRequestBuilders.post("/posts")
                        .content(jsonString)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                    )

                    // then
                        .andExpect(MockMvcResultMatchers.status().is2xxSuccessful)
                        .andDo(MockMvcResultHandlers.print())
                        .andReturn()

                    postId = result.response.contentAsString.toLong()
                    postId shouldNotBe null
                },
                DynamicTest.dynamicTest("특정 게시글에 댓글을 저장한다.") {

                    // given
                    val commentRequest = CommentResources.CreateRequest(
                        content = "댓글 1"
                    )
                    val jsonString = objectMapper.writeValueAsString(commentRequest)

                    // when
                    mockMvc.perform(MockMvcRequestBuilders.post("/posts/$postId/comments")
                        .content(jsonString)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                    )

                    // then
                        .andExpect(MockMvcResultMatchers.status().is2xxSuccessful)
                        .andDo(MockMvcResultHandlers.print())
                        .andReturn()
                },
                DynamicTest.dynamicTest("특정 게시글을 댓글과 함께 조회한다.") {

                    // when
                    mockMvc.perform(MockMvcRequestBuilders.get("/posts/$postId")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                    )

                    // then
                        .andExpect(MockMvcResultMatchers.status().is2xxSuccessful)
                        .andDo(MockMvcResultHandlers.print())
                        .andReturn()
                }
            )
        }
    }
}

 

@Transactional 유무에 따른 lazyException 발생 유무 결과다.

@MocMvcSupport 에 @Transactional 이 있을 때의 테스트 결과

 

@MockMvcSupport 에 @Transactional 이 없을 때 테스트 결과

 

그래서 나의 생각은

테스트코드에서 롤백전략을 가져가려고 편하게 @Transactional 을 붙이게 되는데 lazyLoading 부분을 신경쓸 필요가 있다. 비단 영속성 컨텍스트를 view 영역까지 가져가는것 + 테스트 메소드 내에서 findAll() 로 조회한 특정한 엔티티에 연관된 엔티티를 lazy 로 들고오는 것 이 두가지 행위?/방법? 를 사전에 막을 수 있다. 그래서 롤백전략을 위한 트랜잭셔널 애노테이션을 붙이지 않고, 테스트 라이프 사이클때 데이터들을 모두 날려버리는게 현재로썬 가장 나은 선택지라고 생각한다.

 

관련코드

https://github.com/pasudo123/springboot-kotlin-zerotoall/blob/main/springboot-testcode-basis/README.md

 

참고

https://youtu.be/OM_bN4wzd0g 

(나의 고민에 대해 해당 링크에서 소스를 얻을 수 있었다.)

Posted by doubler
,