본문 바로가기
Spring

[Spring] Kotest context, when 단위로 @Transactional 동작하게 하는 방법

by 노력남자 2023. 12. 11.
반응형

이번 포스팅에선 Kotest에서 DescribeSpec의 context, BehaviorSpec의 when 단위로 @Transactional 동작하게 하는 방법에 대해 알아보겠다.
 
이전 포스팅에서 Kotest로 작성된 테스트에서 @Transactional 동작하게 하는 방법에 대해 알아봤는데 context, when 단위로 동작하는 방법은 없었다. (아래 글은 DescribeSpec의 context 기준으로 작성됐는데, BehaviorSpec을 사용하는 경우 context를 when로만 바꿔주면 된다.)
 

왜 없을까?

 
없어서 불편하다라고 생각만 했지, 별로 왜 없는지 생각을 해보진 않았는데 팀원분이 말해주신 이유는 아래와 같다.
 
context 단위로 트랜잭션을 묶어주는 경우 트랜잭션 범위를 지정하는 방법이 애매하기 때문이다.
 
아래 테스트는 최하단의 context 별로 트랜잭션을 묶어야 한다.
 

 
아래와 같이 묶어야 하는 상황이 올 수도 있다.
 

 
위와 같은 문제도 있겠지만 가장 중요한 건 현재 Kotest 구조상 context를 구분할 수 없다.
 
DescribeSpec의 describe, context는 이름만 다르지 사실 타입은 TestType.Container로 같아서 "context에만 트랜잭션이 동작하게 만들어야겠다!"를 할 수 없다. (BehaviorSpec의 given, when도 마찬가지다.)
 
kotext를 구조를 좀 파악해보니 제한적으로 context인지 아닌지를 구분할 수 있었다. context 단위로 트랜잭션을 적용할 수 있는 방법이 있었다!!
 
먼저 Kotest에서 트랜잭션을 어떻게 적용하고 있는지 알아보자.
 

트랜잭션 적용 방법

 
Kotest에서 @Transactional이 동작하게 하기 위해 사용하고 있는 SpringTestExtension에서 힌트를 얻을 수 있었다.
 
SpringTestExtension에 구현된 트랜잭션 적용 방법은 아래와 같다.
 
1. testCase의 isApplicable()이 true면 트랜잭션을 시작하고
2. 테스트를 실행하고
3. testCase의 isApplicable()이 true면 트랜잭션을 종료한다.
 
(testCase란 describe, context, it, given, when, then을 말한다.)
 

 
SpringTestExtension Class 코드를 전부 복사해서 isApplicable() 로직만 context를 찾게 수정하면 된다.
 

Context 단위로 트랜잭션 적용 방법

 
2가지 방법이 있는데 방법마다 테스트할 수 있는 구조가 좀 달라진다.
 
아래는 SpringTestExtension에 있는 코드를 복사해서 새롭게 만든 Extension이다.
 
아래 코드에서 isApplicable()의 로직만 바꿀 거다. 아래 코드를 복사해서 붙여넣자.
 

import io.kotest.core.extensions.SpecExtension
import io.kotest.core.extensions.TestCaseExtension
import io.kotest.core.spec.Spec
import io.kotest.core.test.TestCase
import io.kotest.core.test.TestResult
import io.kotest.core.test.TestType
import io.kotest.extensions.spring.Properties
import io.kotest.extensions.spring.SpringTestContextCoroutineContextElement
import io.kotest.extensions.spring.testContextManager
import kotlinx.coroutines.withContext
import net.bytebuddy.ByteBuddy
import net.bytebuddy.description.modifier.Visibility
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy
import net.bytebuddy.implementation.FixedValue
import org.springframework.test.context.TestContextManager
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.UUID
import kotlin.reflect.KClass

class SpringTestContextModeExtension : TestCaseExtension, SpecExtension {

   private var ignoreSpringListenerOnFinalClassWarning: Boolean = false

   override suspend fun intercept(spec: Spec, execute: suspend (Spec) -> Unit) {
      safeClassName(spec::class)

      val context = TestContextManager(spec::class.java)
      withContext(SpringTestContextCoroutineContextElement(context)) {
         testContextManager().beforeTestClass()
         testContextManager().prepareTestInstance(spec)
         execute(spec)
         testContextManager().afterTestClass()
      }
   }

   override suspend fun intercept(testCase: TestCase, execute: suspend (TestCase) -> TestResult): TestResult {
      val methodName = method(testCase)
      if (testCase.isApplicable()) {
         testContextManager().beforeTestMethod(testCase.spec, methodName)
         testContextManager().beforeTestExecution(testCase.spec, methodName)
      }
      val result = execute(testCase)
      if (testCase.isApplicable()) {
         testContextManager().afterTestMethod(testCase.spec, methodName, null as Throwable?)
         testContextManager().afterTestExecution(testCase.spec, methodName, null as Throwable?)
      }
      return result
   }

   private fun TestCase.isApplicable() = !!! 여기만 달라짐 !!!
   
   private fun method(testCase: TestCase): Method = if (Modifier.isFinal(testCase.spec::class.java.modifiers)) {
      if (!ignoreFinalWarning) {
         @Suppress("MaxLineLength")
         println("Using SpringListener on a final class. If any Spring annotation fails to work, try making this class open.")
      }
      // the method here must exist since we can't add our own
      this@SpringTestContextModeExtension::class.java.methods.firstOrNull { it.name == "intercept" }
         ?: error("Could not find method 'intercept' to attach spring lifecycle methods to")
   } else {
      val methodName = methodName(testCase)
      val fakeSpec = ByteBuddy()
         .subclass(testCase.spec::class.java)
         .defineMethod(methodName, String::class.java, Visibility.PUBLIC)
         .intercept(FixedValue.value("Foo"))
         .make()
         .load(this::class.java.classLoader, ClassLoadingStrategy.Default.CHILD_FIRST)
         .loaded
      fakeSpec.getMethod(methodName)
   }

   private fun safeClassName(kclass: KClass<*>) {
      if (kclass.java.name.split('.').any { illegals.contains(it) })
         error("Spec package name cannot contain a java keyword: ${illegals.joinToString(",")}")
   }

   private fun methodName(testCase: TestCase): String = (testCase.name.testName + "_" + UUID.randomUUID().toString())
      .replace(methodNameRegex, "_")
      .let {
         if (it.first().isLetter()) it else "_$it"
      }

   private val illegals =
      listOf("import", "finally", "catch", "const", "final", "inner", "protected", "private", "public")

   private val methodNameRegex = "[^a-zA-Z_0-9]".toRegex()

   private val ignoreFinalWarning =
      ignoreSpringListenerOnFinalClassWarning ||
         !System.getProperty(Properties.springIgnoreWarning, "false").toBoolean()
}

 
*KotestProjectConfig에 설정하는 걸 있지말자.
 

class KotestProjectConfig : AbstractProjectConfig() {
    override fun extensions() = listOf(SpringTestContextModeExtension())
}

 

1. 최상단 context 단위로 트랜잭션 적용

 
describe 밑에 있는 최상단 context들을 찾아 트랜잭션을 적용하는 방법이다.
 
최상단 context에만 트랜잭션이 걸리기 때문에 context안에 context를 쓰는 게 맞는 건지 잘 생각해봐야 한다.
 

 
isApplicable() 코드는 아래와 같다.
 
descriptor.parent.isRootTest(): 내 상단이 describe면서
type == TestType.Container: TestType이 Container인 경우
 

private fun TestCase.isApplicable() = descriptor.parent.isRootTest() && type == TestType.Container

 

2. 최하단 context에만 트랜잭션 적용

 
최하단에 있는 context에만 트랜잭션을 적용하는 방법이다.
 
nested context 구조인 경우에 사용할 수 있는 "contexts"를 만들고 최하단 context에만 context를 사용하는 방법이다.
 

 
contexts를 추가해주고
 

suspend fun DescribeSpecContainerScope.contexts(name: String, test: suspend DescribeSpecContainerScope.() -> Unit) {
    registerContainer(TestName("Contexts: ", name, false), false, null) { DescribeSpecContainerScope(this).test() }
}

 
isApplicable()에 아래와 같이 작성해준다.
 

private fun TestCase.isApplicable() = name.prefix == "Context: "

 
위 방법은 꼭 최하단 context인 경우에만 context를 사용해야 한다.

 

3. 내가 원하는 context에 트랜잭션 적용 (추천)

 

이 방법은 자유도가 있는 방법이다.

 

내가 트랜잭션을 시작하고 싶은 곳에 txcontext를 사용하는 방식이다. 1, 2번보다 훨씬 자유롭다.

 

 

txcontext를 추가해주고

 

suspend fun DescribeSpecContainerScope.txcontext(name: String, test: suspend DescribeSpecContainerScope.() -> Unit) {
    registerContainer(TestName("TxContexts: ", name, false), false, null) { DescribeSpecContainerScope(this).test() }
}

 

isApplicable()에 아래와 같이 작성해준다.

 

 

private fun TestCase.isApplicable() = name.prefix == "TxContexts: "

 

주의할 점은 txcontext를 중첩으로 사용하면 안 된다.

 

 

java.lang.IllegalStateException: Cannot start new transaction without ending existing transaction 에러가 발생한다.

 

SpringBootTest 자체에서 한 테스트에 여러 트랜잭션 생성하는 걸 제한하고 있어 발생한 에러다.

 

반응형

댓글