Testcontainers를 이용한 애플리케이션 통합 테스트 환경 구축
이 글에서는 Testcontainers를 활용하여 애플리케이션 통합 테스트 시 데이터베이스 환경을 어떻게 구성할 수 있는지에 대해 설명합니다.
Testcontainers 공식 문서를 참고하여 현재 버전에 맞게 직접 테스트해보며 작성했습니다.
서론
테스트 코드를 작성할 때, Docker를 사용해 테스트용 데이터베이스를 직접 띄우거나 H2 메모리 데이터베이스를 활용할 수 있습니다. 하지만 Docker로 테스트용 DB를 직접 실행하고 종료하는 과정은 번거로울 수 있으며, H2 메모리 DB는 설정이 간편하고 테스트 속도가 빠르지만 운영 환경과 다른 DB를 사용할 때 문제가 발생할 가능성이 있습니다. 이는 테스트에서 문제가 없더라도 실제 운영 환경에서 오류가 발생할 수 있음을 의미합니다.
이러한 문제를 해결하기 위해 Testcontainers를 사용할 수 있습니다. Testcontainers는 통합 테스트를 지원하기 위해 개발된 오픈 소스 Java 라이브러리로, Docker 컨테이너를 활용하여 외부 의존성들을 포함한 테스트 환경을 쉽게 구축하고 관리할 수 있습니다.
Testcontainers의 장점
- 자동화된 DB 설정: 테스트 실행 시 Testcontainers가 자동으로 데이터베이스를 설정해주기 때문에, 별도의 프로그램이나 스크립트를 실행할 필요가 없습니다.
- 운영 환경과 유사한 테스트: 실제 운영 환경과 유사한 데이터베이스 환경에서 테스트를 수행할 수 있어, 운영 환경에서 발생할 수 있는 잠재적인 문제를 사전에 발견할 수 있습니다.
Testcontainers의 단점
- 테스트 속도 저하: Docker 컨테이너를 실행하는 데 시간이 걸리기 때문에, 테스트 속도가 다소 느려질 수 있습니다.
Testcontainers 설정 방법
Testcontainers는 Docker를 활용해 컨테이너를 생성하는 방식이므로, Docker가 설치되어 있어야 합니다. 이번 예제에서는 Spring Boot 3.0.1과 Java 17을 사용합니다.
첫 번째 방법
1. Testcontainers 의존성 추가
먼저, JUnit을 지원하는 Testcontainers 의존성을 추가해야 합니다. 이 글에서는 현재 최신 버전인 1.20.1을 사용합니다.
Gradle (Kotlin DSL)
testImplementation("org.testcontainers:junit-jupiter:1.20.1")
2. @Testcontainers 어노테이션 추가
테스트 클래스에 @Testcontainers 어노테이션을 선언하여 Testcontainers를 활성화합니다.
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
// 테스트 코드
}
3. 데이터베이스 모듈 의존성 추가
Testcontainers에서 사용하는 데이터베이스 모듈의 의존성도 추가합니다. 여기서는 MySQL을 예시로 들었습니다.
Gradle (Kotlin DSL)
testImplementation("org.testcontainers:mysql:1.20.1")
4. MySQLContainer 추가
테스트 클래스에 MySQLContainer를 추가하여 MySQL 데이터베이스 컨테이너를 설정합니다.
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
private static final String USERNAME = "username";
private static final String PASSWORD = "password";
private static final String DATABASE_NAME = "testdb";
static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
.withUsername(USERNAME)
.withPassword(PASSWORD)
.withDatabaseName(DATABASE_NAME);
}
- MySQLContainer는 static으로 선언해야 합니다. 이렇게 하면 해당 테스트 클래스의 여러 테스트 메서드에서 동일한 컨테이너를 사용할 수 있습니다. 만약 static으로 선언하지 않으면, 각 테스트 메서드가 실행될 때마다 새로운 컨테이너가 생성되어 테스트 속도가 굉장히 느려집니다.
5. 컨테이너의 실행 및 종료
테스트 실행 전 컨테이너를 시작하고, 테스트 종료 후에는 컨테이너를 종료합니다.
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.AfterAll;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
private static final String USERNAME = "username";
private static final String PASSWORD = "password";
private static final String DATABASE_NAME = "testdb";
static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
.withUsername(USERNAME)
.withPassword(PASSWORD)
.withDatabaseName(DATABASE_NAME);
@BeforeAll
static void beforeAll() {
mySQLContainer.start();
}
@AfterAll
static void afterAll() {
mySQLContainer.stop();
}
}
위의 코드를 작성하면, 해당 테스트 클래스의 모든 테스트에서 하나의 컨테이너를 사용할 수 있습니다.
6. @Container 어노테이션 활용
@BeforeAll과 @AfterAll 메서드를 생략하고 @Container 어노테이션을 사용하면 코드의 중복을 줄일 수 있습니다.
아래의 코드는 위의 5. 컨테이너 실행 및 종료 코드와 동일한 동작입니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
private static final String USERNAME = "username";
private static final String PASSWORD = "password";
private static final String DATABASE_NAME = "testdb";
@Container
static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
.withUsername(USERNAME)
.withPassword(PASSWORD)
.withDatabaseName(DATABASE_NAME);
}
7. 컨테이너의 URL 확인
컨테이너가 어떤 URL 형태로 생성되는지 궁금하다면 getJdbcUrl() 메서드를 사용해 확인할 수 있습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
private static final String USERNAME = "username";
private static final String PASSWORD = "password";
private static final String DATABASE_NAME = "testdb";
@Container
static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
.withUsername(USERNAME)
.withPassword(PASSWORD)
.withDatabaseName(DATABASE_NAME);
@DisplayName("테스트1")
@Test
void 테스트1() {
System.out.println(mySQLContainer.getJdbcUrl());
}
}
위의 테스트를 실행하면, 컨테이너의 URL이 출력됩니다.
테스트 실행 시점마다 URL 포트가 달라지므로, 실행할 때마다 확인할 수 있습니다.
예를 들어, 첫 번째 실행 시 jdbc:mysql://localhost:64772/testdb와 같이 출력될 수 있으며, 두 번째 실행 시에는 jdbc:mysql://localhost:64945/testdb와 같이 포트 번호가 달라질 수 있습니다.
하지만 위와 같은 설정만으로는 테스트 실행 시 H2 데이터베이스의 메모리 DB와 연결되는 것을 확인할 수 있습니다.
24-08-23 12:29:44.272 [main] DEBUG [HikariConfig:1132] - driverClassName................."org.h2.Driver"
24-08-23 12:29:44.274 [main] DEBUG [HikariConfig:1132] - jdbcUrl.........................jdbc:h2:mem:ad49dbea-90cb-4ab2-b641-64589204c171;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
8. 동적 데이터베이스 설정
테스트 컨테이너로 생성한 데이터베이스를 사용하려면, 스프링에서 Datasource를 생성할 수 있도록 @DynamicPropertySource를 통해 동적으로 프로퍼티 값을 설정해야 합니다.
import org.springframework.boot.test.context.DynamicPropertyRegistry;
import org.springframework.boot.test.context.DynamicPropertySource;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
private static final String USERNAME = "username";
private static final String PASSWORD = "password";
private static final String DATABASE_NAME = "testdb";
@Container
static MySQLContainer<?> mySQLContainer = new MySQLContainer<>("mysql:8.0")
.withUsername(USERNAME)
.withPassword(PASSWORD)
.withDatabaseName(DATABASE_NAME);
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {
dynamicPropertyRegistry.add("spring.datasource.url", () -> mySQLContainer.getJdbcUrl());
dynamicPropertyRegistry.add("spring.datasource.username", () -> USERNAME);
dynamicPropertyRegistry.add("spring.datasource.password", () -> PASSWORD);
dynamicPropertyRegistry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
}
@DisplayName("테스트1")
@Test
void 테스트1() {
// 테스트 로직
}
}
위와 같이 설정한 후 테스트를 다시 실행하면, 테스트 컨테이너와 올바르게 연결되었는지 확인할 수 있습니다.
24-08-23 12:42:44.442 [main] DEBUG [HikariConfig:1132] - driverClassName................."com.mysql.cj.jdbc.Driver"
24-08-23 12:42:44.445 [main] DEBUG [HikariConfig:1132] - jdbcUrl.........................jdbc:mysql://localhost:30035/testdb
위와 같이 MySQL 데이터베이스로 올바르게 연결된 것을 확인할 수 있습니다.
9. GenericContainer 사용 예시
Testcontainers에서 지원하지 않는 데이터베이스를 사용해야 하는 경우, GenericContainer를 사용하여 컨테이너를 생성할 수 있습니다. 이 경우 데이터베이스에 특화된 withDatabaseName() 메서드 같은 것은 사용할 수 없지만, withEnv() 메서드를 사용해 환경 변수를 설정할 수 있습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
class SupportLinkServiceTest {
@Container
private static GenericContainer mySQLContainer = new GenericContainer("mysql")
.withEnv("MYSQL_DB", "testdb");
}
두 번째 방법
1. application-test.yml 설정
위의 복잡한 설정은 모두 필요 없이, application-test.yml 파일에 아래와 같이 작성하면 Testcontainers DB가 위의 과정을 모두 포함한 채로 바로 컨테이너와 연결되어 사용할 수 있습니다.
아래의 yml 파일만 작성하면 static으로 인스턴스를 등록하고 @DynamicPropertySource를 설정한 첫 번째 방법과 동일하게 동작합니다.
spring:
config:
activate:
on-profile: test
datasource:
url: jdbc:tc:mysql:8.0.31:///testdb
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
hibernate:
ddl-auto: create
- jdbc:tc:mysql:8.0.31:///testdb에서 tc가 추가된 부분이 가장 중요합니다.
- tc는 Testcontainers를 통해 MySQL 8.0.31 버전을 사용하는 데이터베이스를 설정한다는 것을 의미합니다.
- driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver는 Spring Boot 2.3 버전 이후부터 생략 가능합니다.
spring:
config:
activate:
on-profile: test
datasource:
url: jdbc:tc:mysql:8.0.31:///testdb
# driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
hibernate:
ddl-auto: create
2. 클래스를 통해 직접 설정
application-test.yml을 작성하지 않고, 클래스에서 바로 설정할 수도 있습니다.
아래는 Testcontainers의 공식 GitHub 예제를 참고한 예시입니다.
@SpringBootTest(
webEnvironment = WebEnvironment.RANDOM_PORT,
properties = {
"spring.datasource.url=jdbc:tc:mysql:8.0.31:///testdb",
"spring.jpa.hibernate.ddl-auto=create"
}
)
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:6-alpine"))
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
redis.start();
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", redis::getFirstMappedPort);
}
}
위와 같이 yml을 생략하고 @SpringBootTest 어노테이션에 properties 옵션 값으로 직접 작성할 수 있습니다.
그리고 위의 예시처럼 다른 컨테이너(Redis)는 첫 번째 방법과 같이 필요한 다른 컨테이너를 함께 구성할 수 있습니다.
이러한 방식으로 구성 클래스를 abstract 클래스로 만들어 두면, 통합 테스트가 필요한 테스트 클래스는 이 추상 클래스를 상속하기만 하면 됩니다.
3. 구성 추상 클래스 사용 코드
이제 실제 데이터베이스가 필요한 통합 테스트를 작성할 때는 추상 클래스를 상속하여 사용하면, 개발자는 테스트 코드 작성에만 집중할 수 있습니다.
class SupportLinkServiceTest extends AbstractIntegrationTest {
@Test
void 테스트1(){
// 테스트 로직 작성
}
}
세 번째 방법
1. docker-compose.yml 작성
docker-compose.yml을 아래와 같이 작성합니다. 예제 테스트에서는 아래와 같이 작성했습니다.
# src/test/resources
version: "3"
services:
testdb:
image: mysql:8.0.31
ports:
- 3306
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testdb
2. DockerComposeContainer 추가
DockerComposeContainer를 활용해 컨테이너들을 한 번에 Testcontainers로 관리할 수 있습니다. 앞선 예제 코드에서 @Testcontainers를 사용한 코드와 구성 방식은 동일합니다.
@SpringBootTest(
classes = SerafinApplication.class,
webEnvironment = WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("test")
@Testcontainers
public abstract class AbstractIntegrationTest {
public static final String MYSQL_CONTAINER_NAME = "testdb";
public static final String MYSQL_DB_NAME = "testdb";
public static final int MYSQL_PORT = 3306;
@Container
static DockerComposeContainer composeContainer =
new DockerComposeContainer(new File("src/test/resources/docker-compose.yml"))
.withExposedService(
MYSQL_CONTAINER_NAME,
MYSQL_PORT,
Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30))
);
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry dynamicPropertyRegistry) {
final String host = composeContainer.getServiceHost(MYSQL_CONTAINER_NAME, MYSQL_PORT);
final Integer port = composeContainer.getServicePort(MYSQL_CONTAINER_NAME, MYSQL_PORT);
dynamicPropertyRegistry.add("spring.datasource.url",
() -> "jdbc:mysql://%s:%d/%s".formatted(host, port, MYSQL_DB_NAME));
dynamicPropertyRegistry.add("spring.datasource.username", () -> "root");
dynamicPropertyRegistry.add("spring.datasource.password", () -> "password");
dynamicPropertyRegistry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
}
}
위의 코드에서 docker-compose.yml의 경로를 파일 객체로 넣어주고, withExposedService() 메서드의 매개변수로 compose 파일에 작성한 서비스 이름, 포트, 그리고 사용 가능한 상태가 될 때까지 대기할 수 있도록 설정합니다.
Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(30))는 컨테이너가 실행되기까지 시간이 소요되는데, 사용 가능한 상태가 될 때까지 기다려주는 코드입니다.
그런 다음, 만든 DockerComposeContainer 인스턴스를 스프링이 사용할 수 있도록 동적으로 설정해주면 됩니다.
마무리하며
이 글에서는 Testcontainers를 사용하여 애플리케이션 테스트 환경을 설정하는 여러 가지 방법을 살펴보았습니다. Testcontainers는 Docker 컨테이너를 이용해 테스트 환경을 자동으로 설정해 주기 때문에, 테스트를 운영 환경과 유사한 환경에서 수행할 수 있으며 간단한 코드 작성만으로 구성할 수 있습니다.
첫 번째 방법은 직접 MySQLContainer를 설정하고 @DynamicPropertySource를 사용해 데이터베이스 연결을 동적으로 설정하는 방법이었습니다.
두 번째 방법은 application-test.yml을 통해 더 간단하게 설정하는 방법을 소개했습니다.
마지막으로, 세 번째 방법은 docker-compose.yml을 사용해 여러 컨테이너를 한 번에 관리할 수 있는 방법이었습니다.
이러한 다양한 방법들을 통해 Testcontainers를 효과적으로 활용할 수 있으며, 상황에 따라 적절한 방법을 선택하여 사용하시면 됩니다.
Testcontainers는 특히 운영 환경과 동일한 조건에서 테스트를 수행해야 할 때 유용하며 테스트 코드의 멱등성을 유지하고 이를 통해 테스트의 신뢰성과 정확성을 높일 수 있습니다.