
Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
테스트를 위해 도커 컨테이너를 띄워주는 자바 라이브러리이다.
컨테이너를 자동으로 띄우고, 종료한다. 즉 기존 사용 중인 서비스의 연결 정보를 가로채서 안전하게 테스트를 할 수 있도록 도와주는 프록시 역할을 한다.
테스트는 멱등성 이 가장 중요하다. 각 테스트마다 같은 환경에서 수행되어야하고 서로 간섭이 있어서는 안된다.
멱등성을 만족시킬 수 있는 여러가지 방법이 있다.
1) 사용 중인 서비스를 복제해 분산 환경에서 테스트
2) 인메모리 데이터베이스 사용
3) 배포 환경과 동일한 docker-compose 를 사용해 환경 구성
4) 테스트 컨테이너 사용
1번의 경우, 비용 문제와 휴먼 에러가 발생할 수 있어 제외했다.
2번의 경우, 인메모리를 사용해 엘라스틱 서치 엔진을 구동시키는 것에 무리가 있었다. 그리고 인메모리 데이터베이스는 h2 DB 를 사용하게 되는데, MySQL에 Specific한 테스트가 불가능하다는 단점이 있었다.
3번의 경우, 임의로 포트를 yml 파일에 명시해야하고 서버 구성원 중 누군가 같은 포트를 사용할 여지가 있기 때문에 에러가 발생할 여지가 있었다. 그리고 컨테이너를 직접 관리해야하는 단점이 있었다.
4번의 경우, 테스트 실행 시 컨테이너가 자동으로 시작되고 완료되면 종료된다. 그리고 구성 정보를 코드 레벨에서 설정할 수 있고 컨테이너 재사용 설정도 할 수 있다. 최종적으로 테스트 컨테이너를 택했다.
dependencies {
... 다른 의존성
// Test Containers
+ testImplementation 'org.testcontainers:junit-jupiter'
+ testImplementation 'org.testcontainers:elasticsearch'
+ testImplementation 'org.testcontainers:mysql'
}
간단하게 의존성을 추가하면 된다.
@TestConfiguration // 테스트 구성 정보
@EnableElasticsearchRepositories(basePackages = "org.sopt.confeti.domain.elastic_search.infra") // 실제 운영에 사용되는 엘라스틱 서치 레포지토리 (document 정보)를 사용한다고 명시한다.
public class ElasticsearchTestConfiguration extends ElasticsearchConfiguration {
@Bean
@Primary
@Override
public ClientConfiguration clientConfiguration() {
// 컨테이너가 실행 중인지 확인
if (!APIBaseTest.elasticsearchContainer.isRunning()) {
throw new IllegalStateException("Elasticsearch container is not running!");
}
// BaseControllerTest의 elasticsearchContainer 참조
// 테스트 컨테이너 호스트 정보를 가져와서 연결한다.
String httpHostAddress = APIBaseTest.elasticsearchContainer.getHttpHostAddress();
return ClientConfiguration.builder()
.connectedTo(httpHostAddress)
.withConnectTimeout(30000)
.withSocketTimeout(60000)
.build();
}
}
먼저 테스트에 사용될 엘라스틱 서치 컨테이너 연결의 구성 정보를 추가한다.
그리고, 운영에서 사용했던 Index 매핑 설정 파일과 동일한 파일이 test/resources/elasticsearch 폴더 내에 있어야한다.
ex) performance-settings.json
ex) search-term-settings.json
public final class SharedTestContainers {
private static final String ELASTICSEARCH_IMAGE = "docker.elastic.co/elasticsearch/elasticsearch:9.0.1";
private static final int STARTUP_TIMEOUT = 120;
public static final ElasticsearchContainer ELASTICSEARCH_CONTAINER =
new ElasticsearchContainer(ELASTICSEARCH_IMAGE)
.withEnv("discovery.type", "single-node") // 마스터 노드 설정
.withEnv("xpack.security.enabled", "false") // SSL 비활성화
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
.withCommand("sh", "-c",
"elasticsearch-plugin install analysis-nori --batch && " +
"/usr/local/bin/docker-entrypoint.sh eswrapper") // 한글 형태소 분석을 사용하므로 플러그인 설치
.withReuse(true) // 컨테이너 재사용
.withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT));
static {
ELASTICSEARCH_CONTAINER.start(); // 컨테이너 실행
}
}
테스트 컨테이너를 응집성있게 관리하기 위해 따로 클래스를 작성했다.
@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
// elastic search
registry.add("spring.data.elasticsearch.uris", elasticsearchContainer::getHttpHostAddress);
registry.add("spring.elasticsearch.uris", elasticsearchContainer::getHttpHostAddress);
}
elasticsearchContainer::getHttpHostAddress 에서는 도커 컨테이너 이름과 랜덤으로 지정된 포트를 결합해 로컬에서 접근 가능한 URI가 반환된다.
@BeforeEachvoid setUp() {
// insert search term test data to es
createSearchTermTestIndex();
insertSearchTermTestDataES();
// insert performance test data to es
createPerformanceTestIndex();
insertPerformanceTestDataES();
}
기본적으로 Spring Rest Docs는 블랙박스 API 테스트를 하기 때문에 어떤 데이터가 어떻게 변했는지 알 수 없다. 그래서 @Rollback 기능도 동작하지 않는 것이다. (사실 애초에 API 테스트 대상 서버가 따로 띄워진다.)
위와 같이 인덱스를 삭제하고 재생성한 뒤 데이터를 매번 생성하는 것은 테스트의 특징 때문이다.
# src/test/resources/application-test.ymlspring:
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
hbm2ddl:
auto: create-drop
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
database-platform: org.hibernate.dialect.MySQLDialect
show-sql: true
datasource:
hikari:
maximum-pool-size: 5
connection-timeout: 20000
# 테스트 전용 로깅 설정
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
org.springframework.test: DEBUG
application-test.yml 파일에 데이터베이스 연결 정보를 작성했다.
public final class SharedTestContainers {
private static final String MYSQL_IMAGE = "mysql:8.0";
private static final int STARTUP_TIMEOUT = 120;
public static final MySQLContainer<?> MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE)
.withDatabaseName("confeti_test") // 임의로 설정
.withUsername("test") // 임의로 설정
.withPassword("test123") // 임의로 설정
.withEnv("MYSQL_ROOT_PASSWORD", "root_password") // 임의로 설정
.withUrlParam("tc", "proxy")
.withCommand("--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci",
"--sql-mode=STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION")
.withReuse(true) // 컨테이너 재사용
.withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT));
static {
MYSQL_CONTAINER.start();
}
}
@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
// mysql
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", () -> "com.mysql.cj.jdbc.Driver");
}
@BeforeEachvoid setUp() {
// insert concert test data to mysql
insertConcertTestData();
// insert festival test data to mysql
insertFestivalTestData();
// insert performance test data to mysql
insertPerformanceTestData();
// insert user test data to mysql
insertUserTestData();
// generate Access Token
generateAccessToken();
}
@AfterEachvoid resetDatabase() {
try {
initializeAllTableData();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* mysql의 모든 데이터를 삭제하는 함수
*/
private void initializeAllTableData() {
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
// 외래키 제약 해제
statement.execute("SET FOREIGN_KEY_CHECKS = 0");
// 모든 테이블 조회
List<String> tableNames = getAllTableNames(connection);
// 모든 테이블 데이터 삭제
for (String tableName : tableNames) {
try {
statement.execute("DELETE FROM " + tableName);
System.out.println("테이블 데이터 삭제: " + tableName);
} catch (Exception e) {
log.warn("테이블 초기화 스킵 : {}", tableName);
}
}
// AUTO_INCREMENT 값 초기화
for (String tableName : tableNames) {
try {
statement.execute("ALTER TABLE " + tableName + " AUTO_INCREMENT = 1");
} catch (Exception e) {
log.warn("AUTO_INCREMENT 초기화 스킵 : {}", tableName);
}
}
// 4. 외래키 제약 복원
statement.execute("SET FOREIGN_KEY_CHECKS = 1");
log.info("=== 모든 테이블 데이터 초기화 완료 ===");
}
} catch (Exception e) {
log.error("테이블 데이터 초기화 실패");
}
}
// 모든 테이블 이름 정보를 가져오는 메서드
private List<String> getAllTableNames(Connection connection) throws Exception {
List<String> tableNames = new ArrayList<>();
ResultSet tables = connection.getMetaData().getTables(
connection.getCatalog(), null, "%", new String[]{"TABLE"}
);
while (tables.next()) {
String tableName = tables.getString("TABLE_NAME");
tableNames.add(tableName);
}
return tableNames;
}
어떤 데이터가 바뀌었는지 모르기 때문에 데이터를 전부 제거 후 다시 생성한다.
public final class SharedTestContainers {
private static final String REDIS_IMAGE = "redis:latest";
public static final int REDIS_PORT = 6379;
private static final int STARTUP_TIMEOUT = 120;
public static final GenericContainer<?> REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE)
.withExposedPorts(REDIS_PORT)
.waitingFor(Wait.forListeningPort())
.withStartupTimeout(Duration.ofSeconds(STARTUP_TIMEOUT))
.withReuse(true);
static {
REDIS_CONTAINER.start();
}
@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
// redis
registry.add("spring.data.redis.host", redisContainer::getHost);
registry.add("spring.data.redis.port",
() -> redisContainer.getMappedPort(SharedTestContainers.REDIS_PORT).toString());
}
@BeforeEach
void setUp() {
// clear redis
clearRedisData();
}
/**
* 레디스의 모든 데이터를 삭제하는 함수
*/
private void clearRedisData() {
try {
redisTemplate.getConnectionFactory().getConnection().flushAll();
log.info("=== Redis 모든 데이터 초기화 완료 ===");
} catch (Exception e) {
log.error("Redis 데이터 초기화 실패: {}", e.getMessage());
}
}
프로젝트 내부에 .testcontainers.properties 파일을 생성한다.
그리고 testcontainers.reuse.enable=true 를 추가한다.
여기까지 테스트 컨테이너를 사용해 테스트를 하고 컨테이너를 각 테스트마다 재사용하는 방법을 알아봤다. 테스트를 실행하면 컨테이너가 생겼다가 사라지는 것을 볼 수 있을 것이다.
엘라스틱 서치 테스트 컨테이너에 관한 레퍼런스가 아에 없는 수준이었는데, 누군가에게 이 글이 도움이 되었으면 좋겠다.
자세한 코드는 아래의 레포지토리를 확인하면 된다.
https://github.com/team-confeti/confeti-server/tree/develop/src/test/java/org/sopt/confeti/restdocs/base