spring boot/테스트

spring boot의 test db환경에 testcontainers 적용 [삽질 후기]

junjunjun 2023. 3. 17. 21:54
반응형

본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.

 

TestContainers 쓰게 된 이유

기존 로컬 테스트 db를 사용하였을 때 단점이 있다.

  1. CIDI환경에서 테스트 db를 따로 만들어줘야 한다. 
    • 빌드를 수행할 때, 테스트 DB가 연결되어 있지 않으면 빌드가 실패한다.
  2.  만약 팀원들과 로컬 db설정이 다르다면, 테스트 결과가 서로 일치하지 않을 수 있다.

 

그래서 해결 방안으로 처음 나온 게 인 메모리 db이다.

  • 인메모리 db의 장점
    • 테스트 속도가 빠르다
    • 설정이 간단하다. (따로 db구축 안 해줘도 됨)
    • 각 팀원의 로컬에서도 같은 결과가 나온다.
    • cicd환경에서 따로 db 구축 안 해줘도 된다.
  • 인메모리 db의 단점(h2)
    • 실제 운영 db랑 다른 결과를 도출할 수 있다.
    • 실제 운영 db의 기능을 지원하지 않는 경우도 있다

인 메모리 데이터베이스는 많은 장점이 있지만, 일부 단점들로 인해 나중에 큰 문제가 발생할 수도 있을 거 같았다. 이러한 문제로 인해 다른 대안을 찾게 되었고. 

 

Testcontainers가 그 대안으로 선택되었다.

Testcontainers는 독립적인 컨테이너 환경에서 테스트를 실행할 수 있으므로, 로컬 환경에 의존하지 않고 일관된 테스트 결과를 얻을 수 있다. 쉽게 말해 테스트를 실행하기 전에 유저가 지정해 준 도커 컨테이너를 자동으로 구축해 준다.

  • Testcontainers의 장점
    • 운영 db와 같은 db를 사용한다.
    • 각 팀원의 로컬에서도 같은 결과가 나온다.
    • cicd환경에서 따로 db 구축을 안 해줘도 된다. (다만 docker 필요)
  • Testcontainers의 단점
    • 느리다. 컨테이너를 구축하는데 많은 시간이 걸린다. 체감상 10초..

 

찾아본 결과로는 대강 빠른 테스트 결과를 필요로 하는 unit 테스트에서는 인 메모리를 쓰고

통합테스트에서는 testcontainer를 쓰는 거 같았다.

 

개념 참고 자료

 

TestContainers 적용

적용 방법 자체는 간단하다. 하지만 여러 블로그 글과 공식 문서 등 testconatainers 적용 방법이 가지각색이라 삽질을 많이 하였다. 또한 우리 프로젝트에 맞게 고려해야 할 사항이 두 가지가 있는데

  1. 초기 데이터를 추가해줘야 한다.
  2. 여러 테스트를 동시에 실행하더라도 컨테이너는 하나로 공유되도록 한다.

이 부분에서도 많은 삽집을 하였다.

1. docker 설치

testconatainers가 제대로 작동하기 위해서는 기본적으로 Docker 환경이 세팅되어있어야 한다.

2. 의존성 추가

// testcontainers
testImplementation "org.testcontainers:mysql:1.17.6"
testImplementation "org.testcontainers:junit-jupiter:1.17.6"
// testImplementation "org.testcontainers:testcontainers:1.17.6"
  • mysql 컨테이너를 쓰기 위해서 org.testcontainers:mysql이 필요하다.
  • @Container, @TestContainers 어노테이션을 사용하기 위해서 org.testcontainers:junit-jupiter가 필요하다.
  • org.testcontainers:testcontainers의 경우 딱히 사용되는 부분이 없기 때문에 주석처리 하였다.

 

나아가기에 앞서 testcontainers에서 컨테이너를 생성하는 방식은 2가지가 있다.
3.1. MySQLContainer 객체를 이용하여 컨테이너 생성
4.1. yml 설정으로만 컨테이너 생성
두 방식은 이어지는 게 아닌 별 개로 설명된다.

3.1. MySQLContainer 객체를 이용하여 컨테이너 생성

먼저 Container객체를 이용하여 컨테이너를 생성하는 방법이다.

시작하기에 앞서 yml에 있는 db 설정을 다 없애야 한다.

 

yml 파일

spring:
  datasource:
#      db관련 설정 X
#      driver-class-name: com.mysql.cj.jdbc.Driver
#      url: jdbc:mysql://test
#      username: root
#      password:

 

MySQLContainer 등록

@Testcontainers
class ServiceTest {

    @Container
    static MySQLContainer mySQLContainer = new MySQLContainer("mysql:8") // mysql image 설정
            .withDatabaseName("masiltest")    // db 이름 지정
            .withUsername("root")             // username 지정
           .withPassword("");                 // password 지정
 }
  • @Testcontainers는 @Container가 붙은 모든 필드를 찾고 해당 컨테이너를 관리해 준다.
  • 정적 필드(static)로 선언된 컨테이너는 테스트 메서드 간에 공유가 된다.
    따라서 메서드가 실행되기 전에 한 번만 start 되고 마지막 메서드가 실행된 후에 stop 된다.
  • 참고로 인스턴스 필드로 선언된 컨테이너는 모든 테스트 메서드에 대해 start 되고 stop 됩니다.

위의 로직만 있을 경우 다음과 같은 오류가 발생한다.

Failed to determine a suitable driver class

 yml 파일에서 spring.datasource.driver-class-name 속성을 지정해주지 않아서 발생하는 오류이다.

그렇다고 yml에 직접 등록해 줄 경우 이번에는 url을 설정해 주라는 오류가 발생할 것이다.

여기서 몇몇 블로그 글에서는 아래의 설정을 추가하도록 알려주고 있다.

spring:
  datasource:
      driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver # 컨테이너 db용 드라이버
      url: jdbc:tc:mysql:8://

하지만 위와 같이 설정할 경우 yml설정으로 생긴 컨테이너, MySqlContainer로 생긴 컨테이너 총 두 개의 컨테이너가 생성되는 문제가 발생한다. 따라서 다른 방법으로 해결해줘야 한다.

 

mySQLContainer에서 우리가 생성해 준 컨테이너의 정보를 얻을 수 있다. 여기서 얻은 정보를 yml 속성 정보에 재정의를 해주면 더 이상 오류가 발생하지 않는다. (이 방법을 사용하기 위해 yml에 있는 db 설정을 다 없애줬던 것이다)

@DynamicPropertySource
public static void overrideProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
    registry.add("spring.datasource.username", mySQLContainer::getUsername);
    registry.add("spring.datasource.password", mySQLContainer::getPassword);
    registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName);
}

 

최종 코드는 다음과 같다.

@Testcontainers
public class ServiceTest {

    @Container
    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8")
            .withDatabaseName("masiltest")
            .withUsername("root")
            .withPassword("");

    @DynamicPropertySource
    public static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
        registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName);
    }
}

 

3.2. MySQLContainer 컨테이너 db에 초기 데이터 넣기

@Container
static MySQLContainer mySQLContainer = (MySQLContainer) new MySQLContainer("mysql:8")
    .withDatabaseName("masiltest")
    .withUsername("root")
    .withPassword("")
    .withInitScript("setup.sql"); // 추가

whitInitScript 메서드를 써주면 된다. 참고로 파일 경로는 /src/test/resources/setup.sql이다.

그다음 ddl-auto : none으로 꼭 설정해줘야 한다. 

테스트를 실행하게 되면 동작되는 순서는 (정확하지 않습니다. log로만 파악한 정보입니다)

컨테이너 생성 -> withInitScript로 설정된 파일 실행 -> ddl-auto 실행

none이 아닐 경우 withInitScript으로 인해 만들어진 데이터가 ddl-auto로 인해 사라진다.

 

+ 본인의 경우 처음에는 setup.sql에 더미 데이터 쿼리문만 추가하였고 ddl-auto를 통해 table이 생성되길 기대했다.

하지만 ddl-auto가 withInitScript보다 먼저 실행되도록 하는 방법을 찾을 수 없었다. 따라서 setup.sql에 table 생성 쿼리문도 추가해 주었다. (4.2에 방법이 있긴 함)

@BeforeAll
public static void setUp() {
    mySQLContainer.start();
    mySQLContainer.withInitScript("setup.sql");
} // 이런식으로 따로 빼줘도 안 되었다.

 

3.3. MySQLContainer 컨테이너 하나로 모든 테스트 돌리기

먼저 testcontainers를 이용해 테스트를 진행할 클래스에 지금까지 구현한 ServiceTest 클래스를 상속받도록 해준다.

 

문제점

  • 이제 전체 테스트를 돌리게 되면 테스트 시간이 엄청 오래 걸리며 Connection refused: connect라는 오류가 발생한다. 또한 클래스 단위로 새롭게 도커 컨테이너를 재생성해주고 있다.

목표

  • 테스트를 하나만 돌리든, 여러 개 돌리든 컨테이너 생성 작업은 딱 한 번만 발생하도록 하고 싶다.
// @Testcontainers   제거
public class ServiceTest {
//    @Container  제거
    static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8")
            .withDatabaseName("masiltest")
            .withUsername("root")
            .withPassword("")
            .withReuse(true)  // 컨테이너 재사용 허용, 근데 있고 없고 차이점이 없다..
            .withInitScript("setup.sql");

    static {
        mySQLContainer.start();
    }

    @DynamicPropertySource
    public static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQLContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mySQLContainer::getUsername);
        registry.add("spring.datasource.password", mySQLContainer::getPassword);
        registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName);
    }
}

더 이상 @Testcontainers와 @Container 가 필요 없다. @Container를 없애줘야 중간에 컨테이너가 stop 되지 않는다.

참고자료 : https://stackoverflow.com/questions/62425598/how-to-reuse-testcontainers-between-multiple-springboottests

 

정적 초기화 블록을 이용해 컨테이너를 start 해주었다. @BeforeAll을 사용해도 동일하게 동작한다.

@BeforeAll
static void beforeAll() {
    mySQLContainer.start();  // 클래스 단위로 실행되지만 컨테이너가 다시 시작되지는 않는다.
}

 

이제 전체 테스트를 돌리면 Connection refused: connect 오류가 발생하지 않고 하나의 docker 컨테이너만 생성되는 걸 볼 수 있다.

 

참고로 @Testcontainers와 @Container를 더 이상 사용하지 않기 때문에
testImplementation "org.testcontainers:junit-jupiter:1.17.6" 의존성은 없애줘도 된다.

 

4.1. yml 설정으로만 컨테이너 생성

이번에는 yml설정만으로 컨테이너를 생성하는 방법이다.

 

yml 설정

spring:
  datasource:
      driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver // 필수
      url: jdbc:tc:mysql:8:///dbname  // tc가 붙으면 컨테이너 자동 생성해줌
  jpa:
    hibernate:
      ddl-auto: update // none X

이렇게만 설정해 주면 테스트 코드 실행 시 컨테이너가 자동으로 생성된다.

컨테이너의 db명, username, password는 모두 디폴트값인 test가 들어간다.

참고 : https://www.baeldung.com/spring-boot-testcontainers-integration-test#1-one-database-per-test-with-configuration

 

설정은 간단하지만 몇 가지 단점이 존재한다.

Caused by: java.sql.SQLSyntaxErrorException: Table 'test.tablename' doesn't exist 
삽질하면서 제일 많이 본 에러... db명을 설정해 줘도 실제 db명은 test로 설정되어 나타나는 문제이다.

 

4.2. yml 설정으로만 컨테이너 db에 초기 데이터 넣기

yml 설정

spring:
  datasource:
      driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver // 필수
      url: jdbc:tc:mysql:8:///dbname?TC_INITSCRIPT=file:src/test/resources/setup.sql
  jpa:
    hibernate:
      ddl-auto: none // update, create X

TC_INITSCRIPT 를 사용하면 컨테이너가 생성될 때  setup.sql 스크립트가 실행된다.
참고 : https://www.testcontainers.org/modules/databases/jdbc/

setup.sql 이 실행된 다음 ddl-auto가 실행되기 때문에 만약 update나 create으로 설정하면 table을 다시 초기화해 버린다.

 

만약 ddl-auto의 기능을 켜놓고 싶다면
(예를 들어 테이블 세팅은 ddl-auto에게 맡기고 setup.sql으로는 더미 데이터만 추가하고 싶은 경우)

TC_INITSCRIPT 기능을 사용할 수 없다. setup.sql을 실행하기 위해 다른 방법을 사용해야 한다.

 

TC_INITSCRIPT 안 쓰는 방법

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

  jpa:
    hibernate:
      ddl-auto: update or create
 ----------------------------------
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위
public class ServiceTest {

    @Autowired
    private DataSource dataSource;

    @BeforeAll
    public void initData() throws SQLException {
        // 초기 sql 스크립트 실행
        CompositeDatabasePopulator populator = new CompositeDatabasePopulator();
        populator.addPopulators(new ResourceDatabasePopulator(new ClassPathResource("setup.sql")));
        populator.populate(dataSource.getConnection());
    }
 }

@TestInstance의 생명주기를 지정해 주면 @BeforeAll 어노테이션 메서드에 static을 안 붙여도 된다.

initDate메서드의 코드는 setup.sql 파일을 직접 실행시켜 주는 로직이다.

참고 : https://stackoverflow.com/questions/68598134/spring-sql-annotations-possible-to-run-once-before-all-tests

 

하지만 이 방식은 전체 테스트 코드를 실행시킬 경우 클래스 단위마다 beforeAll 메서드가 실행되는 단점이 있다.

따라서 TC_INITSCRIPT를 사용하는 방식을 권장한다.

 

4.3. yml 설정으로만 컨테이너 하나로 모든 테스트 돌리기

실행되는 속도를 보면 최초로 한 번만 컨테이너를 실행하는 거 같기에 따로 설정을 해주지 않았다.

 

그 밖에

컨테이너가 여러 개 생성되는 경우
설정이 잘못되어 연결이 잘 되지 않을 경우에 컨테이너가 여러 개 생성될 수 있다.
Caused by: java.sql.SQLSyntaxErrorException: CREATE command denied to user 'test'@'172.17.0.1' for table rnj
유저의 권한이 거부될 경우 발생한다. username을 root로 지정해 주면 해결된다.
Caused by: org.hibernate.HibernateException: Access to DialectResolutionInfo cannot be null when 'hibernate.dialect' not set
dialect을 지정하지 않아서 발생하는 오류이다. 하지만 다른 로직에 문제가 있어서 발생했을 수 있다.
spring.jpa.database-platform: org.hibernate.dialect.MySQL8Dialect
이걸 추가해 주면 원래의 오류 내용을 볼 수 있다.

 

컨네이너 구축시간이 너무 오래걸리기 때문에 평소에는 로컬db설정으로 진행하고 pr올리기 전에 확인차 testcontainers를 이용하게 될 거 같다.

 

구현 참고자료

 

반응형