💡 이 글에서 사용된 코드: 깃헙
Testcontainers를 사용하면, Docker 컨테이너로 원하는 DB 또는 서비스가 동작하게 된다. 이 때, 컨테이너에 맵핑되는 호스트의 포트를 포함해서 다양한 설정 값이 임의로 세팅된다는 특징을 갖고 있다.
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test
username: root
password: 1234
즉, 컨테이너로 올라간 서비스들에 연결하기 위한 설정 값들을 보통의 방식처럼 설정파일을 통해서 정적으로 설정할 수 없게 되는 것이다.
그렇다믄... 어케 할까?? 🤔
그 방법을 바~로 알아보자!
MySQL, Redis, RabbitMQ를 사용해야하는 상황이라고 가정해보도록 하겠다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// DB
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'
// Testcontainers
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql:1.20.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
이 글에서 사용한 프로젝트의 전체 패키지인데, 여기서 Testcontainers라고 되어 있는 부분이 Testcontainers와 Testcontainers에서 MySQL을 사용하기 위한 필수 패키지들이다.
dependencies {
...
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql:1.20.0'
...
}
spring.profiles.active=test
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=create-drop
spring.data.redis.host=localhost
spring.rabbitmq.host=localhost
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
test/resources 하위에 위와 같이 설정 파일을 만들어준다. (물론, yml 파일로 만들어도 된다)
설정 파일을 살펴보면 이상한 점이 있다. DB에 접속하려면 DB의 Url이 정의되어 있어야하고, Redis나 RabbitMQ와 연결하려면 각 서비스가 구동중인 포트가 정의 되어 있어야하는데, 위 설정 파일에는 존재하지 않는다.
왜냐하면, 이런 설정 값들이 Testcontainers에 의해서 동적으로 변할 부분들이기 때문에, 설정 파일에 정적으로 작성해두는 것이 의미가 없기 때문이다.
자, 그럼 이제 동적으로 설정 값을 세팅해보도록 하자.
💡 MySQL과 같은 DB의 경우 Datasource에 Testcontainers용 드라이버와 함께 설정 값을 작성해도 된다. (지난 글 참고)
검색해보면 다양한 글들에서 하나의 테스트 코드를 기준으로 설정하는 방법들을 소개해주는데, 사실 실무에서는 수백개의 클래스에 대해서 테스트 코드를 작성해야하므로, 테스트 클래스 각각에 번거롭게 설정을 해주는 것은... 다메... ☠️
그래서 Testcontainers에 대한 설정 및 제어 코드를 별도의 클래스에 작성하고, 이 클래스를 여러 테스트 클래스에서 가져다가 사용하도록 구성을 하겠다. 그 수단으로 Testcontainers용 Extension을 구현할 것이다.
먼저, 동적으로 설정 값을 세팅하는 방법은 다양하게 있는데, 검색해보면 아래 2개를 주로 소개를 해준다.
@DynamicPropertySource의 경우 테스트 클래스마다 동적으로 설정값을 할당해주는 로직이 들어가야해서 사용 못하겠다고 판단했고, @ServiceConnection은 사용에 실패했다 😇
뭐 어찌됐든 가장 직관적인 방법을 찾게 되어서 그 방법으로 동적으로 설정 값을 세팅할 것이다.
System.setProperty()
그 방법은 바로 이 메서드를 사용해서 무식하지만 직관적으로 설정 값을 세팅하는 것이다.
그럼 본격적으로 어떻게 설정하는지 알아보자.
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.utility.DockerImageName;
public class MysqlContainerExtension implements Extension, BeforeAllCallback, AfterAllCallback {
@Container
static MySQLContainer mysql = new MySQLContainer(DockerImageName.parse("mysql:8.4.1"));
@Override
public void afterAll(ExtensionContext extensionContext) {
mysql.start();
System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
System.setProperty("spring.datasource.username", mysql.getUsername());
System.setProperty("spring.datasource.password", mysql.getPassword());
}
@Override
public void beforeAll(ExtensionContext extensionContext) {
mysql.stop();
}
}
Extension, BeforeAllCallback, AfterAllCallback을 구현한 Extesion 구현체를 이렇게 만든다.
여기서 주목할 것 중에 하나는 afterAll에서 컨테이너를 실행해주고, beforeAll에서 컨테이너를 종료해주는 것인데, @Testcontainers 어노테이션을 사용하면 알아서 해주지만, 테스트 클래스마다 붙여주기 귀찮으므로 직접 구현해주었다.
System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
System.setProperty("spring.datasource.username", mysql.getUsername());
System.setProperty("spring.datasource.password", mysql.getPassword());
또, 주목할 것은 afterAll에 있는 대망의 동적 프로퍼티 할당 코드이다 ㅎㅎ
Testcontainers에서 어떤 포트를 사용할지 모르므로, Jdbc url을 컨테이너가 세팅한 값을 가져와서 동적으로 할당하는 것이다.
Username과 Password도 뭐로 세팅되는지 알 수 없어서 컨테이너에서 가져오도록 하였다. (디버그 툴로 확인해보니 둘 다 test였긴 했다.)
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.utility.DockerImageName;
public class RedisContainerExtension implements Extension, BeforeAllCallback, AfterAllCallback {
@Container
static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:7.2.5"))
.withExposedPorts(6379);
@Override
public void afterAll(ExtensionContext extensionContext) {
redis.start();
System.setProperty("spring.data.redis.port", String.valueOf(redis.getFirstMappedPort()));
}
@Override
public void beforeAll(ExtensionContext extensionContext) {
redis.stop();
}
}
이건 Redis를 컨테이너로 띄우기 위한 클래스이고, MySQL과 달리 Testcontainers용 Redis 모듈을 설치하진 않아서 GenericContainer로 컨테이너 클래스를 만들어주었다.
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.utility.DockerImageName;
public class RabbitContainerExtension implements Extension, BeforeAllCallback, AfterAllCallback {
@Container
static GenericContainer rabbitmq = new GenericContainer(DockerImageName.parse("rabbitmq:3.13.6-management"))
.withExposedPorts(5672);
@Override
public void afterAll(ExtensionContext extensionContext) {
rabbitmq.start();
System.setProperty("spring.rabbitmq.port", String.valueOf(rabbitmq.getFirstMappedPort()));
}
@Override
public void beforeAll(ExtensionContext extensionContext) {
rabbitmq.stop();
}
}
이것은 RabbitMQ를 컨테이너로 띄우기 위한 클래스이다.
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import study.extensions.MysqlContainerExtension;
import study.extensions.RabbitContainerExtension;
import study.extensions.RedisContainerExtension;
@SpringBootTest
@ExtendWith({MysqlContainerExtension.class, RedisContainerExtension.class, RabbitContainerExtension.class})
class PostRepositoryTest {
@Autowired
private PostRepository postRepository;
@Test
void 생성과_조회() throws Exception {
// given
Post post = postRepository.save(Post.create("제목", "내용"));
Long savedId = post.getId();
// when
Optional<Post> optionalPost = postRepository.findById(savedId);
// then
Post foundPost = optionalPost.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
assertThat(foundPost.getTitle()).isEqualTo("제목");
assertThat(foundPost.getContent()).isEqualTo("내용");
}
}
지난 글에서 사용했던 테스트 코드인데, ExtendWith 부분만 바꿔서 우려먹도록 하겠다 ㅎㅎ
ExtendWith에 이전에 작성한 컨테이너 관련 클래스들을 입력해주면 된다. 3개 각각 입력하기 귀찮으면 하나의 Extension클래스로 묶어줘도 된다.

테스트는 별 로직이 아니니 물론 잘 통과했고,

컨테이너들도 테스트가 시작할 때 뜨고 테스트가 종료되니가 삭제가 잘 되었다.
위와 같이 beforeAll에서 켜주고, afterAll에서 컨테이너가 내려가도록 설정하면, 테스트 클래스 마다 컨테이너를 올렸다가 내렸다가 하게 되어서 전체 테스트 입장에서는 속도가 매우 느려진다.
그래서 굳이 테스트 클래스마다 컨테이너를 다시 생성해줄 필요가 없다면, afterAll에서 컨테이너를 중지하는 코드를 제거해주면 된다. 그러면, 전체 테스트가 다 끝났을 때 컨테이너들이 중지될 것이고, 당연하게도 테스트 속도도 빨라진다.
모두 행복한 통합 테스트가 되길 바라며 마치도록 하겠다. 🤓