Kotest에서 TestContainers 적용하기 대모험

Glen·2024년 1월 9일
1

TroubleShooting

목록 보기
5/6

서론

개인적으로 하고 있는 사이드 프로젝트의 통합 테스트 환경에서 Testcontainers를 사용해 보기로 했다.

테스트 컨테이너를 사용하면, Docker 기반의 인프라 환경을 자동으로 구성해 주므로, RDB, Redis, Kafka 같은 외부 서비스에 의존적인 코드를 더욱 신뢰성 있게 테스트할 수 있다.

진행 중인 다른 프로젝트인 페스타고에서는 테스트 컨테이너를 사용하지 않았기에, Redis를 사용한 테스트가 불가하고, H2에서 일부 MySQL 문법이 호환되지 않는 이슈가 있었기에 이번 기회에 새롭게 학습하고, 적용했을 때 결과가 좋다면 팀 프로젝트에 적용하여 신뢰성 있는 테스트를 할 수 있을 것으로 생각했다.

테스트 컨테이너를 사용하려면, 다음과 같이 build.gradle.kts에 의존성을 추가하면 된다.

dependencies {
    ...
    // Testcontainers  
    testImplementation("org.testcontainers:testcontainers")  
    testImplementation("org.testcontainers:mysql")
    ...
}

테스트 컨테이너는 스프링에서 버전 관리를 제공해 주기 때문에 버전을 명시해 주지 않아도 된다.

그리고 다음과 같이 외부 설정 파일의 spring.datasource.driver-class-name, spring.datasource.url에 다음과 같이 설정하면 된다.

spring:  
  datasource:  
    driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver  
    url: jdbc:tc:mysql:8://test_db

이제 테스트를 돌려보면 DB 커넥션을 얻어올 때 테스트 컨테이너가 Docker를 통해 MySQL 이미지를 받아, 실행 환경을 자동으로 구성해 준다.

이는 org.testcontainers.jdbc.ContainerDatabaseDriver 클래스를 확인해 보면 알 수 있다.

public class ContainerDatabaseDriver implements Driver {
    ...
    public synchronized Connection connect(String url, Properties info) throws SQLException {  
        if (!this.acceptsURL(url)) {  
            return null;  
        } else {  
            ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url);  
            synchronized(jdbcUrlContainerCache) {  
                String queryString = (String)connectionUrl.getQueryString().orElse("");  
                JdbcDatabaseContainer container = (JdbcDatabaseContainer)jdbcUrlContainerCache.get(connectionUrl.getUrl());  
                if (container == null) {  
                    LOGGER.debug("Container not found in cache, creating new instance");
                    ...
                    jdbcUrlContainerCache.put(url, container);  
                    container.setParameters(parameters);  
                    container.start();  
                }  
                ...
            }  
        }  
    }
    ...
}

Driver는 MySQLContainer 클래스를 디버깅해 본 결과, com.mysql.cj.jdbc.Driver 드라이버를 사용한다.
아마 url에 따라 동적으로 이미지를 받고, Driver를 설정하는 것 같다.

MySQL을 단순하게 띄워서 사용하고 싶다면 이 정도 설정이면 충분하지만, 테스트 컨테이너를 사용한 이유는 테스트하기 까다로운 Redis 같은 인프라 환경을 설정하기 위해서이다.

또한, utf8mb4 같은 문자열 인코딩 설정을 세팅할 수 없다는 점 등 커스텀한 환경을 구축하기에는 부족한 점이 있다.

따라서 사용자 정의 환경을 위해 Docker compose 기반의 테스트 컨테이너가 필요했다.

테스트 컨테이너는 (당연하게) Docker compose를 지원한다.

또한 Kotest는 다양한 라이브러리와 프레임워크의 통합을 위해 Extension을 제공하는데, Spring Extension도 그중 하나이다.

override fun extensions() = listOf(SpringExtension)

Kotest는 테스트 컨테이너를 위한 Extension 또한 제공한다.

다음과 같이 라이브러리를 추가하고 테스트 코드를 작성한다.

dependencies {
    ...
    testImplementation("io.kotest.extensions:kotest-extensions-testcontainers:${kotestExtensionTestcontainersVersion}")
    ...
}
@SpringBootTest  
class GalaxyhubApplicationTests : DescribeSpec({  

    extensions(SpringExtension)

    install(DockerComposeContainerExtension(File("src/test/resources/docker-compose.yml"), ContainerLifecycleMode.Project))  
        .apply {  
            waitingFor("test_mysql", ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2"))  
            start()
        }

    describe("contextLoads") {  
    }  
})

Docker compose는 비동기적으로 실행되기 때문에, waitingFor() 메서드로 동기화 작업이 필요하다.

docker-compose.yml 파일은 다음과 같이 설정했고, 테스트가 끝나면 컨테이너가 삭제되므로 volume을 설정하지 않았다.

version: "3"  
services:  
  test_mysql:  
    image: mysql:8.0.33  
    ports:  
      - "13306:3306"  
    environment:  
      TZ: Asia/Seoul  
      MYSQL_DATABASE: test  
      MYSQL_ROOT_PASSWORD: 1234  
      MYSQL_USER: test  
      MYSQL_PASSWORD: 1234  
    command:  
      - --character-set-server=utf8mb4  
      - --collation-server=utf8mb4_unicode_ci

하지만 이렇게 했다고 끝이 아니다.

다른 테스트 클래스가 생기면 매번 테스트 컨테이너를 설정하기 위해 중복된 코드를 작성해야 한다.

@SpringBootTest  
class FooTest : DescribeSpec({  
    ...
    install(DockerComposeContainerExtension(File("src/test/resources/docker-compose.yml"), ContainerLifecycleMode.Project))  
        .apply {  
            waitingFor("test_mysql", ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2"))  
            start()
        }
    ...
})

@SpringBootTest  
class BarTest : DescribeSpec({  
    ...
    install(DockerComposeContainerExtension(File("src/test/resources/docker-compose.yml"), ContainerLifecycleMode.Project))  
        .apply {  
            waitingFor("test_mysql", ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2"))  
            start()
        }
    ...
})

또한, 테스트 코드를 실행시키면 실행이 되지 않는다.

이유는 중복된 이름의 컨테이너가 테스트 클래스의 개수만큼 실행되기 때문이다.

따라서 테스트에서 상속하는 Spec을 한 번 더 감싸는 커스텀 Spec을 만들어 중복을 해결하려고 했다.

@SpringBootTest  
abstract class IntegrationSpec(body: DescribeSpec.() -> Unit = {}) : DescribeSpec(body) {    

    override fun extensions() = listOf(SpringExtension)
  
    init {  
        install(DockerComposeContainerExtension(File("src/test/resources/docker-compose.yml"), ContainerLifecycleMode.Project))  
            .apply {  
                waitingFor("test_mysql", ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2"))  
                start()  
            }  
    }  
}
class FooTest : IntegrationSpec({  
    ...
    describe("test") {  
        ...
    }
    ...
})

class BarTest : IntegrationSpec({  
    ...
    describe("test") {  
        ...
    }
    ...
})

하지만 역시 이 방법 또한 마찬가지로 부모 클래스에서 테스트 컨테이너를 생성하고 실행시키기 때문에 중복된 컨테이너가 생기므로 테스트가 실패한다.

이것을 해결하려면 테스트 컨테이너를 정적 변수에 할당하여 사용하면 JVM에서 단 하나의 테스트 컨테이너가 실행되므로 중복된 테스트 컨테이너가 생길 일이 없다.

테스트 컨테이너 공식 문서에서는 싱글턴 컨테이너를 사용하는 방법이 나와 있다.

따라서 다음과 같이 코드를 변경하면 된다.

@SpringBootTest  
abstract class IntegrationSpec(body: DescribeSpec.() -> Unit = {}) : DescribeSpec(body) {  
  
    override fun extensions() = listOf(SpringExtension)  
  
    companion object {  
  
        @JvmStatic  
        private val container = DockerComposeContainer(File("src/test/resources/docker-compose.yml"))  
            .apply {  
                waitingFor("test_mysql", ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2"))  
                start()  
            }  
    }  
}

변경된 코드에서는 더 이상 Testcontainers Extension을 사용하지 않는다.

왜냐하면 정적 변수로 선언한 즉시 생명 주기가 전체 테스트에 종속되기 때문이다.

또한 공식 문서 자료에도 읽어보면 나와 있듯, 굳이 stop() 메서드로 명시적으로 컨테이너를 종료할 필요가 없다.

왜냐하면 테스트 컨테이너가 실행될 때 Ryuk 컨테이너가 같이 실행되는데, Ryuk 컨테이너가 JVM이 종료될 때 테스트 컨테이너를 종료해 주기 때문이다.

싱글턴 컨테이너를 사용한 방법에서 @Testcontainers, @Container 어노테이션을 사용한 방법은 잘못된 방법이니, 공식 문서를 참고하자

문제 발생

이렇게 해피엔딩으로 끝나는 줄 알았으나 다른 테스트를 실행하는 과정에서 문제가 발생했다.

주로 테스트를 실행할 때 특정 패키지의 테스트를 모두 실행하는 편인데, 테스트 컨테이너를 정의한 스펙이 포함되지 않은 패키지에서 테스트 컨테이너가 실행되는 것이었다.

이상하게 단일 테스트 클래스를 실행할 때는 테스트 컨테이너가 실행되지 않았다.

원인 추론, 확인

테스트 실행을 패키지 단위로 하면, 모든 테스트 클래스에 대해 인스턴스를 만들고 선택한 패키지가 아닌 인스턴스는 실행하지 않는 것이 분명했다.

따라서 테스트 컨테이너를 정의한 스펙에 다음과 같이 로그를 남겨보았다.

@SpringBootTest  
abstract class IntegrationSpec(body: DescribeSpec.() -> Unit = {}) : DescribeSpec(body) {  

    override fun extensions() = listOf(SpringExtension)  

    companion object {  
  
        @JvmStatic  
        private val container = DockerComposeContainer(File("src/test/resources/docker-compose.yml"))  
            .apply {  
                ...
                log.info { "테스트 컨테이너 실행" }
                ...
            }  
    }  
}

놀랍게도 다음과 같은 로그가 남겨진 것을 발견했다.

20:50:24.906 [main] INFO kr.galaxyhub.sc.common.IntegrationSpec -- 테스트 컨테이너 실행

그리고 브레이크 포인트를 찍어 디버깅을 해보니 io.kotest.framework.discovery.Discovery 클래스의 doDiscovery() 메서드에서 선택된 테스트에 대해 필터링하는 것을 확인했다.

class Discovery(
    ...
) {
    ...
    private fun doDiscovery(request: DiscoveryRequest): Result<DiscoveryResult> = runCatching {  
      
       val specClasses =  
          if (request.onlySelectsSingleClasses()) loadSelectedSpecs(request) else fromClassPaths  
      
       val filtered = specClasses  
          .asSequence()  
          .filter(selectorFn(request.selectors))  
          .filter(filterFn(request.filters))  
          // all classes must subclass one of the spec parents  
          .filter(isSpecSubclassKt)  
          // we don't want abstract classes  
          .filterNot(isAbstract)  
          .toList()
      ...
    }
    ...
}

즉, 테스트 클래스에 대해 필터링을 수행하면서 모든 클래스를 로딩하는데 이때 companion object의 정적 변수가 초기화되며 컨테이너가 실행되는 것이다.

단일 테스트만 실행했을 때 해당 지점에 브레이크 포인트가 찍히지 않는 것을 봐선, 필터링 기능 때문에 어쩔 수 없는 것이 분명했다.

해결 방안

해결하려면 정적 필드를 정의하지 않으면 되는 것인데, 이렇게 되면 클래스마다 중복된 테스트 컨테이너가 실행되므로 해결할 수 없다.

원하는 기능은 다음과 같다.

  1. 특정 테스트를 실행할 때 테스트 컨테이너가 실행될 것
  2. 여러 테스트를 동시에 실행해도 하나의 테스트 컨테이너만 실행될 것

간단한 아이디어로 다음과 같은 어노테이션을 정의하여, 테스트가 실행되기 전 테스트 클래스에 다음과 같은 어노테이션이 있으면 테스트 컨테이너가 실행되는 것을 떠올렸다.

@Target(AnnotationTarget.CLASS)  
@Retention(AnnotationRetention.RUNTIME)  
annotation class EnableTestcontianers
@SpringBootTest  
@EnableTestcontianers  
abstract class IntegrationSpec(body: DescribeSpec.() -> Unit = {}) : DescribeSpec(body) {  
  
    override fun extensions() = listOf(SpringExtension)  
}

이게 가능하려면 전체 테스트를 실행하기 전, 생명 주기를 관리해 주는 훅이 있어야 한다.

Kotest에서 전체 테스트가 실행될 때 제공하는 훅 메서드가 있나 공식 문서를 뒤져보니 다음과 같은 Extension이 있는 것을 발견했다.

ExtensionDescription
ProjectExtensionIntercepts calls to the test engine before a project starts.

ProjectExtension은 인터페이스로, 다음과 같은 구현 메서드를 가지고 있다.

interface ProjectExtension : Extension {
    suspend fun interceptProject(context: ProjectContext, callback: suspend (ProjectContext) -> Unit)
}

또한 Javadoc으로 다음과 같이 설명되어 있다.

Implementations must invoke the callback callback if they wish the project to be executed, otherwise not calling callback will skip the entire project.
Implementations can modify the ProjectContext and changes will be reflected downstream.

해당 인터페이스를 구현하면 스프링의 Interceptor와 같이 전체 테스트의 전처리 과정을 수행할 수 있다.

따라서 다음과 같이 Extension을 구현했다.

BeforeProjectListener, AfterProjectListener 또한 있지만, 테스트 인스턴스에 특정 어노테이션이 붙었는지 검사가 불가능하여, 선택할 수 없었다.

class TestContainerExtension : ProjectExtension {  
  
    private val containers = mutableListOf<DockerComposeContainer<*>>()  
  
    override suspend fun interceptProject(context: ProjectContext, callback: suspend (ProjectContext) -> Unit) {  
        if (isTestcontianersEnable(context)) {  
            DockerComposeContainer(File("src/test/resources/docker-compose.yml")).apply {  
                waitingFor(  
                    "test_mysql",  
                    ShellStrategy().withCommand("mysql -u'test' -p'1234' -e'select 1' && sleep 2")  
                )  
                withLogConsumer("test_mysql", Slf4jLogConsumer(LoggerFactory.getLogger(javaClass)))  
                start()  
            }.also {  
                containers.add(it)  
            }  
        }  
        callback(context)  
        containers.forEach { it.stop() }  
    }  
  
    private fun isTestcontianersEnable(context: ProjectContext): Boolean {  
        val hasAnnotationItself = context.suite.specs.stream()  
            .anyMatch { it.kclass.hasAnnotation<EnableTestcontianers>() }  
        return if (hasAnnotationItself) true else context.suite.specs.stream()  
            .flatMap { it.kclass.superclasses.stream() }  
            .anyMatch { it.hasAnnotation<EnableTestcontianers>() }  
    }  
}

Ryuk 컨테이너가 테스트 컨테이너를 자동으로 종료해 주지만, 혹시 몰라 명시적으로 컨테이너를 종료해 주었다.

코드를 설명하면, 테스트 컨텍스트에서 @EnableTestcontianers 어노테이션이 붙은 Spec을 조회한다.

이때, 부모 클래스에 붙은 어노테이션은 검사하지 않기 때문에, 한 번 더 superclasses()를 호출하여 검사를 수행한다.

@EnableTestcontianers이 붙은 테스트 클래스가 있다면 테스트 컨테이너를 실행한다.

테스트 컨테이너 실행을 마치면, 파라미터로 넘어온 callback을 실행하여 테스트를 실행하고, 마무리로 실행된 컨테이너를 모두 종료한다.

하지만 이 Extension만 구현했다고 원하는 결과로 작동하지 않는데, 이유는 해당 Extension이 등록되지 않았기 때문이다.

Kotest는 @AutoScan 어노테이션을 제공하는데, 해당 어노테이션을 구현한 Extension에 붙이면 테스트에 해당 Extension이 적용된다.

@AutoScan
class TestContainerExtension : ProjectExtension {
    ...
}

혹은 AbstractProjectConfig를 상속한 클래스를 만들어, extensions() 메서드를 상속한 방법을 사용해도 된다.

이제 테스트 컨테이너를 실행할 테스트에 @EnableTestcontianers 어노테이션을 붙이면 특정 테스트에서만 테스트 컨테이너를 실행하도록 하여, 더욱 효과적인 테스트를 구성할 수 있다!

@SpringBootTest  
@EnableTestcontianers  
abstract class IntegrationSpec(body: DescribeSpec.() -> Unit = {}) : DescribeSpec(body) {  
    ...
}

정리

Testcontainers를 사용하는 게 간단할 줄 알았는데, 생각보다 과정이 험난했다. 😂

검색했을 때 블로그마다 설명들이 다르고, best practice를 찾을 수 없어 실험적으로 계속 적용할 수밖에 없었다.

어찌저찌 적용을 마무리하고, 패키지로 테스트를 실행했을 때 테스트 컨테이너가 실행되는 것을 보고 한숨이 나왔지만, 결국 해결했다. 😂

이전에 Kotest의 생명 주기 훅에 대해 찾아보지 않았다면, 해결하는 데 더 시간이 걸렸을 것 같다.

역시 라이브러리는 공식 문서가 잘 만들어진 것을 선택해야 하는 것 같다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글