spring boot의 test db환경에 testcontainers 적용 [삽질 후기]
본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.
TestContainers 쓰게 된 이유
기존 로컬 테스트 db를 사용하였을 때 단점이 있다.
- CIDI환경에서 테스트 db를 따로 만들어줘야 한다.
- 빌드를 수행할 때, 테스트 DB가 연결되어 있지 않으면 빌드가 실패한다.
- 만약 팀원들과 로컬 db설정이 다르다면, 테스트 결과가 서로 일치하지 않을 수 있다.
그래서 해결 방안으로 처음 나온 게 인 메모리 db이다.
- 인메모리 db의 장점
- 테스트 속도가 빠르다
- 설정이 간단하다. (따로 db구축 안 해줘도 됨)
- 각 팀원의 로컬에서도 같은 결과가 나온다.
- cicd환경에서 따로 db 구축 안 해줘도 된다.
- 인메모리 db의 단점(h2)
- 실제 운영 db랑 다른 결과를 도출할 수 있다.
- 실제 운영 db의 기능을 지원하지 않는 경우도 있다
인 메모리 데이터베이스는 많은 장점이 있지만, 일부 단점들로 인해 나중에 큰 문제가 발생할 수도 있을 거 같았다. 이러한 문제로 인해 다른 대안을 찾게 되었고.
Testcontainers가 그 대안으로 선택되었다.
Testcontainers는 독립적인 컨테이너 환경에서 테스트를 실행할 수 있으므로, 로컬 환경에 의존하지 않고 일관된 테스트 결과를 얻을 수 있다. 쉽게 말해 테스트를 실행하기 전에 유저가 지정해 준 도커 컨테이너를 자동으로 구축해 준다.
- Testcontainers의 장점
- 운영 db와 같은 db를 사용한다.
- 각 팀원의 로컬에서도 같은 결과가 나온다.
- cicd환경에서 따로 db 구축을 안 해줘도 된다. (다만 docker 필요)
- Testcontainers의 단점
- 느리다. 컨테이너를 구축하는데 많은 시간이 걸린다. 체감상 10초..
찾아본 결과로는 대강 빠른 테스트 결과를 필요로 하는 unit 테스트에서는 인 메모리를 쓰고
통합테스트에서는 testcontainer를 쓰는 거 같았다.
개념 참고 자료
- https://medium.com/riiid-teamblog-kr/testcontainer-로-멱등성있는-integration-test-환경-구축하기-4a6287551a31
- https://umbum.dev/1127
- https://dealicious-inc.github.io/2022/01/10/test-containers.html
TestContainers 적용
적용 방법 자체는 간단하다. 하지만 여러 블로그 글과 공식 문서 등 testconatainers 적용 방법이 가지각색이라 삽질을 많이 하였다. 또한 우리 프로젝트에 맞게 고려해야 할 사항이 두 가지가 있는데
- 초기 데이터를 추가해줘야 한다.
- 여러 테스트를 동시에 실행하더라도 컨테이너는 하나로 공유되도록 한다.
이 부분에서도 많은 삽집을 하였다.
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 되지 않는다.
정적 초기화 블록을 이용해 컨테이너를 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가 들어간다.
설정은 간단하지만 몇 가지 단점이 존재한다.
- 컨테이너가 필요하지 않은 테스트에서도 적용된다.
- db명이 test로 고정된다. dbname부분은 바꾸면 된다고 공식문서에도 나오지만 실제도 바뀌지 않는다.
https://www.testcontainers.org/modules/databases/jdbc/
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 파일을 직접 실행시켜 주는 로직이다.
하지만 이 방식은 전체 테스트 코드를 실행시킬 경우 클래스 단위마다 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를 이용하게 될 거 같다.
구현 참고자료
- https://www.freecodecamp.org/news/integration-testing-using-junit-5-testcontainers-with-springboot-example/
- https://jaehoney.tistory.com/222
- https://kukim.tistory.com/149
- https://www.baeldung.com/spring-boot-testcontainers-integration-test
- https://www.baeldung.com/docker-test-containers