[Kotlin] 커스텀 어노테이션으로 테스트 통합 테스트로 격리하기

궁금하면 500원·2025년 4월 10일
0

커스텀 어노테이션으로 테스트 격리하기: Spring Boot + Kotlin 환경에서의 통합 테스트

지난 시간 동안 Java + Spring Boot 기술 스택으로 테스트 관련에 대해 글을 올린 적이 있습니다.
현재는 Kotlin 언어를 학습하면서 사이드 프로젝트도 Kotlin + Spring Boot 조합으로 개발하고 있는데, 사용하면 할수록 강력한 언어임을 느끼고 있습니다.

오늘은 코틀린 Spring 환경에서의 통합테스트 환경에서 테스트 격리하는 것에 대해 다룰 것이고 글에서 사용하는 데이터베이스는 MySQL, H2(mysql mode) 입니다.

테스트 격리가 무엇인지 잘 모르거나, 통합 테스트 환경에서 개발 테스트를 돌리면 성공하지만 전체를 돌리면 실패하는 이유를 모르신다면 이 글을 읽어보면 좋겠습니다!

1. 테스트 격리는 무엇이고 왜 중요한 것일까?

  • 테스트 격리(Isolated Test)란 무엇일까요?

말 그대로 테스트 환경을 격리해준다고 이해하면 쉬울 것 같습니다.

논지가 빠르신 분들은 "테스트 격리는 테스트끼리 어떤 영향이 나타나, 우선 순위가 있을 것이기 때문에 필요한 것이 아닐까?" 라고 알아채실 수 있습니다.

그렇다면 어떤 영향이 나고, 왜 중요이 나는 것일까요?

한 번 예시로 보겠습니다.

먼저 다음 상황은 E2E 테스트를 SpringBootTest + RestAssured를 통해 진행하는 통합테스트 코드입니다.

@IntegrationTest
class AuthControllerIntegrationTest(
    @Autowired private val authRepositoryPort: AuthRepositoryPort,
    @Autowired private val authPasswordEncryptorPort: AuthPasswordEncryptorPort
) {
    @Test
    fun `회원가입을 진행한다`() {
        // given
        val request = SignUpRequest("username", "password")
        
        // when
        val response = RestAssured.given().log().all()
            .`when`()
            .contentType(ContentType.JSON)
            .body(request)
            .post("/auth/sign-up")
            .then().log().all()
            .extract()
            
        // then
        assertEquals(response.statusCode(), HttpStatus.CREATED.value())
    }
    
    @Test
    fun `회원가입을 진행한다2`() {
        // given
        val request = SignUpRequest("username", "password")
        
        // when
        val response = RestAssured.given().log().all()
            .`when`()
            .contentType(ContentType.JSON)
            .body(request)
            .post("/auth/sign-up")
            .then().log().all()
            .extract()
            
        // then
        assertEquals(response.statusCode(), HttpStatus.CREATED.value())
    }
    
    @Test
    fun `로그인을 진행한다`() {
        // given
        authRepositoryPort.save(
            Auth.signUpWithEncryption(
                username = "username",
                password = "password",
                authPasswordEncryptorPort = authPasswordEncryptorPort
            )
        )
        val request = SignInRequest("username", "password")
        
        // when
        val response = RestAssured.given().log().all()
            .`when`()
            .contentType(ContentType.JSON)
            .body(request)
            .get("/auth/sign-in")
            .then().log().all()
            .extract()
            
        // then
        assertEquals(response.statusCode(), HttpStatus.OK.value())
    }
}

위와 같이 작성하면 쉽게 테스트 격리를 할 수 있게 됩니다.

우리가 원하는 결과는 모두 통과하는 것입니다.

결과를 보면 희망과는 다르게 '회원가입2, 로그인' 테스트가 실패하는 것을 볼 수 있습니다.
회원가입2 테스트의 로그를 보면 '이미 존재하는 username입니다.' 라는 로그를 확인할 수 있습니다.
같은 테스트를 전체 실행이 아닌 개별 실행을 하면 또 모두 성공합니다.

왜 그런지 유추해보면, 테스트용 DB H2에서 이미 회원가입 테스트를 통해 값이 적재 됐는데, 한 번 더 동일한 값으로 회원가입을 진행해서 그런 것임을 알 수 있습니다.
위와 같이 테스트 간 격리가 이뤄지지 않으면 실패하거나 충돌하는 것을 확인했습니다.

2. 그렇다면 왜 이런 결과가 나오는 것일까요?

관련된 내용을 공식 문서를 한 번 봐보겠습니다!

Context Caching

문서를 살짝 정리해보면, 스프링에선 기본적으로 테스트 실행 속도를 높이기 위해서 ApplicationContext를 캐싱하고 있고, 테스트 안의 상태 값들(ex. DB, Bean..)이 변하지 않으면 캐싱이 되기 때문에 이전 테스트에 이어서 동일한 컨텍스트를 쓰는 것이고 그러기 때문에 이전 테스트의 결과가 이어진다고 볼 수 있습니다.

  • 잠시 위 내용과 큰 관계는 없지만, 위 특징(캐싱)을 이용하면 테스트 성능도 개선할 수 있습니다.

만약 우리가 통합 테스트 환경에서 사용하는 모든 Bean을 하나의 클래스에서 선언하고 테스트 대상 클래스에서 이를 상속 받아서 사용한다면 모두 같은 테스트 컨텍스트 안에서 돌아가고, 이를 통해 컨텍스트를 띄우는 비용을 줄여 통합 테스트 성능을 개선할 수 있습니다.

이제 컨텍스트 공유 때문에 테스트 격리가 필요하다는 것을 알았습니다.

격리 방법은 여러 가지가 있습니다.

테스트 환경이 캐싱된다면 테스트 마다 @Transactional을 사용하는 방법이 있을 수도 있고, @DirtiesContext를 통해 테스트 컨텍스트를 개별로 띄워 격리할 수도 있고, @Sql을 통해 직접 DB를 초기화 해주는 방법도 있습니다.

선택은 자유지만, 개인적으로 저는 @DirtiesContext는 잘 사용하지 않고 있습니다.

위 공식문서의 테스트 캐싱을 활용할 수 없고 결국 캐싱되지 않은 컨텍스트를 사용하므로 전체적인 테스트 수행 속도가 느려지기 때문입니다.

가장 깔끔하게 sql을 통해서 격리하는 것을 선호하곤 하는데요.
테이블이 추가될 때마다 sql 초기화 쿼리를 또 직접짜면 귀찮기 때문에 다른 방식으로 이를 해결하고 있습니다.

3. 커스텀 어노테이션으로 편리하게 테스트 격리하기

바로 위에서 언급한 테스트 초기화 방법은, 테스트용 db에서 네이티브 쿼리로 테이블 정보를 모두 가져온 후 TRUNCATE 쿼리를 날려 초기화 해주는 방법입니다.

테스트 환경에서만 사용하는 DB로 H2를 쓰기 때문에 마음껏 날려도 되지만, 실제 DB와 연결해서 사용한다면 해당 방법은 절대로 사용하시면 안됩니다.

위 방법을 이용해서 다음과 같은 시나리오로 테스트를 진행하게 됩니다.

  • 테스트1 시작 전
  • DB Table 모두 조회 및 TRUNCATE 수행
  • 테스트1 실행
  • 테스트2 시작 전
  • DB Table 모두 조회 및 TRUNCATE 수행
  • 테스트2 실행
  • ...

< DB 테이블 조회 + TRUNCATE >를 하도록 기능을 하나 만들고, < 테스트 실행 전에 이게 실행되는 어노테이션 >을 만들면 다음과 같습니다.

package com.api.helper

import io.restassured.RestAssured
import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.TestContext
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.AbstractTestExecutionListener
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.lang.annotation.ElementType
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
import java.lang.annotation.Target

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        DatabaseCleanupListener::class
    ]
)
annotation class IntegrationTest

class DatabaseCleanupListener : AbstractTestExecutionListener() {

    override fun beforeTestExecution(testContext: TestContext) {
        val applicationContext = testContext.applicationContext
        val transactionManager = applicationContext.getBean(PlatformTransactionManager::class.java)
        val entityManager = applicationContext.getBean(EntityManager::class.java)

        applyRestAssuredPort(applicationContext.environment.getProperty("local.server.port"))
        cleanupDatabase(entityManager, transactionManager)
    }

    private fun applyRestAssuredPort(port: String?) {
        RestAssured.port = requireNotNull(port?.toIntOrNull()) {
            "RestAssured port 설정 불가: 'local.server.port'를 사용할 수 없습니다.."
        }
    }

    private fun cleanupDatabase(
        entityManager: EntityManager,
        transactionManager: PlatformTransactionManager
    ) {
        TransactionTemplate(transactionManager).executeWithoutResult {
            val tableNames = entityManager.createNativeQuery(
                "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'"
            ).resultList.filterIsInstance<String>()

            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate()
            tableNames.forEach { table ->
                entityManager.createNativeQuery("TRUNCATE TABLE \"$table\"").executeUpdate()
            }
            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate()
        }
    }
}

뭐가 되게 많아서 복잡할 수 있지만, 기능의 목적부터 보고 하나씩 설명하겠습니다.

테스트 실행 전 DB 테이블 조회 + TRUNCATE

위 코드에서 DatabaseCleanupListener 클래스입니다.

  • AbstractTestExecutionListener.beforeTestExecution을 오버라이딩하고 있습니다.

  • 콜백 메서드로, 테스트 메서드가 실행되기 전 호출이 됩니다.
    즉 재정의한 해당 메서드 내부의 로직이 테스트 전 작동된다고 보시면 됩니다.

  • cleanupDatabase() 메서드는 네이티브 쿼리로 테이블 명을 모두 조회하고, 제약 조건을 잠시 제거하고 TRUNCATE 한 후 다시 걸어주는 로직이 담긴 메서드입니다.

어노테이션

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

  • 랜덤 포트를 사용한 이유는 예제에서 RestAssured 기반의 통합테스트를 사용하고 있기 때문입니다. 사용하지 않는다면 다른 설정 값을 사용하셔도 됩니다.

@TestExecutionListeners

  • 네이밍 그대로 테스트 실행 전후로 커스텀 로직을 주입할 수 있는 Listener를 등록하는 어노테이션입니다.
    즉 테스트 클래스가 실행될 때, 리스너에 따라 테스트 생명주기에 개입하게 됩니다.

DependencyInjectionTestExecutionListener::class

  • 테스트 인스턴스 생성 후, Bean을 필드에 주입해주는 리스너

DatabaseCleanupListener::class

  • 우리가 위에서 만든 테스트 격리 유틸 클래스입니다. @TestExecutionListeners에 등록되어 테스트 전에 beforeTestExecution 안에 로직이 작동됩니다.
  • 만약 Cleaner에서 afterTestMethod를 재정의 하고, Listener에 등록하면 테스트 메서드 이후 추가적인 로직도 만들 수있습니다.

위와 같이 작성한 커스텀 어노테이션을 사용하면 손쉽게 테스트 격리를 할 수 있게 됩니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글