TestContainer를 이용한 JUnit 통합 테스트

    데이터베이스를 포함한 통합 테스트

    통합 테스트는 일반적으로 어플리케이션 외부 프로세스와의 통신까지 고려한다. 가장 대표적인 프로세스는 데이터베이스인데, 데이터베이스를 포함한 동작 단위로 테스트 하는 것을 통합 테스트 중 하나로 고려한다. 데이터베이스를 포함한 통합 테스트에서는 다음 사항을 고려해서 테스트 해보면 좋다. 

    • 테스트 케이스마다 데이터베이스의 격리 보장
    • 실제 운영 환경에 적합한 환경을 제공하는가

    아래에서 내용을 조금 더 고려해보자.

     

    테스트 케이스마다 데이터베이스의 격리 보장

    테스트 케이스마다 데이터베이스의 격리가 보장되어야 하는 것은 중요하다. 왜냐하면 어떤 테스트의 결과가 다른 테스트에 영향을 미친다면 테스트를 할 때 마다 다른 결과가 나올 것이다. 예를 들어 A, B라는 테스트가 있고 A 테스트가 끝난 후 데이터베이스에 데이터가 남아있는 상황이라고 가정하자. 그리고 B는 데이터베이스에 있는 데이터를 읽은 후 테스트를 하는데, 이 때 A의 테스트 잔해에 영향을 받는 상황이 될 수 있다. 이 경우 B의 테스트 결과는 어떨 때는 참이고, 어떨 때는 거짓이 된다. 

    문제는 이런 경우 테스트 케이스에 전반적으로 많아질 때다. 테스트를 할 때 마다 결과가 바뀌게 되면 결국 이 테스트 코드는 믿을 수 없는 코드가 된다. 아무런 효용성이 없어진 테스트 코드는 머지 않아 많게는 수천 줄의 주석으로 바뀌게 될 것이다. 이런 문제를 미연에 방지하기 위해서 각 테스트마다 데이터베이스의 데이터는 격리 되어야 한다. 

     

    실제 운영 환경에 적합한 환경을 제공하는가

    주로 데이터베이스라고 하면 RDBMS를 떠올린다. 데이터베이스를 포함한 통합 테스트를 작성한다면, 당연히 운영 DB는 제외되어야 한다. 운영 DB는 매 순간마다 DB의 상태가 바뀔 것이기 때문에 테스트 결과에 영향을 미친다. 또한, 테스트의 결과가 운영 DB에 영향을 미치면 어플리케이션 전체의 데이터 정합성에 큰 문제를 미칠 수 밖에 없다. 따라서 운영 DB와 테스트 환경에서 사용할 DB는 분리되어야만 한다. 

    그렇다면 테스트 환경에서 사용할 DB를 위해서 어떤 선택지가 존재할까? 개인적으로 생각하는 DB는 다음과 같다.

    • 컨테이너 
    • 서버에 띄어진 DB
    • In-Memory DB

    가장 먼저 손쉽게 사용할 수 있는 인메모리 DB를 고려해볼 수 있다. 인메모리 DB에는 SQL Lite, H2가 있을 수 있다. 이들의 장점은 어플리케이션에서 임베디드로 사용할 수 있기 때문에 빠르게 동작하고, 유지보수 비용이 거의 들지 않는다는 점이다. 그렇지만 이들은 한 가지 문제점을 가지고 있는데, 모든 종류의 RDBMS를 지원하지 않는다는 것이다. RDBMS는 각각이 방언(dialect)를 가지고 있고, 서로 다르게 동작할 수도 있다. 인메모리 DB를 사용한다는 것은 '편리'할 수는 있지만 '실제 운영 환경'에는 동떨어졌다는 것이다. 

    두번째로 서버에 띄어진 DB를 생각해볼 수 있다. 테스트 전용 DB가 생기는 것이기 때문에 운영 환경에 가까울 수 있어서 그 부분은 장점이다. 그렇지만 관리해야 할 서버가 하나 추가 되기 때문에 유지보수 비용에서 나쁜 점수가 들어간다. 

    세번째는 컨테이너다. 필요한 경우 RDBMS 이미지를 가져와서 컨테이너를 띄우고, 그 컨테이너를 대상으로 테스트를 하는 것이다. RDBMS 이미지를 가져와서 동작하기 때문에 실제 운영에서 사용할 DB를 그대로 사용할 수 있다는 장점이 있고, 테스트를 할 때만 사용하기 때문에 유지보수 비용도 상대적으로 적다. 

     

    정리

    데이터베이스를 포함한 통합 테스트를 할 때는 테스트 간의 격리성, 실제 운영 환경과 유사한지를 고려해야한다. 이런 것들을 고려한다면 세 가지 방법이 존재한다고 생각하는데, 그 중에서 가장 합리적인 것은 실제 운영 환경과 유사하면서 유지보수 비용이 적게 드는 컨테이너를 테스트용 DB로 사용하는 것이다. 이번 포스팅의 뒷쪽 부분은 테스트용 DB로 사용하는 방법을 작성해보고자 한다. 


    컨테이너를 테스트용 DB로 사용

    도커 컨테이너를 테스트용 DB로 사용하기로 결정했다면, '어떻게' 사용할지도 알아봐야한다. 짧은 식견으로는 두 가지 방법이 존재한다고 생각한다. 

    • 쉘 스크립트를 작성해서 테스트 전후로 띄우기
    • 서드 파티 라이브러리를 이용해 테스트 전후로 띄우기

    개인적으로는 잘 만들어진 서드 파티 라이브러리가 존재한다면, 그 라이브러리를 이용해서 목적을 달성하는 것이 좋다고 생각한다. 가장 쉽게 떠올릴 수 있는 이유는 이거다. 

    프로그램이 죽을 때, Exit(1)만 보여주는게 좋을까? 아니면 죽은 이유를 StackTrace로 보여주는 것이 좋을까?

    이런 것들을 고려한다면 나는 아래 두 가지 이유를 바탕으로 서드파티 라이브러리를 이용해서 테스트 컨테이너를 띄우는 것을 추천한다. 

    1. 그 언어로 Wrapping 되어있다. 이 말은 예외가 발생되었을 때, 단순히 SIGTERM이 아니라 그 언어가 필요로 하는 Exception 로그들을 던지며 종료된다. 즉, 디버깅 하기 편리하다. 
    2. 서드 파티 라이브러리는 사용에 필요한 부분들만 공개 API로 남겨둔다. 내부 구현을 자세히 알 필요가 없이, API만 숙지하고 사용하면 된다. 

     


    TestContainer로 integration Test하기

    JUnit 테스트 프레임워크에서 동작하는 도커 서드파티 라이브러리는 TestContainer가 있다. 이것을 이용하면 손쉽게 테스트 전후로 도커 컨테이너를 띄워서 통합 테스트의 외부 의존성으로 사용할 수 있다. 


    의존성 추가하기

    TestContainer는 외부 라이브러리기 때문에 의존성을 추가해서 사용해야한다. 나는 MySQL을 기준으로 동작 시킬 것이기 때문에 아래와 같이 추가했다. 필요하다면 다른 컨테이너도 추가할 수 있다. (https://mvnrepository.com/search?q=org.testcontainers)

    // build.gradle
    testImplementation 'org.testcontainers:junit-jupiter:1.17.6'
    testImplementation 'org.testcontainers:mysql:1.17.6'

    BaseTest 클래스 작성하기

    BaseTest 클래스는 이름에서 알 수 있듯이 필요한 컨테이너를 띄우는 역할을 한다. 

    • @BeforeAll은 이 클래스와 관련된 모든 테스트 코드를 실행하기 전 단 한번만 수행한다. 이 때, 컨테이너를 생성하고 실행한다.
    • @CloseAll은 이 클래스와 관련된 모든 테스트 코드를 다 실행한 후 단 한번만 수행한다. 이 때, 생성된 컨테이너를 종료시킨다. 

     

    @BeforeAll은 다음과 같은 과정을 거친다.

    1. @BeforeAll 메서드 내부에서는 MySQL 컨테이너를 하나 만든다. 이 때 이미지는 'mysql:8.0.31'로 사용한다. DB 이름과 DB 계정 정보를 설정하고, 컨테이너가 노출할 포트 번호를 설정한다. 
    2. 컨테이너를 시작한다. 이 때, Docker Engine이 반드시 켜져있어야한다. 컨테이너가 띄워지는데 이 때 컨테이너의 노출되는 포트번호는 랜덤으로 생성된다. 개발자가 이것을 지정할 수는 없다.
    3. 컨테이너가 띄워지면 처음에 노출된 3306 포트와 맵핑된 포트 번호를 알 수 있다. 3306은 컨테이너 내부의 포트 번호로 이해할 수 있고, 맵핑된 포트 번호는 호스트의 포트 번호가 된다. 
    4. 컨테이너의 호스트 포트 번호를 setProperty()를 이용해서 환경변수로 등록한다. 이것을 application.yml에서 동적으로 읽어오도록 한다.
    @SpringBootTest
    public class BaseTest {
    
        public static GenericContainer container;
        @BeforeAll
        public static void setUp() {
    
    		// 컨테이너 메타 정보 작성
            container = new MySQLContainer("mysql:8.0.31")
                    .withDatabaseName("test")
                    .withUsername("testuser")
                    .withPassword("testpass")
                    .withExposedPorts(3306);
    
    		// 컨테이너 생성
            container.start();
    
    		// 환경변수 추가
            Integer mappedPort = container.getFirstMappedPort();
            System.setProperty("MYSQL_PORT", mappedPort.toString());
        }
    
        @AfterAll
        public static void wrapUp() {
        	// 자원회수
            container.close();
        }
    }

    Application.yml 작성하기

    Application.yml에는 DB 정보와 Hibernate와 관련된 설정 값을 작성한다. 이 때 중요한 것은 다음이다.

    • MySQL의 Port 번호를 ${MYSQL_PORT}로 작성한다. 이것은 환경변수 MYSQL_PORT의 값을 읽어온다는 뜻이다. 이것은 컨테이너가 시작될 때 환경변수가 등록되면서 사용할 수 있게 된다.
    spring:
      datasource:
        url: jdbc:mysql://localhost:${MYSQL_PORT}/test
        username: testuser
        password: testpass
        driver-class-name: com.mysql.cj.jdbc.Driver
    
      jpa:
        hibernate:
          ddl-auto: create
        database: mysql

    Truncate 쿼리 작성하기

    위와 같이 작성했으면 Truncate 쿼리도 작성해두어야 한다. 운영 환경에 가깝게 만들것이기 때문에 @Rollback(value = false)로 작성할 예정이다. 즉, 각 테스트가 끝날 때 마다 DB에 데이터가 적재되기 시작한다. 이 상태로 놔두면 테스트 간의 간섭이 발생하기 때문에 Truncate 쿼리를 작성하고 테스트가 시작할 때 마다 실행하도록 한다. 

    Truncate 쿼리를 작성하는 이유는 매번 DDL을 하는 것이 번거롭기 때문이다. JPA를 이용한다면 초기에 DDL Auto를 이용해서 필요한 모든 테이블이 작성된다. 이것을 그대로 사용하기 위해서 테이블의 데이터만 모두 날리는 Truncate 쿼리를 만들어서 사용한다. 아래 쿼리를 커스텀해서 사용하면 된다. 

    이 때 FK_CHECKS를 0, 1로 하는 것은 연관관계가 있는 컬럼을 무시하고 지우기 위함이다. 

    public static void truncateAllTable(JdbcTemplate jdbcTemplate) {
    
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
    
        List<String> tableNames = List.of(
                "YOUT_TABLE_NAME");
    
        tableNames.forEach(tableName ->
                jdbcTemplate.execute("TRUNCATE TABLE " + tableName));
    
        jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
    }

    테스트에 사용하기

    테스트를 사용하는 것은 간단하다.

    • @SpringBootTest 어노테이션이 붙은 모든 테스트 클래스에 BaseTest를 상속 받도록 한다.
    • JdbcTemplate을 주입받고, @BeforeEach 메서드 내에서 truncate 쿼리가 실행되도록 한다. 

    @BeforeAll을 상속 받은 클래스끼리는 BaseTest에서 생성한 도커 컨테이너를 함께 사용한다. 즉, 하나의 테스트 클래스마다 하나의 테스트 컨테이너를 띄우고, 해당 테스트 클래스의 모든 테스트 메서드에서 함께 사용할 수 있게 된다. 도커 컨테이너를 여러 번 띄우면 시간이 오래 걸린다는 단점이 있는데 그런 문제점을 일부 해결할 수 있게 된다. 

    @SpringBootTest 어노테이션이 붙은 모든 테스트 클래스에 BaseTest를 상속받도록 하는 것은 설정의 문제가 발생하기 때문이다. @SpringBootTest가 있으면 해당 클래스에는 어플리케이션과 동일한 환경의 스프링 컨테이너가 만들어지고 그것을 주입받아서 사용하게 된다. 이 때, JPA를 사용하면 EntityManager가 스프링 빈으로 등록된다.

    EntityManager는 DataSource를 바탕으로 DB 정보를 읽어와서 생성되는 스프링 빈이다. 그렇지만 앞서서 application.yml에서 ${MYSQL_PORT}로 설정값을 주입 받기 때문에 테스트 컨테이너가 띄워지지 않은 상황에서 application.yml에 해당 값은 공백이 된다. DataSource는 DB Connection을 찾을 수 없게 되고 EntityManager의 스프링 빈은 등록에 실패하게 된다. 따라서 개인적으로는 @SpringBootTest 어노테이션이 붙은 모든 클래스에 BaseTest를 상속하도록 하는 것을 권장한다. 

    @SpringBootTest
    public class TestA extends BaseTest {
    
        @Autowired
        JdbcTemplate jdbcTemplate;
    
        @BeforeEach
        void init() {
            TestUtils.truncateAllTable(jdbcTemplate);
        }
    
        @Test
        void test1(){
        ...
        }
        
        @Test
        void test2(){
        ...
        }
    }

    실행해보기

    정상적으로 설정이 되었다면, 테스트가 실행될 때 아래 로그가 보일 것이다. 로그를 보면 알겠지만 Wrapping된 DockerClient를 불러와서 컨테이너를 생성할 것을 Docker Engine에게 요청하는 과정이다. 이 때 만약 Port가 맵핑이 잘 되지 않았다면, Hibernate가 DDL 할 때 Connection Refuse가 발생하면서 문제가 된다. 

     


    알아둘 것

    다른 블로그들을 살펴보면 @TestContainers, @Container를 이용해서 상속을 사용하지 않아도 테스트 클래스에서 컨테이너를 공유해서 사용하는 방법을 제시한다. 그렇지만 이 어노테이션은 JUnit 5.x 버전부터 사용이 가능하다. 반면 2023년 2월 이전에 생성된 일반적인 스프링 프로젝트는 JUnit 4.x 버전을 사용하기 때문에 @TestContainers, @Container를 사용할 수 없다. 

    나 역시 JUnit 4.x 버전을 사용했기 때문에 많은 삽질 끝에 위와 같이 수정해서 테스트 코드를 작성했다. 

    댓글

    Designed by JB FACTORY