본문 바로가기

Dot Programming/Spring

[Spring] TestContainers로 멱등성있는 MySql 테스트 환경 구축하기

    멱등성있는 테스트 환경 구축해야 하는 이유

    멱등성이란, 수학에서 사용하는 용어에서 유래한 것으로. 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 말한다. HTTP 메서드로 예를 들면, GET, PUT, DELETE 요청은 여러 번 요청해도 매번 요청 결과가 같아서 멱등성이 보장된다. 테스트 코드도 마찬가지로 어느 환경에서 실행해도 매번 동일한 요청 결과가 출력되어 멱등성이 보장되어야 한다.  배포나 로컬 환경에서 사용하는 DB가 서로 일치하지 않을 때 멱등성이 깨지는 경우가 발생한다.

    예로, @DataJpaTest에는 @AutoConfigureTestDatabase가 명시되어 있어서 스프링 테스트는 자동으로 내장된 DB를 찾아 사용한다. 만약 배포 환경을 MySql DB를 사용하고 있다면 H2와 MySql의 차이로 테스트 멱등성이 깨지게 된다. 

    @ExtendWith(SpringExtension.class)
    // ...
    @Transactional
    @AutoConfigureTestDatabase // 가본적으로 자동으로 내장 DB를 사용한다
    public @interface DataJpaTest{
    	// ...
    }

     

    @DataJpaTest를 실행하면 다음과 같이 인메모리 h2가 실행되는 것을 확인할 수 있다.

     

     

    테스트용 인메모리 DB(H2)

    스프링은 H2 인메모리 DB를 사용한다. 스프링 프로젝트에 h2 의존성을 추가했다는 가정 하에 테스트 환경(application-test.yml)에 아무런 설정을 하지 않은 채로 테스트를 실행한다면 스프링은 자동으로 H2 인메모리 DB를 생성하여 테스트를 동작시킨다.

    spring:
      h2:
        console:
          enabled: true

     

    H2는 매우 이용이 간단하고 빠르게 동작한다는 장점이 있지만, 배포용 DB에 특화된 기능을 테스트하거나 DDL을 명시할 때 H2를 사용하면 local용으로 다른 쿼리를 작성해야 한다는 문제가 발생할 수 있다. 이 외에도 다양한 문제가 발생한다는 것을 찾아볼 수 있었다.

     

    테스트 DB 환경 설정하는 방법들

    배포 DB가 MySql이라고 할 경우 테스트 환경에서도 같은 Mysql을 설정할 수 있는 방법은 여러가지가 있다.

     

    1. H2 인메모리 DB 활용하기

    위에서 말한 바와 같이 멱등성이 깨지게 되므로 사용하지 않는 것을 권장하고 있다.

     

    2. 로컬에 실제 DB를 생성하여 사용하기

    로컬에 실제 DB를 생성하여 테스트를 실행할 수도 있지만 이는 다른 OS 환경으로 이동할 때마다 매번 테스트 DB를 생성해줘야 한다는 불편함이 있다. 

     

    3. 사용하고자 하는 DB의 임베디드 라이브러리 적용하기

    인메모리 DB를 배포 환경에서 사용하고 있는 DB와 일치시켜 문제를 해결할 수도 있을 뿐더러 이 방식을 사용하면 다른 OS 환경에서 매번 테스트 DB를 생성해주는 번거로움을 겪지 않아도 된다.

     

    그런데 이러한 방식은 container 기술보다는 덜 경량화된 방식일 뿐더러 특정 버전이나 특정 OS에는 동작하는 않는 이슈가 있었다고 한다. 현재 mysql embedded 오픈소스에 접속해보면 deprecated 되었고 더 나은 대안인 Testcontainers를 사용하라고 추천해주고 있다.

     

    4. docker 사용하기 

    베디드 라이브러리가 deprecated가 된 이유은 컨테이너 기술을 지원하는 docker 때문이다. mysql 컨테이너 이미지만 있다면 어디에서든 간단하게 배포 환경과 일치하는 테스트 실행이 가능하다. 컨테이너 이미지를 띄우는 방법은 docker-compose 파일이나 혹은 직접 이미지를 빌드하여 docker 컨테이너 명령어를 실행하는 방법이 있다. 매번 이를 직접 실행하고 종료를 해줘야 하므로 여전히 불편함이 존재한다.

     

    5. testContainers 사용하기

    TestContainers를 사용하면 위에서 말한 문제점을 다 해결할 수 있다. Testcontainers는 docker 컨테이너를 외부 설정 없이 Java 언어만으로 구축할 수 있는 오픈소스 라이브러리이다. 이를 사용하게 되면 단순 테스트 실행만으로도 mysql 컨테이너가 실행되고 스프링 테스트는 해당 컨테이너에 등록된 DB로 테스트를 하게 되어 정말 쉽게 어떤 환경에서도 멱등성있는 테스트 환경을 구축할 수 있다. 

     

    Testcontainers로 테스트 환경 구축하기

    Testcontainers는 Junit 테스트를 지원하는 자바 라이브러리로 DB, Selenium 웹 브라우저 등 Docer 컨테이너에서 실행할 수 있는 경령화된 이미지를 제공해준다. 그래서 테스트 실행시 외부에서 따로 DB를 설정하거나 별도의 프로그램 또는 스크립트를 실행할 필요가 없이 자바 언어만으로 docker 컨테이너를 실행할 수 있다. 단점은 테스트 실행 시간이 느려진다는 점이 있다.

     

    1. 스프링 프로젝트 시작하기 - 로컬 환경 설정

    Testcontainers로 테스트 환경을 구축하여 각 통합 및 JPA 테스트에 적용해보자. 먼저 스프링 프로젝트를 만들어 다음과 같은 환경으로 실행시킨다.

    version: '3.1'
    
    services:
      mysql:
        image: mysql
        container_name: mysqldb
        environment:
          - MYSQL_DATABASE=foodb
          - MYSQL_ROOT_PASSWORD=foo
        ports:
          - 3307:3306
        networks:
          - spring-net
    
      spring-testcontainers-app:
        image: spring-testcontainers-app
        container_name: spring-testcontainers-app
        build: .
        restart: always
        environment:
          MYSQL_HOST: mysqldb
          MYSQL_DATABASE: foodb
          MYSQL_USER: root
          MYSQL_PASSWORD: foo
          MYSQL_PORT: 3306
        ports:
          - 8080:8080
        depends_on:
          - mysql
        networks:
          - spring-net
    
    networks:
      spring-net:

     

    2. Testcontainers 의존성 추가

    JUnit5에 Testcontainers를 추가하려면 다음과 같은 의존성을 추가해줘야 한다.

     

    gradle에 설정하기

    // testContainers
    testImplementation "org.junit.jupiter:junit-jupiter:5.8.1"
    testImplementation "org.testcontainers:junit-jupiter:1.16.3"
    testImplementation "org.testcontainers:mysql:1.16.3"
    

     

    3. Testcontainers 동작 테스트하기

    먼저 Testcontainers가 정상적으로 동작하는 확인하기 위해 다음과 같이 테스트를 작성해서 실행시켜 본다. prod나 dev 환경의 DB나 인메모리 DB와 연동되지 않고 독립적으로 컨테이너로 구동된 DB로 동작하는 것을 확인해주면 된다.

     

    구동시키는 방법도 여러가지가 있다. 

    1. 로컬 DB 설정과 매핑하여 구축하기
    2. TC 컨테이너 설정 오버라이딩하여 구축하기
    3. 테스트 독립 환경 구축하기 (권장)

     

    1. 로컬 DB 설정과 매핑하여 구축하기

    스프링 테스트 코드에서 따로 profile을 지정하지 않으면 application.yml에 있는 설정 값을 적용하게 된다. 그래서 test에서 구동되는 설정도 디폴트 환경설정 값과 일치하게 되므로 testcontainer 값도 그와 같이 설정해주면 된다.

    • 스프링 테스트가 DB 설정파일을 applcation.yml을 읽어서 테스트 DB 설정도 이와 같음
    • 그래서 컨테이너 DB 설정 == 테스트(=로컬) DB 설정을 해줘서 구동시킴

    application.yml 설정

    • ${MYSQL_~}는 docker로 구동할 때 동작하는 설정이다.
    • 로컬 DB 디폴트 값은 (database = test, username=test_user, password=1234)로 설정되어 있다.
    spring:
      datasource:
        url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:test}
        username: ${MYSQL_USER:test_user}
        password: ${MYSQL_PASSWORD:1234}

     

    CustomerIntegrationTest 테스트 코드 작성

    1. @SpringBootTest 통합테스트 클래스(CustomerIntegrationTest)를 생성한다.
    2. @TestContainers 애노테이션을 추가한다.
    3. "mysql:8"이미지를 추가하여 MySQLContainer 인스턴스를 생성한다.
    4. @DynamicPropertySource로 jdbcUrl은 testContainer에서 동작하는 mysql 주소로 오버라이딩해줘야 한다. 안그러면 jdbcUrl {jdbc:mysql://locahost~}을 그대로 사용하여 로컬 DB로 테스트하게 된다. 
    @Transactional
    @SpringBootTest
    @Testcontainers
    class CustomerIntegrationTest {
    
    	@Autowired
    	private CustomerRepository customerRepository;
    
    	@Container
    	private static MySQLContainer mysqlContainer = new MySQLContainer("mysql:8")
    					.withDatabaseName("test")
    					.withUsername("test_user")
    					.withPassword("1234");
    
            @DynamicPropertySource
    	public static void overrideProps(DynamicPropertyRegistry registry){
    		registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
    	
    	}
    	@Test
    	// ...
    
    }
     

    2. TC 설정 오버라이딩하여 구축하기

    그런데 1번과 같은 방식은 로컬의 설정 값이 변경하면 테스트에 설정되어 있는 값도 변경해줘야 한다는 불편함이 있다.  jdbcUrl을 오버라이딩한 것 처럼 mysql 컨테이너 자체에 있는 디폴트 값 설정을 모두 오버라이딩해주면 굳이 로컬 DB 설정과 매핑을 해주지 않아도 된다.

    • MysqlContainer 기본값은 (databaseName = "test", user="test", password="test")로 설정되어 있다.  
    • @DynamicPropertySource로 모든 설정 값을 오버라이딩하여 실행하면 mysql 컨테이너가 정상적으로 동작하여 독립된 테스트 환경을 구축할 수 있다.
    @Transactional
    @SpringBootTest
    @Testcontainers
    class CustomerIntegrationTest {
    
    	@Autowired
    	private CustomerRepository customerRepository;
    
    	@Container
    	private static MySQLContainer mysqlContainer = new MySQLContainer("mysql:8");
    
    
    	@DynamicPropertySource
    	public static void overrideProps(DynamicPropertyRegistry registry){
    		registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
    		registry.add("spring.datasource.username", mysqlContainer::getUsername);
    		registry.add("spring.datasource.password", mysqlContainer::getPassword);
    	}
    
    
    	@Test
    	// ...
    }

     

    3. 테스트 독립 환경 구축하기 (권장)

    그런데 2번보다 더 간단한 방법이 있다. 실제로 스프링 프로젝트에서는 자바 코드로 mysql 커넥션을 해주지 않아도 yml 설정만으로도 정상적으로 동작한다. testcontainers에서도 이와 같은 방식으로 구동이 가능하다. test 프로필(application--test)을 생성하여 test 환경에서만 적용되는 DB 설정을 해주면 된다.

     

    testcontainers mysql 8은 jdbc:tc:mysql:8:///로 url을 설정해준다.


    application-test.yml 설정

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

     

    CustomerIntegrationTest 테스트 코드 작성

    @ActiveProfiles("test")로 application-test.yml 설정파일을 참조할 수 있게만 해주면 testconatiner를 매우 간단하게 동작시킬 수 있다.

    @ActiveProfiles("test")
    @Transactional
    @SpringBootTest
    class CustomerIntegrationTest {
    
    	@Autowired
    	private CustomerRepository customerRepository;
    
    	@Test
    	// ...
    }
     

    4. MySql Testcontainers 싱글톤 컨테이너 생성하기

    MySql Container를 동작시켜 멱등성있고 독립된 테스트 환경을 구축하였다. 여기서 여러 테스트 클래스에 대해 한 번만 시작되는 컨테이너를 정의해서 사용한다면 더 원활한 테스트 환경을 만들 수 있다. Testcontainers에서 따로 이 사용 사례에 대해 추가로 제공하는 기능은 없지만 다음과 같은 패턴을 사용해서 구현할 수 있다.

     

     

    AbstractContainerBaseTest 추상 클래스 생성

    싱글톤 컨테이너는 BaseTest가 로드될 때 한 번만 실행된다. 그러면 위의 추상 클래스를 상속하는 모든 테스트 클래스에서는 동일한 컨테이너를 사용하게 된다. stop()은 따로 설정하지 않아도 testcontainers의 코어인 Ryuk container는 테스트 끝에서 정지 작업을 처리해준다고 한다.

    public abstract class AbstractContainerBaseTest {
    	static final String MYSQL_IMAGE = "mysql:8";
    	static final MySQLContainer MY_SQL_CONTAINER;
    	static {
    		MY_SQL_CONTAINER = new MySQLContainer(MYSQL_IMAGE);
    		MY_SQL_CONTAINER.start();
    	}
    }

     

    통합 테스트 실행하기

    다음과 같이 3개의 통합 테스트 클래스를 만들어보고 실행해보자.

    @SpringBootTest
    class FooIntegrationTest1 extends AbstractContainerBaseTest {
    	//...
    }
    
    @SpringBootTest
    class FooIntegrationTest2 extends AbstractContainerBaseTest {
    	//...
    }
    
    @SpringBootTest
    class FooIntegrationTest3 {
    	static final String MYSQL_IMAGE = "mysql:8";
    	static final MySQLContainer MY_SQL_CONTAINER = new MySQLContainer(MYSQL_IMAGE);
        
    	@BeforeAll
    	static void setUp(){
    		MY_SQL_CONTAINER.start();
    	}
       
        // ...
    ]

     

    해당 로그는 Mysql 컨테이너를 생성하는 로그이다. 여러 통합 테스트를 실행해도 컨테이너가 하나만 생성된다면 정상적으로 동작하는 것이다.

     

    Mysql 컨테이너 생성 로그

     

    5. 각 테스트에 싱글톤 MySql 컨테이너 적용하기 (@SpringBootTest, @DataJpaTest)

    1. @SpringBootTest 통합 테스트 커스텀 

    멱등성있는 통합 테스트를 실행하기 위해 많은 애노테이션이 쌓인다. 매번 모든 테스트 클래스에 명시해주기에는 중복된 코드가 많이 발생하므로 따로 커스텀해주어 사용한다.

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @ActiveProfiles("test")
    @Transactional
    @SpringBootTest
    public @interface IntegrationTest {
    }
    
    // 통합 테스트 
    @IntegrationTest
    class FooIntegrationTest1 extends AbstractContainerBaseTest {
    }
    
    ..
    
    @IntegrationTest
    class FooIntegrationTest2 extends AbstractContainerBaseTest {
    }
    
    ..
    
    @IntegrationTest
    class FooIntegrationTest3 extends AbstractContainerBaseTest {
    }

     

    2. @DataJpaTest JPA 테스트 커스텀 

    @DataJpaTest는 JPA와 연관된 테스트를 수행해주기 위한 애노테이션이다. @DataJpaTest 자체에 Spring 테스트, Transactional 등 많은 애노테이션들이 기본으로 명시되어 있다. 

     

    글 맨 위에서도 언급했던 부분으로, 그 중에 @AutoConfigureTestDatabase라는 애노테이션도 명시되어 있는데 이는 자동으로 내장된 임베디드 데이터베이스를 사용하도록 동작한다. TestContainers를 구동시켜도 H2 임베디드 데이터베이스로 테스트가 이루어지기 때문에 @DataJpaTest도 커스텀을 해줘서 H2 임베디드 DB가 아닌 Testcontainers에서 구동된 DB로 동작하도록 해줘야 한다.

     

    다음과 같이 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)을 추가해주고 profile을 test로 지정해주면 된다. 

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.TYPE)
    @ActiveProfiles("test")
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public @interface TCDataJpaTest {
    }
    
    // Jpa 관련 테스트
    @TCDataJpaTest
    class CustomerRepositoryTest extends AbstractContainerBaseTest {
    
    	@Autowired
    	private CustomerRepository customerRepository;
    
    	@Test
        // ...	
    }

     

     

    이와 같이 다소 테스트 실행이 느려진다는 단점이 있지만 어느 환경에서도 편리하게 멱등성있는 테스트를 실행할 수 있는 환경을 구축해보았습니다. 위에서 실습한 예제 코드는 github에서 확인이 가능합니다.

     

    testcontainers

     


    참고

    https://medium.com/riiid-teamblog-kr/testcontainer-%EB%A1%9C-%EB%A9%B1%EB%93%B1%EC%84%B1%EC%9E%88%EB%8A%94-integration-test-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-4a6287551a31

    https://phauer.com/2017/dont-use-in-memory-databases-tests-h2/

    https://www.inflearn.com/course/the-java-application-test/dashboard

    https://www.baeldung.com/spring-boot-testcontainers-integration-test