ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [사내 TestContainer 적용] Spring boot 통합테스트 도입기
    Spring Boot/테스트 2023. 1. 6. 00:38

    Test 결과

    개발자가 되고 가장 고된 작업으로 기억에 남을 통합테스트 작성 과정을 기록에 남긴다.

     

    작년 3월 입사하고 백엔드 시니어님의 갑작스러운 퇴사로,

    익숙하지 않은 지식들을 내것으로 만들기 위해 또는 살아남기 위해 씨름하던 중,

    작년 하반기 무렵 드디어 회사에 새로운 시니어님이 입사하셨다.

     

    직장 상사 보다는 동료로,  그리고 굉장히 능력있는 분이 오셔서 내게는 작년중 가장 큰 행운이었다고 할 수 있다.   

     

    그리고 작년 9월부터 새로온 분과 기존 Mybatis와 Postgres로 되어있던 프로젝트를 다른 기술 spec으로 처음부터 다시 구축하기로 결정했다. 

    (처음에는 Logging 작업을 진행한 후에, 점진적으로 프로젝트를 개선하려고 했지만, 일주일 정도를 작업해보시고는 처음부터 다시 만드는 쪽이 속도가 더 나올 것 같다는 판단을 하시고 새로 프로젝트를 구성하게 되었다. )

     

    [기존 프로젝트 -> 신규 프로젝트 바꾼 것들]

    • Spring boot 2.7.x-> Spring boot 3.0.0 
    • Java 11 -> Kotlin 1.7
    • Postgresql -> Mysql
    • Mybatis -> JPA
    • 통합 테스트코드 추가

    변경 과정에서 하고 싶은 말, 기록하고 싶은 것은 많지만

    오늘은 내가 담당해서 작성한 "통합테스트"에 대해서 기록한다.

     

    1. 😵기존의 테스트 방법

    처음 입사하고 받아본 코드에는 테스트 코드를 찾아볼 수 없었다. 

    CTO님이 설명해주신 없는 이유는 다음과 같았다.(Test 코드는 필요하지만 시간이 없다)

     

    1. 정책, 기획이 계속 변하는 상황이라 테스트 코드는 코딩 시간, 유지보수를 배로 들게 할 것이다.

    2. 우리 같은 스타트업에서는 당장 돌아가는 비즈니스 로직이 더 급하다.

     

    그래도 테스트를 안할 수 없기 때문에 Postman의 Collection Test 기능을 사용해서 테스트를 진행했다.

    Postman의 Collection 테스트

    장점은 아래와 같았다.

    • Local, Dev 환경 프로젝트에 실제 이용자의 행동을 시나리오를 만들어서 테스트하고 오류가 없는 것을 보장할 수 있다.
    • JavaScript를 이용해서 API 결과 값을 다른 API에 사용할 변수로 쓸 수 있고 작성 방법이 단순하다. 

    Postman Test 코드

    단점은 아래와 같았다.

    • 성공 사례에 대해서만 테스트하여 반쪽짜리 테스트
    • 여러명이 작업해야하기 때문에 JavaScript 파일에 대한 형상관리가 필요 (Postman의 유료 기능으로 클라우드로 지원하지만, 비용을 지불할 수 없어서 export, import를 반복하는 작업이 수반되었다.) 
    • 테스트에 실패할때마다 IDE를 켜서 디버깅 모드로 API를 호출하면서 수작업으로 테스트한다. 
    • Dev 환경의 경우 Test Data를 미리 생성해서 관리되어야했는데, API가 추가되면 서로 관계가 생기거나 수정이 일어나면 복잡해지는 문제
    🔑 위의 기존 테스트를 Code Level로 옮긴 작업을 기록한다.

     

    내가 담당한 업무는 상기 작업을 Postman 테스트를 Code로 옮기는 작업이다. (TDD, 단위 테스트를 원하시는 분은 부적합하다.)

    또한 공부를 하다가 보니, 내가 작성한 테스트는 아이스크림 패턴이란 이름의 안티 패턴이다. 단위테스트가 많을때에만 사용할 수 있는 방식이다.

    2. 🧑‍🏫 공유의 기쁨

    일주일 정도의 충분한 리서치 기간이 주어졌고,

    TestContainer라는 라이브러리를 사용해서 전체 API의 성공, 예외 상황을 테스트하는데 2달 정도 걸려서 구현했다.

     

    입사하고 9개월만에 아... 나 개발자 된것 같다라는 기쁨을 얻었고, 고생한 만큼 사내에만 공유하기 아쉬워서 블로그에 남겨본다. 

    사내 세미나를 진행했다.
    사내 문서
    후속 조치
    TestContainer 커뮤니티에 직접 사용법을 물어봤다.

    3. 🧑‍🏫 TestContainer 

    3.1 🤔Test Container란?

    🧑‍🏫 테스트를 위한 DB 컨테이너를 시작, 삭제, 중지하는 등의 life-cycle를 손쉽게 관리해주는 라이브러리이다. 쉽게 말하면 테스트를 위해 Docker Container를 띄워서 사용하게 해주는 라이브러리.
    
    (자바, Go, Node.js, python, Rust.. 등등의 다양한 언어 지원)
    • 사용 조건
      1. 컴퓨터에 도커가 설치되어 있을 것 
      2. CI 환경이 구축되어 있을 것 (빌드, 테스트 자동화)
    • Spring의 경우 TestContainer Java 라이브러리를 사용한다.
      • Java로 Docker Container를 생성, 실행, 중지, 삭제 할 수 있다.
      • Java로 테스트 실행 전/후로 Container를 start, stop 할 수 있다.
      • Parallel Test를 지원한다.
      • 다양한 DB module을 제공하고 있다.

    3.2 사용하는 이유

    TestContainers를 사용하면 개발, 테스트, 운영 환경 동일한 DB를 사용할 수 있다.

    <aside> 🔑 테스트 코드의 정확성을 높여주고 멱등성을 보장한다.

    </aside>

    개발하면서 하나의 프로젝트에 아래와 같은 여러 DB를 마주할 수 있다.

    1. Local DB 환경 : 개발할때 Local에 구축한 DB
    2. 빌드시 H2 사용 : CI/CD build 할때 DB
    3. 개발 버전 DB : 통합 테스트용 DB 환경
    4. 운영 DB : 사용자가 사용하는 DB 환경

    “어? 테스트 할 땐 잘 돌았는데?”
    
    🙅‍♂️ 같은 코드라도 어떤 DB를 사용하느냐에 따라서 다른 테스트 결과가 나올 수 있다.
    • 왜 차이가 날까? ****
      • DB 마다 트랜잭션 관련된 설정이 다를 수 있다. (isolation level, propagation 등 데이터베이스 마다 설정이 다르다)
      • DB 마다 지원하는 SQL의 차이가 있다.
    • TestContainers를 사용하면 아래와 같이 환경을 구성할 수 있다.

    • 텅 빈 데이터 베이스를 컨테이너로 띄워서 테스트하고 지워버리는 것이 테스트 컨테이너의 철학
    • TestDB를 따로 구성해서 테스트 하는 방법보다 더 좋은 점은, Test DB 데이터가 망가지거나 하는 것에서 자유로워질 수 있다.
    • 테스트 할때만 비어있는 DB로 테스트하고 그 DB를 없애 버리는게 TestContainer의 등장 이유
    • 단점
      • 테스트의 작성 속도가 느려진다.
        • 그러나, 로컬에서는 Junit의 태깅을 사용 해서 Test에서 제외하고 CI Build에서만 동작하게 하는 방식으로 단점을 해소할 수 있다.
        • Test Case가 아니라 Test Suite를 단위로 테스트 시나리오를 미리 설계하고 작성한 뒤에 한번에 Test를 돌려보며 작업해야한다. (하나의 클래스 테스트하는데 인스턴스를 올리는 이유로 20-30초가 소요된다. test 함수 하나하나 작성하면서 계속 돌리는 단위테스트가 아니다.)

    3.3 🤔 동작 원리

    1. 테스트를 실행하면 라이브러리가 자동으로 Ryuk라는 Docker Container를 생성, 실행
    2. Ryuk는 코드에 설정한 DB를 생성, 실행하고 테스트 시작
    3. 테스트가 종료되면 Ryuk가 인스턴스 정리하고 마지막으로 종료된다.

     

    3.4 🤔프로젝트에 설정하기

    1. build.gradle에 라이브러리 추가
    testImplementation("org.testcontainers:mysql:1.17.4") // mysql module
    testImplementation("org.testcontainers:junit-jupiter:1.17.4") // Junit 사용 위한 라이브러리
    
    • TestContainer의 Jupiter extention으로 Junit5 테스트에서 동작할 수 있도록 함.
    • Junit 제공기능 중에 테스트 순서 (Order), 테스트 반복 (MethodSource) 활용해서 순차적으로 테스트가 진행할 수 있도록 함.Junit5 구성

    구글 이미지

    • TestContainer Mysql module의 클래스 구조

    3.5 🤔Test Container 싱글턴으로 동작하게 설정

    1) 수동으로 라이프 사이클 관리

    1. Static으로 DB 컨테이너 정보 코드로 작성 (Static으로 작성해야 하나의 클래스에서 1개의 컨테이너가 돈다. Static이 하니면 함수마다 1개의 컨테이너 실행)
    2. 추상 클래스로 만들어서 전체 TestClass에서 하나의 싱글톤으로 테스트 컨테이너가 동작하게 구성 
    3. @BeforeAll로 start
    4. @AfterAll로 stop
    abstract class TestContainerInitializer {
        companion object {
            val mysqlDB = MySQLContainer<Nothing>(  // 마이에스큐엘 테스트 컨테이너
                DockerImageName.parse("mysql:8.0.30")
            ).apply {
                waitingFor(Wait.forListeningPort()) // 로컬에 따로 띄운 DB에 붙지 않도록 대기 설정
                withReuse(false)
            }
    
            val redis = GenericContainer<Nothing>(
                DockerImageName.parse("redis")
            ).apply {
                withExposedPorts(6379)
                waitingFor(Wait.forListeningPort())
            }
    
            @JvmStatic
            @DynamicPropertySource // 런타임에서 동적으로 Property를 등록해주는 어노테이션
            fun properties(registry: DynamicPropertyRegistry) {
                registry.add("spring.datasource.url", mysqlDB::getJdbcUrl)
                registry.add("spring.datasource.password", mysqlDB::getPassword)
                registry.add("spring.datasource.username", mysqlDB::getUsername)
    
                registry.add("spring.redis.host", redis::getHost)
                registry.add("spring.redis.port", redis::getFirstMappedPort)
            }
    
            init {
                mysqlDB.start()
                redis.start()
            }
        }
    }

    2) 자동으로 컨테이너 라이프 사이클 관리

    1. @Container 어노테이션을 붙이고 Static으로 DB 컨네이너를 정의한다.
      • static이 아니면 Test 함수 마다 컨테이너 생성한다. static이면 Testcontainer 안에 Test끼리 컨테이너를 공유
      • @Container 어노테이션이 자동으로 컨테이너를 시작, 종료 해준다.
    2. 자동 라이프사이클 관리의 경우, 테스트 컨테이너가 싱글턴으로 동작하지 않고, 클래스 마다 1개의 컨테이너가 뜨는 문제가 있었다. 
    • 예제 ) 테스트 컨테이너 mysql 모듈 사용한 경우
    @SpringbootTest
    @Testcontainers // 테스트 컨테이너에서 Junit 돌아가게 해주는 주피터 확장 어노테이션
    class TestClass @Autowired constuctor(
    		private val userRepository: UserRepository
    ) {
    
        companion object {
            @Container // 인스턴스 생성, 종료 자동으로 동작하게함
            val container = MySQLContainer<Nothing>(
                DockerImageName.parse("mysql") // 도커로 올라간 DB
            ).apply {
                withDatabaseName("database")
                withUsername("username")
                withPassword("password")
            }
        }
       
    }

     

    3.6 🤔Test Container의 property 스프링에 매핑하기

    1 ) ApplicationContextInitializer 사용해서 TestContainer DB와 Spring 연동 설정 방법 (feat. Port 지정)

    1. TestContainer 생성
    2. ApplicationContextInitializer를 구현하여 생성된 컨테이너 정보를 축출한 다음 Environment에 넣는 configuration을 작성한다.
    3. @ContextConfiguration(initializaers = ContainerPropertyInitializar::class) 로 테스트에 등록
    4. 테스트 코드에서 @Value 또는 Environment.getProperty(”환경변수”) 로 불러서 사용할 수 있다.
    class ContainerPropertyInitializer: 
    					ApplicationContextInitializer<ConfigurableApplicationContext> {
            
    	override fun initialize(applicationContext: ConfigurableApplicationContext){
               TestPropertyValues.of("container.port=" + mysqlContainer.getServicePort(db, port))
      }
    }
    

    2) DynamicPropertySource 사용해서 TestContainer 와 Spring 연동

    • 스프링 5.2.5에서 등장
    • 런타임에서 동적으로 환경변수 적용
    • TestContainer에서 권장하는 방식 (2023.1 기준)
    @JvmStatic
    @DynamicPropertySource // 런타임에서 동적으로 Property를 등록해주는 어노테이션
    fun properties(registry: DynamicPropertyRegistry) {
        registry.add("spring.datasource.url", mysqlDB::getJdbcUrl)
        registry.add("spring.datasource.password", mysqlDB::getPassword)
        registry.add("spring.datasource.username", mysqlDB::getUsername)
    
        registry.add("spring.redis.host", redis::getHost)
        registry.add("spring.redis.port", redis::getFirstMappedPort)
    }
    class ContainerPropertyInitializer: 
    					ApplicationContextInitializer<ConfigurableApplicationContext> {
            
    	override fun initialize(applicationContext: ConfigurableApplicationContext){
               TestPropertyValues.of("container.port=" + mysqlContainer.getServicePort(db, port))
      }
    }
    

    reference

    반응형

    'Spring Boot > 테스트' 카테고리의 다른 글

    [TEST 시리즈 1]단위테스트란? Unit 테스트 정의  (2) 2022.11.18
    JUnit이란?  (0) 2022.01.05
Designed by Tistory.