지난 시간 동안 Java + Spring Boot 기술 스택으로 테스트 관련에 대해 글을 올린 적이 있습니다.
현재는 Kotlin 언어를 학습하면서 사이드 프로젝트도 Kotlin + Spring Boot 조합으로 개발하고 있는데, 사용하면 할수록 강력한 언어임을 느끼고 있습니다.
오늘은 코틀린 Spring 환경에서의 통합테스트 환경에서 테스트 격리하는 것에 대해 다룰 것이고 글에서 사용하는 데이터베이스는 MySQL, H2(mysql mode) 입니다.
테스트 격리가 무엇인지 잘 모르거나, 통합 테스트 환경에서 개발 테스트를 돌리면 성공하지만 전체를 돌리면 실패하는 이유를 모르신다면 이 글을 읽어보면 좋겠습니다!
말 그대로 테스트 환경을 격리해준다고 이해하면 쉬울 것 같습니다.
논지가 빠르신 분들은 "테스트 격리는 테스트끼리 어떤 영향이 나타나, 우선 순위가 있을 것이기 때문에 필요한 것이 아닐까?" 라고 알아채실 수 있습니다.
그렇다면 어떤 영향이 나고, 왜 중요이 나는 것일까요?
한 번 예시로 보겠습니다.
먼저 다음 상황은 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에서 이미 회원가입 테스트를 통해 값이 적재 됐는데, 한 번 더 동일한 값으로 회원가입을 진행해서 그런 것임을 알 수 있습니다.
위와 같이 테스트 간 격리가 이뤄지지 않으면 실패하거나 충돌하는 것을 확인했습니다.
관련된 내용을 공식 문서를 한 번 봐보겠습니다!
문서를 살짝 정리해보면, 스프링에선 기본적으로 테스트 실행 속도를 높이기 위해서 ApplicationContext를 캐싱하고 있고, 테스트 안의 상태 값들(ex. DB, Bean..)이 변하지 않으면 캐싱이 되기 때문에 이전 테스트에 이어서 동일한 컨텍스트를 쓰는 것이고 그러기 때문에 이전 테스트의 결과가 이어진다고 볼 수 있습니다.
만약 우리가 통합 테스트 환경에서 사용하는 모든 Bean을 하나의 클래스에서 선언하고 테스트 대상 클래스에서 이를 상속 받아서 사용한다면 모두 같은 테스트 컨텍스트 안에서 돌아가고, 이를 통해 컨텍스트를 띄우는 비용을 줄여 통합 테스트 성능을 개선할 수 있습니다.
이제 컨텍스트 공유 때문에 테스트 격리가 필요하다는 것을 알았습니다.
격리 방법은 여러 가지가 있습니다.
테스트 환경이 캐싱된다면 테스트 마다 @Transactional을 사용하는 방법이 있을 수도 있고, @DirtiesContext를 통해 테스트 컨텍스트를 개별로 띄워 격리할 수도 있고, @Sql을 통해 직접 DB를 초기화 해주는 방법도 있습니다.
선택은 자유지만, 개인적으로 저는 @DirtiesContext는 잘 사용하지 않고 있습니다.
위 공식문서의 테스트 캐싱을 활용할 수 없고 결국 캐싱되지 않은 컨텍스트를 사용하므로 전체적인 테스트 수행 속도가 느려지기 때문입니다.
가장 깔끔하게 sql을 통해서 격리하는 것을 선호하곤 하는데요.
테이블이 추가될 때마다 sql 초기화 쿼리를 또 직접짜면 귀찮기 때문에 다른 방식으로 이를 해결하고 있습니다.
바로 위에서 언급한 테스트 초기화 방법은, 테스트용 db에서 네이티브 쿼리로 테이블 정보를 모두 가져온 후 TRUNCATE 쿼리를 날려 초기화 해주는 방법입니다.
테스트 환경에서만 사용하는 DB로 H2를 쓰기 때문에 마음껏 날려도 되지만, 실제 DB와 연결해서 사용한다면 해당 방법은 절대로 사용하시면 안됩니다.
위 방법을 이용해서 다음과 같은 시나리오로 테스트를 진행하게 됩니다.
< 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()
}
}
}
뭐가 되게 많아서 복잡할 수 있지만, 기능의 목적부터 보고 하나씩 설명하겠습니다.
위 코드에서 DatabaseCleanupListener 클래스입니다.
AbstractTestExecutionListener.beforeTestExecution을 오버라이딩하고 있습니다.
콜백 메서드로, 테스트 메서드가 실행되기 전 호출이 됩니다.
즉 재정의한 해당 메서드 내부의 로직이 테스트 전 작동된다고 보시면 됩니다.
cleanupDatabase() 메서드는 네이티브 쿼리로 테이블 명을 모두 조회하고, 제약 조건을 잠시 제거하고 TRUNCATE 한 후 다시 걸어주는 로직이 담긴 메서드입니다.
위와 같이 작성한 커스텀 어노테이션을 사용하면 손쉽게 테스트 격리를 할 수 있게 됩니다.