2021-11-25 [test] : springboot 에서 테스트를 작성하면서, 내가 간과한 부분. 그리고 좀 더 생각해보기 (open-session-in-view)
Spring 2021. 11. 25. 22:05개요
실무 프로젝트 시, 테스트코드를 작성하다가 다르게 알게된 부분도 있고, 보일러 플레이트 코드가 양산되는거 같아 정리겸 쓰는 글. 그리고 좀 더 개선된 코드로 가기위한 나름의 몸부림..
- 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 부분을 호출하는 콜백 인터페이스 구현체를 별도로 만들었다. (설명이 복잡한데 코드로 바로 보자.)
아래가 내가 새롭게 만든 코드이다. 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 발생 유무 결과다.
그래서 나의 생각은
테스트코드에서 롤백전략을 가져가려고 편하게 @Transactional 을 붙이게 되는데 lazyLoading 부분을 신경쓸 필요가 있다. 비단 영속성 컨텍스트를 view 영역까지 가져가는것 + 테스트 메소드 내에서 findAll() 로 조회한 특정한 엔티티에 연관된 엔티티를 lazy 로 들고오는 것 이 두가지 행위?/방법? 를 사전에 막을 수 있다. 그래서 롤백전략을 위한 트랜잭셔널 애노테이션을 붙이지 않고, 테스트 라이프 사이클때 데이터들을 모두 날려버리는게 현재로썬 가장 나은 선택지라고 생각한다.
관련코드
참고
(나의 고민에 대해 해당 링크에서 소스를 얻을 수 있었다.)
'Spring' 카테고리의 다른 글
2022-09-09 [jdbcTemplate] : insert/upsert/update 성능비교 (0) | 2022.09.09 |
---|---|
2022-03-27 [spring-cloud] : @RefreshScope (0) | 2022.03.27 |
20201121 [java] proxy 에 대한 이해 (수정 : 2021-11-26) (0) | 2020.11.22 |
20111113 [transcation] 스프링 선언적 트랜잭션 (0) | 2020.11.20 |
20201025 [spring-cache] cache abstraction (0) | 2020.10.25 |