Kotlin/Kotlin | Spring 학습기록

[Kotest] Nested Test spec에서의 context 생명주기 및 트랜잭션

kth990303 2022. 9. 8. 20:46
반응형

코틀린 프로젝트에서 kotest로 테스트 코드를 짜다가 통과해야 할 테스트가 통과하지 않는 현상을 마주치게 됐다.

우리는 StringSpec과 유사한, 중첩되지 않은 구조에서 Given - When - Then 구조의 BehaviorSpec 스타일로 테스트를 마이그레이션하고 있었다

변경 전 JUnit, 변경 후 kotest - BehaviorSpec

위 두 테스트 코드는 아예 동일하다.

그저 계층만 나뉘게 바뀌었을 뿐.

 

구조에 따라 계층을 나누기만 하고, 순서 변경이라든지 코드의 변경이 없었으니 테스트는 당연히 문제 없이 통과할 줄 알았다.

그런데 결과는?

Tests Failed...

 

당연히 WAITING에서 PASS로 변경될 줄 알았는데 Given에서 진행해준 update가 진행되지 않고 WAITING인 상태로 테스트가 진행됐다. 심지어 코드 변경은 전혀 없고 구조에 따라 나누기만 했는데도 그렇다.

 

코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 Given("점수 수정사항이 있을 경우") {
 
    // 점수 평가 대상자 저장        
    val evaluationTarget = 
        evaluationTargetRepository.save(createEvaluationTarget(1L, 2L, EvaluationStatus.WAITING))
 
    val updatedStatus = EvaluationStatus.PASS
    val updatedNote = "특이 사항 없음."
    val answers = listOf(EvaluationItemScoreData(score = 5, id = 3L))
    val gradeEvaluationRequest = EvaluationTargetData(answers, updatedNote, updatedStatus)
 
    evaluationTargetService.grade(evaluationTarget.id, gradeEvaluationRequest)  // 점수 변경 -> WAITING에서 PASS로
 
    When("점수를 수정하면") {
        Then("점수가 수정된다") {
 
            val updatedEvaluationTarget = evaluationTargetRepository.findByIdOrNull(evaluationTarget.id)!!
            updatedEvaluationTarget.evaluationStatus shouldBe updatedStatus // PASS로 변경됐는지 확인
        }
    }
}
 
cs

1. 평가 대상자 저장 ( evaluationTargetRepository.save(...) )

2. 평가 대상자 점수 수정하기 ( evaluationTargetService.grade )

3. 점수 수정되는지 확인 ( updatedEvaluationTarget.evaluationStatus shouldBe updatedStatus )

 

2번을 진행했음에도 불구하고 3번이 통과하지 않은 것이다.

평가 대상자 점수가 수정되는 2번 구현이 제대로 되지 않았는지 확인하기 위해 디버깅을 해보았다.

디버깅을 해보면분명 Given에서 작성한 grade (점수 변경) 메서드가 진행돼서 update 된 결과를 확인할 수 있다.

evaluationStatus가 WAITING에서 PASS로 바뀐 것을 확인 가능하다.

 

그런데 왜 최종결과로는 반영되지 않는 것일까?


Nested Spec에서의 Transaction

결론부터 말하자면 Nested Spec에서의 context lifecycle 은 가장 indent가 깊은 leaf 테스트에서만 시작되고 끝나기 때문이다. BehaviorSpec일 경우 Then에서만 트랜잭션이 진행되고, DescribeSpec일 경우 it에서만 트랜잭션이 진행되는 것이다. 해당 트랜잭션이 끝나면 롤백하게 된다.

1
2
3
4
5
6
7
2022-09-08 20:01:36.256: Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
2022-09-08 20:01:36.265 : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
 
2022-09-08 20:01:36.278: Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
2022-09-08 20:01:36.298: Began transaction (1for test context [DefaultTestContext@395b0532 testClass = EvaluationTargetServiceTest, 
2022-09-08 20:01:36.308: Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
 
cs

Given에 작성한 save 메서드가 진행된 후에 Began transaction이 시작된 것을 확인할 수 있다.

위 Transaction 로깅은 yml에서 아래 설정을 해주면 볼 수 있다.

logging.level.org.springframework.transaction.interceptor = TRACE

 

만약 update 코드를 leaf test 안에 넣으면 어떻게 될까?

같은 트랜잭션에서 update를 하므로 변경이 감지될까?

1
2
3
4
5
6
7
8
When("점수를 수정하면") {
    Then("점수가 수정된다") {
        evaluationTargetService.grade(evaluationTarget.id, gradeEvaluationRequest)  // 점수 변경 -> WAITING에서 PASS로
        val updatedEvaluationTarget = evaluationTargetRepository.findByIdOrNull(evaluationTarget.id)!!
        updatedEvaluationTarget.evaluationStatus shouldBe updatedStatus // PASS로 변경됐는지 확인
    }
}
 
cs

테스트 성공

테스트가 성공하는 것을 확인할 수 있다.

update가 같은 트랜잭션 내에서 실행됐기 때문에 변경을 인식할 수 있는 것이다.


kotest의 Nested 구조에서 트랜잭션 범위를 설정하려면?

매번 Then에서만 update를 할 순 없다. 테스트의 트랜잭션 범위를 바꿔보자.

class EvaluationTargetServiceTest(
    private val evaluationTargetRepository: EvaluationTargetRepository
) : BehaviorSpec({
    extensions(SpringTestExtension(SpringTestLifecycleMode.Root))

위와 같이 TestConfiguration의 extensions 설정으로 test lifecycle을 변경할 수 있다.

 

SpringTestLifecycleMode.Root로 변경해주어 Given에서부터 트랜잭션이 시작되게 할 수 있다.

 

SpringTestLifecycleMode를 보면 Default 옵션은 SpringTestLifecycleMode.Test 이며, leaf tests에서만 setup, teardown하는 것을 알 수 있다.

1. extensions(SpringExtension)
2. extensions(SpringTestExtension(SpringTestLifecycleMode.Test))

위 두 가지 방법 모두 라이프사이클을 leaf test로 진행하는 방법이다.

그 이유는 위 사진을 보면 알 수 있는데, SpringTestExtension()을 까보면, Default 값이 SpringTestLifecycleMode.Test 이기 때문이다.  (extensions를 까본게 아니고 SpringTestExtension()을 까본 것에 주목하자! extensions는 생략해선 안된다. 아래에 후술 예정)

 

SpringTestLifecycleMode.Root로 extensions를 설정하고 다시 테스트를 진행하면 결과는 아래와 같다.

1
2
3
4
5
6
7
8
9
2022-09-08 20:22:16.643: Began transaction (1for test context [DefaultTestContext@4886fa86 testClass = EvaluationTargetServiceTest ...
2022-09-08 20:22:16.965: Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
Hibernate: 
    insert 
    into
        evaluation_target
        (id, administrator_id, evaluation_id, evaluation_status, note, user_id) 
    values
        (null, ?, ?, ?, ?, ?)
cs

Began transaction이 먼저 뜨고 Given에 작성한 save 메서드(insert)가 진행되는 것을 확인할 수 있다.

 

주의할 점은 트랜잭션이 중첩된 구조가 아닌, 하나의 트랜잭션의 범위를 설정하는 문제이기 때문에 트랜잭션 전파레벨(propagation level)을 설정하는 게 해결책이 아니라는 점이다. 

 

 

만약 SpringTestLifecycleMode가 각각 Root, Test인 상태로 아래 테스트를 돌리면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Given("점수 수정사항이 있을 경우") {
 
    // save
 
    When("점수를 수정하면") {
        Then("점수가 수정된다") {
            // update
 
            // update 확인
        }
    }
 
    When("점수를 수정하면") {
        Then("점수가 수정된다") {
            // update 확인
        }
    }
}
 
cs

결과는 아래와 같다.

  • Root일 경우 Root test가 setup, teardown될 동안 트랜잭션이 진행되기 때문에 중간에 리프테스트가 끝나도 트랜잭션이 롤백되지 않는다. 따라서 위 테스트는 모두 통과하게 된다.
  • Test (Default)일 경우 leaf test가 teardown되면 (@Transactional이 붙어있는 경우 한정) 테스트가 롤백되기 때문에 첫 번째 테스트는 통과, 두 번째 테스트는 실패하게 된다.

+) 23.08.08 추가

extensions(SpringTestExtension(SpringTestLifecycleMode.Test))

만약 위 extensions를 아예 생략하는 경우는 어떻게 될까?

 

@SpringBootTest + @Transactional + BehaviorSpec (extensions 생략) 환경에서 아래 테스트를 진행해보았다.

특정 기수를 생성하는 테스트 코드를 작성해보았다.

같은 기수는 2개 이상 생성될 수 없다. extensions()를 명시해준 경우는 Leaf Test (Then) 절이 끝나면 rollback이 수행되는 것을 확인할 수 있었다. 따라서 위 Then 절 테스트는 2개 모두 통과될 것이다.

하지만 rollback이 수행되지 않고 테스트가 실패했다.

 

extensions() 를 까보면 아래와 같다.

SpringTestLifeCycle을 TEST, ROOT를 명시하지 않고 생략하면 그저 emptyList()가 삽입되는 것이다.

extensions(SpringTestExtension())

만약 위처럼 extensions(SpringTestExtension()) 으로라도 넣었다면 SpringTestExtension()의 Default LifeCycle은 TEST 이므로 라이프사이클이 Leaf Test 컨텍스트 단위로 설정됐을 것이다. 그럼 성공적으로 테스트가 통과했을 것이다.

 

 

참고로 SpringTestExtension()을 까보면 아래 코드가 존재한다.

테스트 context 라이프사이클을 설정해주는 부분이 존재한다.

그리고 afterTestClass()를 까보면 테스트 컨텍스트를 remove해주는 코드를 확인할 수 있다.


처음에는 트랜잭션 전파레벨 등 별 생각을 다 했었다.

우아한테크코스 코치님께서 알려주신 덕분에 kotest의 SpringTestLifecycleMode를 설정함에 따라 context 생명주기가 다르다는 사실을 알게 됐다. @Nested 클래스의 생명주기와 비슷한 것 같다는 생각도 든다. 

 

참고

 

도움을 준 사람

  •  moseoh님 (댓글 감사합니다 :) )

 

 

반응형