TestContainers 공식 문서나 많은 블로그 글을 찾아봐도, TestContainers로 데이터베이스 Replication을 구성한 예제는 정말 찾기 어려워서 삽질을 꽤 했다,,,
그래서
을 마치고, 테스트 환경에서도 동일한 환경에서 테스트하길 바라는 사람들과 미래의 나에게 조금이라도 도움이 되고자
내가 작성한 코드를 공유하고자 한다!
아래 과정을 차근히 따라오면 개발 환경과 동일한 테스트 환경을 구축할 수 있다.
미리 말하자면, 만약 데이터베이스를 복제하지 않고 사용하고자 한다면, 해당 포스팅을 읽을 필요가 없다.
이 경우 TestContainers에서 편리한 테스트 환경 구축을 위한 모듈을 제공하고 있기 때문에,
TestContainer 공식 문서 의 예제를 참고하여 아주 간단하고 쉽게 테스트 환경을 구축할 수 있다.
여기서는, docker-compose 파일을 직접 작성하고, TestContainers를 통해 컨테이너를 띄워서 테스트 환경을 구축하는 과정을 소개한다.
그럼 이제부터 docker-compose 파일을 어떻게 작성했는지 같이 보자.
경로 : src/test/resources/docker-compose-test.yml
version: "3.8"
services:
# 쓰기 전용 데이터베이스
test-main:
image: 'bitnami/postgresql:latest'
restart: on-failure
volumes:
- ./replication-user-grant-test.sql:/docker-entrypoint-initdb. d/db.sql
environment:
- POSTGRESQL_REPLICATION_MODE=master # 복제 모드 [master / slave]
- POSTGRESQL_REPLICATION_USER=repl_user # 복제 사용자 이름
- POSTGRESQL_REPLICATION_PASSWORD=repl_password # 복제 사용자 비밀번호
- POSTGRESQL_USERNAME=test
- POSTGRESQL_PASSWORD=test
- POSTGRESQL_DATABASE=test_db
# 읽기 전용 데이터베이스
test-standby:
image: 'bitnami/postgresql:latest'
restart: on-failure
depends_on:
- test-main
environment:
- POSTGRESQL_REPLICATION_MODE=slave # 복제 모드 [master / slave]
- POSTGRESQL_REPLICATION_USER=repl_user # 복제 사용자 이름
- POSTGRESQL_REPLICATION_PASSWORD=repl_password # 복제 사용자 비밀번호
- POSTGRESQL_MASTER_HOST=test-main
- POSTGRESQL_MASTER_PORT_NUMBER=5432
- POSTGRESQL_USERNAME=test
- POSTGRESQL_PASSWORD=test
replication-user-grant-test.sql
파일은 복제 사용자에게 replication을 위한 권한을 부여하는 스크립트 파일이다.
경로 : src/test/resources/replication-user-grant-test.sql
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO repl_user;
ALTER USER REPL_USER WITH SUPERUSER;
통합 테스트 환경에서는 test 프로파일로 스프링부트 서버를 띄울 것이다.
application-test.yml 파일을 작성하고 @ActiveProfiles("test")
로 프로파일을 지정해주면 application-test.yml
에 작성된 설정을 기반으로 어플리케이션이 실행된다.
경로: src/test/resources/application-test.yml
데이터 소스에 연결하기 위해서는 spring.datasource.main.hikari.jdbc-url
와 spring.datasource.standby.hikari.jdbc-url
속성이 필요하지만, 컨테이너가 시작될 때 노출된 포트(5432)에 대한 Docker 매핑 포트가 동적으로 변경되기 때문에 어플리케이션 컨텍스트가 시작될떄 속성이 동적으로 등록되도록 구현하였다.
driver-class-name이나 username / password는 변경되는 값이 아니므로, application-test.yml에 명시하였다.
spring:
config:
activate:
on-profile: test
datasource:
main:
hikari:
driver-class-name: org.postgresql.Driver
username: test
password: test
standby:
hikari:
driver-class-name: org.postgresql.Driver
username: test
password: test
TestContainers 설정을 위한 값들을 코드 상에 직접 문자열 형태로 드러내는 것은 가독성과 유지보수성을 저하시킨다고 생각했다.
그래서 테스트 환경에 외부 서비스를 구축하는데 필요한 정보들을 관리하고자 다음과 같이 ModuleInformation Enum을 작성했다.
경로 : src/test/java/[그룹명]/[아티팩트명]/domain/ModuleInformation
package com.ourhours.server.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum ModuleInformation {
POSTGRES_MAIN_SERVICE("test-main"),
POSTGRES_STANDBY_SERVICE("test-standby"),
POSTGRES_MAIN_LOG(".*database system is ready to accept connection.*"),
POSTGRES_STANDBY_LOG(".*database system is ready to accept read-only connections.*"),
POSTGRES_JDBC_URL_SUFFIX("/test_db"),
DETERMINE_CURRENT_LOOKUP_KEY("determineCurrentLookupKey");
private final String value;
}
TestContainers로 멱등성을 보장하기 위해서는 테스트 메소드 실행 전후로 컨테이너가 시작되고 종료되어야 한다.
이 방법은 개발자가 코드를 통해 테스트했던 데이터를 삭제하지 않아도 된다는 점이 장점이지만, 그만큼 테스트 시간이 더 오래 걸린다.
뿐만 아니라, TestContainers에서 제공하는 Restarted containers 방식을 사용하기 위해, 테스트 클래스마다 private final로 컨테이너를 정의해야 하는데 이는 테스트 코드의 가독성을 저하시킬 것이라고 생각된다. (링크 참고)
이러한 이유로 지금은 IntegrationTestSupporter 클래스를 추상 클래스를 정의하여 이 클래스를 상속 받은 모든 테스트 클래스들이 실행되기 전에 컨테이너가 한번 새로 시작되고, 테스트가 종료될 때 컨테이너가 종료되도록 구현하였지만, 후에 리팩토링 될 수 있다.
package com.ourhours.server;
import static com.ourhours.server.domain.ModuleInformation.*;
import static org.testcontainers.containers.PostgreSQLContainer.*;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.testcontainers.containers.DockerComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
@SpringBootTest
@ActiveProfiles("test")
@ContextConfiguration(initializers = IntegrationTestSupporter.ContainerPropertyInitializer.class)
abstract class IntegrationTestSupporter {
@Autowired
protected Environment environment;
@Container
static final DockerComposeContainer<?> postgresContainer;
static String POSTGRES_MAIN_SERVICE_NAME = POSTGRES_MAIN_SERVICE.getValue();
static String POSTGRES_STANDBY_SERVICE_NAME = POSTGRES_STANDBY_SERVICE.getValue();
static String POSTGRES_MAIN_LOG_REGEX = POSTGRES_MAIN_LOG.getValue();
static String POSTGRES_STANDBY_LOG_REGEX = POSTGRES_STANDBY_LOG.getValue();
static Integer POSTGRES_MAIN_PORT;
static Integer POSTGRES_STAND_BY_PORT;
static String POSTGRES_MAIN_HOST;
static String POSTGRES_STAND_BY_HOST;
static String POSTGRES_MAIN_PREFIX;
static String POSTGRES_STAND_BY_PREFIX;
static String POSTGRES_SUFFIX = POSTGRES_JDBC_URL_SUFFIX.getValue();
static {
postgresContainer = new DockerComposeContainer(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService(POSTGRES_MAIN_SERVICE_NAME, POSTGRESQL_PORT,
Wait.forLogMessage(POSTGRES_MAIN_LOG_REGEX, 1))
.withExposedService(POSTGRES_STANDBY_SERVICE_NAME, POSTGRESQL_PORT,
Wait.forLogMessage(POSTGRES_STANDBY_LOG_REGEX, 1));
postgresContainer.start();
POSTGRES_MAIN_PORT = postgresContainer.getServicePort(POSTGRES_MAIN_SERVICE_NAME, POSTGRESQL_PORT);
POSTGRES_STAND_BY_PORT = postgresContainer.getServicePort(POSTGRES_STANDBY_SERVICE_NAME, POSTGRESQL_PORT);
POSTGRES_MAIN_HOST = postgresContainer.getServiceHost(POSTGRES_MAIN_SERVICE_NAME, POSTGRES_MAIN_PORT);
POSTGRES_STAND_BY_HOST = postgresContainer.getServiceHost(POSTGRES_STANDBY_SERVICE_NAME,
POSTGRES_STAND_BY_PORT);
POSTGRES_MAIN_PREFIX = "jdbc:postgresql://" + POSTGRES_MAIN_HOST + ":";
POSTGRES_STAND_BY_PREFIX = "jdbc:postgresql://" + POSTGRES_STAND_BY_HOST + ":";
}
static class ContainerPropertyInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext context) {
Map<String, String> properties = new HashMap<>();
properties.put("spring.datasource.main.hikari.jdbc-url",
POSTGRES_MAIN_PREFIX + POSTGRES_MAIN_PORT + POSTGRES_SUFFIX);
properties.put("spring.datasource.standby.hikari.jdbc-url",
POSTGRES_STAND_BY_PREFIX + POSTGRES_STAND_BY_PORT + POSTGRES_SUFFIX);
TestPropertyValues.of(properties)
.applyTo(context);
}
}
}
getServiceHost(serviceName, servicePort)
: 컨테이너가 수신 대기 중인 IP 주소를 반환한다.getServicePort(serviceName, servicePort)
: Docker 매핑 포트를 반환한다.이렇게 알아낸 호스트와 포트 정보를 바탕으로 jdbc-url을 정의하여 스프링부트 어플리케이션 컨텍스트가 시작될 때 동적으로 속성을 추가하여 데이터베이스(서비스)에 연결할 수 있다.
Wait.forLogMessage(POSTGRES_MAIN_LOG_REGEX, 1)
이렇게, 테스트 환경에 PostgreSQL Replication 구성이 완료되었다.
이제 테스트 코드를 작성해보자.
package com.ourhours.server;
import static com.ourhours.server.global.config.database.postgresql.DataSourceConfiguration.*;
import static org.junit.jupiter.api.Assertions.*;
import javax.sql.DataSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Qualifier;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
@Slf4j
class DataSourceConfigurationTest extends IntegrationTestSupporter {
@DisplayName("MainDataSource 설정 테스트")
@Test
void mainDataSourceTest(
@Qualifier(MAIN_DATA_SOURCE) final DataSource mainDataSource) {
// Given
String driverClassName = environment.getProperty("spring.datasource.main.hikari.driver-class-name");
String jdbcUrl = environment.getProperty("spring.datasource.main.hikari.jdbc-url");
String username = environment.getProperty("spring.datasource.main.hikari.username");
// When
try (HikariDataSource hikariDataSource = (HikariDataSource)mainDataSource) {
// Then
log.info("hikari DataSource : [{}]", hikariDataSource);
assertEquals(hikariDataSource.getDriverClassName(), driverClassName);
assertEquals(hikariDataSource.getJdbcUrl(), jdbcUrl);
assertEquals(hikariDataSource.getUsername(), username);
}
}
@DisplayName("standbyDataSource 설정 테스트")
@Test
void standbyDataSourceTest(
@Qualifier(STANDBY_DATA_SOURCE) final DataSource standbyDataSource) {
// Given
String driverClassName = environment.getProperty("spring.datasource.standby.hikari.driver-class-name");
String jdbcUrl = environment.getProperty("spring.datasource.standby.hikari.jdbc-url");
String username = environment.getProperty("spring.datasource.standby.hikari.username");
// When
try (HikariDataSource hikariDataSource = (HikariDataSource)standbyDataSource) {
// Then
log.info("hikari DataSource : [{}]", hikariDataSource);
assertEquals(hikariDataSource.getDriverClassName(), driverClassName);
assertEquals(hikariDataSource.getJdbcUrl(), jdbcUrl);
assertEquals(hikariDataSource.getUsername(), username);
}
}
}
package com.ourhours.server;
import static com.ourhours.server.domain.ModuleInformation.*;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Method;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.transaction.annotation.Transactional;
import com.ourhours.server.global.config.database.postgresql.DataSourceType;
import com.ourhours.server.global.config.database.postgresql.RoutingDataSource;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Execution(ExecutionMode.CONCURRENT)
class RoutingDataSourceConfigurationTest extends IntegrationTestSupporter {
@Transactional
@DisplayName("MainDataSource Replication 설정 테스트")
@Test
void testMainDataSourceReplication() throws Exception {
// Given
RoutingDataSource routingDataSource = new RoutingDataSource();
// When
Method declaredMethod = RoutingDataSource.class.getDeclaredMethod(DETERMINE_CURRENT_LOOKUP_KEY.getValue());
declaredMethod.setAccessible(true);
Object object = declaredMethod.invoke(routingDataSource);
// Then
log.info("object : [{}]", object);
assertEquals(DataSourceType.MAIN.toString(), object.toString());
}
@Transactional(readOnly = true)
@DisplayName("StandbyDataSource Replication 설정 테스트")
@Test
void testStandbyDataSourceReplication() throws Exception {
// Given
RoutingDataSource routingDataSource = new RoutingDataSource();
// When
Method declaredMethod = RoutingDataSource.class.getDeclaredMethod(DETERMINE_CURRENT_LOOKUP_KEY.getValue());
declaredMethod.setAccessible(true);
Object object = declaredMethod.invoke(routingDataSource);
// Then
log.info("object : [{}]", object);
assertEquals(DataSourceType.STANDBY.toString(), object.toString());
}
}